diff --git a/README.md b/README.md
index f8be39e..8cf13da 100644
--- a/README.md
+++ b/README.md
@@ -202,3 +202,7 @@ When deploying the Brew application to a production environment, follow these gu
}
}
```
+
+> [!IMPORTANT]
+> **HTTPS Required for PWAs**: For security reasons, web browsers will only install Progressive Web Apps (PWAs) and register Service Workers when served over a secure connection (`HTTPS`). Make sure to set up an SSL certificate (e.g., via Let's Encrypt / Certbot) for your production deployment domain. Local development on `localhost` or `127.0.0.1` is exempt and will work over HTTP.
+
diff --git a/index.html b/index.html
index 3f58e2b..904fde8 100644
--- a/index.html
+++ b/index.html
@@ -4,6 +4,9 @@
+
+
+
brew
diff --git a/public/icon-192.png b/public/icon-192.png
new file mode 100644
index 0000000..d0b94e6
Binary files /dev/null and b/public/icon-192.png differ
diff --git a/public/icon-512.png b/public/icon-512.png
new file mode 100644
index 0000000..d0b94e6
Binary files /dev/null and b/public/icon-512.png differ
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 0000000..9c0b18c
--- /dev/null
+++ b/public/manifest.json
@@ -0,0 +1,30 @@
+{
+ "name": "Brew Journal",
+ "short_name": "Brew",
+ "description": "Log and track your coffee brew recipes offline-first.",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#FAF6F1",
+ "theme_color": "#2C1810",
+ "orientation": "portrait",
+ "icons": [
+ {
+ "src": "favicon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ },
+ {
+ "src": "icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable any"
+ },
+ {
+ "src": "icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable any"
+ }
+ ]
+}
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..ee4e211
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,80 @@
+const CACHE_NAME = 'brew-journal-v1';
+
+// Static assets to cache immediately
+const PRECACHE_ASSETS = [
+ '/',
+ '/index.html',
+ '/manifest.json',
+ '/favicon.svg',
+ '/icon-192.png',
+ '/icon-512.png'
+];
+
+// Install Event
+self.addEventListener('install', (e) => {
+ e.waitUntil(
+ caches.open(CACHE_NAME).then((cache) => {
+ return cache.addAll(PRECACHE_ASSETS);
+ })
+ );
+ self.skipWaiting();
+});
+
+// Activate Event
+self.addEventListener('activate', (e) => {
+ e.waitUntil(
+ caches.keys().then((keys) => {
+ return Promise.all(
+ keys.map((key) => {
+ if (key !== CACHE_NAME) {
+ return caches.delete(key);
+ }
+ })
+ );
+ })
+ );
+ self.clients.claim();
+});
+
+// Fetch Event
+self.addEventListener('fetch', (e) => {
+ const url = new URL(e.request.url);
+
+ // Bypass API requests - always network-only for backend API calls
+ if (url.pathname.startsWith('/api/')) {
+ e.respondWith(fetch(e.request));
+ return;
+ }
+
+ // Stale-while-revalidate for static assets
+ e.respondWith(
+ caches.match(e.request).then((cachedResponse) => {
+ if (cachedResponse) {
+ // Fetch new version in background to update cache
+ fetch(e.request).then((networkResponse) => {
+ if (networkResponse.status === 200) {
+ caches.open(CACHE_NAME).then((cache) => {
+ cache.put(e.request, networkResponse);
+ });
+ }
+ }).catch(() => {/* Ignore network errors offline */});
+
+ return cachedResponse;
+ }
+
+ // Network fallback
+ return fetch(e.request).then((networkResponse) => {
+ if (!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') {
+ return networkResponse;
+ }
+
+ const responseToCache = networkResponse.clone();
+ caches.open(CACHE_NAME).then((cache) => {
+ cache.put(e.request, responseToCache);
+ });
+
+ return networkResponse;
+ });
+ })
+ );
+});
diff --git a/server/db.js b/server/db.js
index c4e27ab..84e8c8b 100644
--- a/server/db.js
+++ b/server/db.js
@@ -7,6 +7,7 @@ const pool = new Pool({
const initDb = async () => {
try {
+ // 1. Users table
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
@@ -16,6 +17,42 @@ const initDb = async () => {
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
+
+ // 2. Beans table
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS beans (
+ id VARCHAR(50) PRIMARY KEY,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ name VARCHAR(255) NOT NULL,
+ roastery VARCHAR(255),
+ tasting_notes TEXT,
+ roast_type VARCHAR(50),
+ roast_date VARCHAR(50),
+ image TEXT,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ is_deleted BOOLEAN DEFAULT FALSE
+ )
+ `);
+
+ // 3. Brew logs table
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS brew_logs (
+ id VARCHAR(50) PRIMARY KEY,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ bean_id VARCHAR(50) REFERENCES beans(id) ON DELETE CASCADE,
+ method VARCHAR(50),
+ grind VARCHAR(100),
+ water_temp VARCHAR(50),
+ ratio VARCHAR(50),
+ yield VARCHAR(50),
+ time VARCHAR(50),
+ notes TEXT,
+ rating INTEGER,
+ created_at BIGINT,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ is_deleted BOOLEAN DEFAULT FALSE
+ )
+ `);
console.log('Database initialized');
} catch (err) {
console.error('Error initializing database', err);
diff --git a/server/index.js b/server/index.js
index e463e87..8328f2b 100644
--- a/server/index.js
+++ b/server/index.js
@@ -129,6 +129,143 @@ app.post('/api/verify-token', authenticateToken, (req, res) => {
res.json({ valid: true, user: req.user });
});
+// Sync data (beans and brew logs)
+app.post('/api/sync', authenticateToken, async (req, res) => {
+ const userId = req.user.id;
+ const { lastSyncedAt, beans = [], brewLogs = [] } = req.body;
+ const serverTime = new Date().toISOString();
+
+ const client = await pool.connect();
+ try {
+ await client.query('BEGIN');
+
+ // 1. Process incoming beans
+ for (const bean of beans) {
+ await client.query(`
+ INSERT INTO beans (id, user_id, name, roastery, tasting_notes, roast_type, roast_date, image, updated_at, is_deleted)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ ON CONFLICT (id) DO UPDATE SET
+ name = EXCLUDED.name,
+ roastery = EXCLUDED.roastery,
+ tasting_notes = EXCLUDED.tasting_notes,
+ roast_type = EXCLUDED.roast_type,
+ roast_date = EXCLUDED.roast_date,
+ image = EXCLUDED.image,
+ updated_at = EXCLUDED.updated_at,
+ is_deleted = EXCLUDED.is_deleted
+ WHERE EXCLUDED.updated_at > beans.updated_at OR beans.user_id IS NULL
+ `, [
+ bean.id,
+ userId,
+ bean.name || '',
+ bean.roastery || '',
+ bean.tastingNotes || '',
+ bean.roastType || '',
+ bean.roastDate || '',
+ bean.image || '',
+ bean.updatedAt ? new Date(bean.updatedAt) : new Date(),
+ bean.isDeleted || false
+ ]);
+ }
+
+ // 2. Process incoming brew logs
+ for (const log of brewLogs) {
+ await client.query(`
+ INSERT INTO brew_logs (id, user_id, bean_id, method, grind, water_temp, ratio, yield, time, notes, rating, created_at, updated_at, is_deleted)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
+ ON CONFLICT (id) DO UPDATE SET
+ bean_id = EXCLUDED.bean_id,
+ method = EXCLUDED.method,
+ grind = EXCLUDED.grind,
+ water_temp = EXCLUDED.water_temp,
+ ratio = EXCLUDED.ratio,
+ yield = EXCLUDED.yield,
+ time = EXCLUDED.time,
+ notes = EXCLUDED.notes,
+ rating = EXCLUDED.rating,
+ created_at = EXCLUDED.created_at,
+ updated_at = EXCLUDED.updated_at,
+ is_deleted = EXCLUDED.is_deleted
+ WHERE EXCLUDED.updated_at > brew_logs.updated_at OR brew_logs.user_id IS NULL
+ `, [
+ log.id,
+ userId,
+ log.beanId,
+ log.method || '',
+ log.grind || '',
+ log.waterTemp || '',
+ log.ratio || '',
+ log.yield || '',
+ log.time || '',
+ log.notes || '',
+ log.rating || 0,
+ log.createdAt ? BigInt(log.createdAt) : BigInt(Date.now()),
+ log.updatedAt ? new Date(log.updatedAt) : new Date(),
+ log.isDeleted || false
+ ]);
+ }
+
+ // 3. Fetch server changes since lastSyncedAt
+ let beansQuery = `SELECT * FROM beans WHERE user_id = $1`;
+ let logsQuery = `SELECT * FROM brew_logs WHERE user_id = $1`;
+ const params = [userId];
+
+ if (lastSyncedAt) {
+ beansQuery += ` AND updated_at > $2`;
+ logsQuery += ` AND updated_at > $2`;
+ params.push(new Date(lastSyncedAt));
+ }
+
+ const serverBeans = await client.query(beansQuery, params);
+ const serverLogs = await client.query(logsQuery, params);
+
+ await client.query('COMMIT');
+
+ // Map DB fields back to camelCase expected by the frontend
+ const mappedBeans = serverBeans.rows.map(b => ({
+ id: b.id,
+ name: b.name,
+ roastery: b.roastery,
+ tastingNotes: b.tasting_notes,
+ roastType: b.roast_type,
+ roastDate: b.roast_date,
+ image: b.image,
+ updatedAt: b.updated_at.toISOString(),
+ isDeleted: b.is_deleted
+ }));
+
+ const mappedLogs = serverLogs.rows.map(l => ({
+ id: l.id,
+ beanId: l.bean_id,
+ method: l.method,
+ grind: l.grind,
+ waterTemp: l.water_temp,
+ ratio: l.ratio,
+ yield: l.yield,
+ time: l.time,
+ notes: l.notes,
+ rating: l.rating,
+ createdAt: Number(l.created_at),
+ updatedAt: l.updated_at.toISOString(),
+ isDeleted: l.is_deleted
+ }));
+
+ res.json({
+ serverTime,
+ beans: mappedBeans,
+ brewLogs: mappedLogs
+ });
+
+ } catch (err) {
+ await client.query('ROLLBACK');
+ console.error('Sync failed', err);
+ res.status(500).json({ error: 'Sync failed' });
+ } finally {
+ client.release();
+ }
+});
+
+
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
diff --git a/src/App.jsx b/src/App.jsx
index 31739a8..ebdf57d 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -3,14 +3,47 @@ import { AuthContext } from "./AuthContext";
import Login from "./Login";
import Register from "./Register";
-// ─── Storage helpers ───
+// ─── Storage helpers with Browser/localStorage Fallback ───
const STORAGE_KEY = "coffee-logbook-data";
-const defaultData = { beans: [], brewLogs: [] };
+const defaultData = { beans: [], brewLogs: [], lastSyncedAt: null };
+
+const storage = {
+ get: async (key) => {
+ try {
+ if (window.storage && typeof window.storage.get === "function") {
+ return await window.storage.get(key);
+ }
+ const val = localStorage.getItem(key);
+ return val ? { value: val } : null;
+ } catch {
+ return null;
+ }
+ },
+ set: async (key, val) => {
+ try {
+ if (window.storage && typeof window.storage.set === "function") {
+ await window.storage.set(key, val);
+ return;
+ }
+ localStorage.setItem(key, val);
+ } catch (e) {
+ console.error("Storage set failed:", e);
+ }
+ }
+};
async function loadData() {
try {
- const result = await window.storage.get(STORAGE_KEY);
- return result ? JSON.parse(result.value) : defaultData;
+ const result = await storage.get(STORAGE_KEY);
+ if (result) {
+ const parsed = JSON.parse(result.value);
+ return {
+ beans: parsed.beans || [],
+ brewLogs: parsed.brewLogs || [],
+ lastSyncedAt: parsed.lastSyncedAt || null
+ };
+ }
+ return defaultData;
} catch {
return defaultData;
}
@@ -18,7 +51,7 @@ async function loadData() {
async function saveData(data) {
try {
- await window.storage.set(STORAGE_KEY, JSON.stringify(data));
+ await storage.set(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
console.error("Save failed:", e);
}
@@ -209,6 +242,7 @@ const styles = `
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
+ @keyframes pulse { 0% { opacity: 0.3; } 50% { opacity: 1; } 100% { opacity: 0.3; } }
.modal {
background: var(--bg);
@@ -727,14 +761,131 @@ export default function CoffeeLogbook() {
const [brewFilter, setBrewFilter] = useState("all");
const [editingBean, setEditingBean] = useState(null);
+ const [isOnline, setIsOnline] = useState(navigator.onLine);
+ const [syncing, setSyncing] = useState(false);
+
+ // Load local data on mount
useEffect(() => {
loadData().then(setData);
}, []);
+ // Sync logic
+ const syncData = useCallback(async (currentData = data) => {
+ if (!token || !currentData) return;
+ if (!navigator.onLine) {
+ setIsOnline(false);
+ return;
+ }
+ setIsOnline(true);
+ setSyncing(true);
+
+ try {
+ const { beans = [], brewLogs = [], lastSyncedAt = null } = currentData;
+
+ const res = await fetch('/api/sync', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ },
+ body: JSON.stringify({
+ lastSyncedAt,
+ beans,
+ brewLogs
+ })
+ });
+
+ if (!res.ok) {
+ throw new Error('Sync failed');
+ }
+
+ const responseData = await res.json();
+ const { serverTime, beans: serverBeans, brewLogs: serverLogs } = responseData;
+
+ // Merge server beans
+ const mergedBeans = [...beans];
+ serverBeans.forEach(sb => {
+ const idx = mergedBeans.findIndex(b => b.id === sb.id);
+ if (idx >= 0) {
+ const localBean = mergedBeans[idx];
+ if (new Date(sb.updatedAt) > new Date(localBean.updatedAt || 0)) {
+ mergedBeans[idx] = sb;
+ }
+ } else {
+ mergedBeans.push(sb);
+ }
+ });
+
+ // Merge server logs
+ const mergedLogs = [...brewLogs];
+ serverLogs.forEach(sl => {
+ const idx = mergedLogs.findIndex(l => l.id === sl.id);
+ if (idx >= 0) {
+ const localLog = mergedLogs[idx];
+ if (new Date(sl.updatedAt) > new Date(localLog.updatedAt || 0)) {
+ mergedLogs[idx] = sl;
+ }
+ } else {
+ mergedLogs.push(sl);
+ }
+ });
+
+ // Purge soft-deleted items that are successfully synced to the database
+ const finalBeans = mergedBeans.filter(b => !(b.isDeleted && new Date(b.updatedAt) <= new Date(serverTime)));
+ const finalLogs = mergedLogs.filter(l => !(l.isDeleted && new Date(l.updatedAt) <= new Date(serverTime)));
+
+ const nextData = {
+ beans: finalBeans,
+ brewLogs: finalLogs,
+ lastSyncedAt: serverTime
+ };
+
+ setData(nextData);
+ await saveData(nextData);
+ } catch (err) {
+ console.error('Failed to sync data:', err);
+ } finally {
+ setSyncing(false);
+ }
+ }, [token, data]);
+
+ // Periodic and connectivity event-driven sync triggers
+ useEffect(() => {
+ const handleOnline = () => {
+ setIsOnline(true);
+ syncData();
+ };
+ const handleOffline = () => {
+ setIsOnline(false);
+ };
+
+ window.addEventListener('online', handleOnline);
+ window.addEventListener('offline', handleOffline);
+
+ // Initial sync once token and data are loaded
+ if (token && data) {
+ syncData();
+ }
+
+ // Sync every 30 seconds
+ const interval = setInterval(() => {
+ if (token && data) {
+ syncData();
+ }
+ }, 30000);
+
+ return () => {
+ window.removeEventListener('online', handleOnline);
+ window.removeEventListener('offline', handleOffline);
+ clearInterval(interval);
+ };
+ }, [token, data === null, syncData]);
+
const persist = useCallback(async (newData) => {
setData(newData);
await saveData(newData);
- }, []);
+ syncData(newData);
+ }, [syncData]);
if (loading) {
return (
@@ -788,33 +939,45 @@ export default function CoffeeLogbook() {
if (!data) return ;
- const { beans, brewLogs } = data;
+ // Filter out soft-deleted items for UI rendering
+ const allBeans = data.beans || [];
+ const allLogs = data.brewLogs || [];
+
+ const beans = allBeans.filter(b => !b.isDeleted);
+ const brewLogs = allLogs.filter(l => !l.isDeleted);
const addBean = (form) => {
- const bean = { id: uid(), ...form };
- persist({ ...data, beans: [...beans, bean] });
+ const now = new Date().toISOString();
+ const bean = { id: uid(), ...form, updatedAt: now, isDeleted: false };
+ persist({ ...data, beans: [...allBeans, bean] });
setModal(null);
};
const updateBean = (form) => {
- persist({ ...data, beans: beans.map(b => b.id === editingBean.id ? { ...b, ...form } : b) });
+ const now = new Date().toISOString();
+ const nextBeans = allBeans.map(b => b.id === editingBean.id ? { ...b, ...form, updatedAt: now } : b);
+ persist({ ...data, beans: nextBeans });
setModal(null);
setEditingBean(null);
- if (selectedBean?.id === editingBean.id) setSelectedBean({ ...selectedBean, ...form });
+ if (selectedBean?.id === editingBean.id) setSelectedBean({ ...selectedBean, ...form, updatedAt: now });
};
const deleteBean = (id) => {
- persist({ ...data, beans: beans.filter(b => b.id !== id), brewLogs: brewLogs.filter(l => l.beanId !== id) });
+ const now = new Date().toISOString();
+ const nextBeans = allBeans.map(b => b.id === id ? { ...b, isDeleted: true, updatedAt: now } : b);
+ const nextLogs = allLogs.map(l => l.beanId === id ? { ...l, isDeleted: true, updatedAt: now } : l);
+ persist({ ...data, beans: nextBeans, brewLogs: nextLogs });
setSelectedBean(null);
};
const addBrew = (form) => {
- const log = { id: uid(), createdAt: Date.now(), ...form };
- persist({ ...data, brewLogs: [...brewLogs, log] });
+ const now = new Date().toISOString();
+ const log = { id: uid(), createdAt: Date.now(), ...form, updatedAt: now, isDeleted: false };
+ persist({ ...data, brewLogs: [...allLogs, log] });
setModal(null);
};
- const getBeanName = (id) => beans.find(b => b.id === id)?.name || "Unknown Bean";
+ const getBeanName = (id) => allBeans.find(b => b.id === id)?.name || "Unknown Bean";
const filteredLogs = (brewFilter === "all" ? brewLogs : brewLogs.filter(l => l.method === brewFilter))
.sort((a, b) => b.createdAt - a.createdAt);
@@ -833,6 +996,24 @@ export default function CoffeeLogbook() {
Brew Journal
+
+ ●
+ {isOnline ? (syncing ? "Syncing..." : "Synced") : "Offline"}
+
{user?.username}
logout()}
diff --git a/src/main.jsx b/src/main.jsx
index 6a139d3..eb0bcc1 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -11,3 +11,12 @@ createRoot(document.getElementById('root')).render(
,
)
+
+if ('serviceWorker' in navigator) {
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register('/sw.js')
+ .then(reg => console.log('Service Worker registered', reg))
+ .catch(err => console.error('Service Worker registration failed', err));
+ });
+}
+