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

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