This commit is contained in:
2026-06-06 09:49:43 +05:30
parent cb81d476a8
commit 6e5ce9eb86
10 changed files with 496 additions and 15 deletions

View File

@@ -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.

View File

@@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#2C1810" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>brew</title>
</head>
<body>

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

30
public/manifest.json Normal file
View File

@@ -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"
}
]
}

80
public/sw.js Normal file
View File

@@ -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;
});
})
);
});

View File

@@ -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);

View File

@@ -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}`);
});

View File

@@ -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 <div className="app" style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: "100vh" }}><div style={{ textAlign: "center", color: "var(--text3)" }}><div style={{ fontSize: 32 }}>☕</div><div style={{ marginTop: 8 }}>Loading…</div></div></div>;
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() {
<h1>Brew Journal</h1>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
fontSize: "11px",
fontWeight: "500",
color: isOnline ? "#4A7C59" : "#B44040",
background: isOnline ? "rgba(74,124,89,0.08)" : "rgba(180,64,64,0.08)",
padding: "4px 8px",
borderRadius: "12px",
transition: "all 0.3s"
}}
title={isOnline ? "All local logs are synchronized with PostgreSQL database" : "No internet connection. Saved locally."}
>
<span style={{ fontSize: "10px", animation: syncing ? "pulse 1.5s infinite" : "none" }}>●</span>
<span>{isOnline ? (syncing ? "Syncing..." : "Synced") : "Offline"}</span>
</div>
<span style={{ fontSize: "12px", color: "var(--text2)" }}>{user?.username}</span>
<button
onClick={() => logout()}

View File

@@ -11,3 +11,12 @@ createRoot(document.getElementById('root')).render(
</AuthProvider>
</StrictMode>,
)
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));
});
}