pwa ops
This commit is contained in:
211
src/App.jsx
211
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 <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()}
|
||||
|
||||
Reference in New Issue
Block a user