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
Loading…
; - 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}