diff --git a/index.html b/index.html index 3c91a47..de48018 100644 --- a/index.html +++ b/index.html @@ -5,8 +5,11 @@ + + + Red Bull Intake Tracker diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..8da5f71 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..edc5ec4 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..6c4a8db --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,22 @@ +{ + "name": "Red Bull Intake Tracker", + "short_name": "Intake", + "description": "Track Red Bull and caffeine intake — cans, spending, flavours, and trends — synced across devices.", + "start_url": ".", + "display": "standalone", + "background_color": "#eef3fb", + "theme_color": "#2563c7", + "icons": [ + { + "src": "icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/src/App.tsx b/src/App.tsx index 103344e..a56e0ba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,6 +51,7 @@ import { } from "./lib/userLimits"; import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel"; import { buildEntryDerived, groupByDay, groupByFlavour, groupByWeek, humanDateTime, makeId } from "./lib/metrics"; +import { clearCachedEntries, readCachedEntries, writeCachedEntries } from "./lib/entryCache"; import { buildJsonImportPreview, exportPayload, parseImport } from "./lib/storage"; import { useToasts } from "./lib/toasts"; import type { @@ -113,6 +114,7 @@ function App() { const jsonFileInputRef = useRef(null); const importInFlightRef = useRef(false); const refreshDebounceRef = useRef(null); + const cacheHydratedRef = useRef(false); useEffect(() => { localStorage.setItem(THEME_STORAGE_KEY, normaliseThemeId(themeId)); @@ -131,21 +133,31 @@ function App() { return () => media.removeEventListener("change", handleChange); }, []); - const refreshEntries = useCallback(async (userId: string, showLoader = true) => { - if (showLoader) setDataLoading(true); - setSyncError(""); - try { - const remoteEntries = await listEntries(userId); - setEntries(sortEntries(remoteEntries)); - setNotice(`Synced ${remoteEntries.length} Appwrite entr${remoteEntries.length === 1 ? "y" : "ies"}.`); - } catch (error) { - const message = appwriteErrorMessage(error); - setSyncError(message); - setNotice("Appwrite sync failed."); - } finally { - if (showLoader) setDataLoading(false); - } - }, []); + const refreshEntries = useCallback( + async (userId: string, showLoader = true) => { + if (showLoader) setDataLoading(true); + setSyncError(""); + try { + const remoteEntries = await listEntries(userId); + setEntries(sortEntries(remoteEntries)); + writeCachedEntries(userId, remoteEntries); + cacheHydratedRef.current = false; + setNotice(`Synced ${remoteEntries.length} Appwrite entr${remoteEntries.length === 1 ? "y" : "ies"}.`); + } catch (error) { + if (cacheHydratedRef.current) { + // Cached entries are on screen, so one info toast replaces the generic error toast. + pushToast({ tone: "info", message: "Couldn't reach the server — showing saved data" }); + } else { + const message = appwriteErrorMessage(error); + setSyncError(message); + setNotice("Appwrite sync failed."); + } + } finally { + if (showLoader) setDataLoading(false); + } + }, + [pushToast], + ); useEffect(() => { let mounted = true; @@ -197,9 +209,16 @@ function App() { useEffect(() => { if (!user) { + cacheHydratedRef.current = false; setEntries([]); return; } + const cachedEntries = readCachedEntries(user.$id); + cacheHydratedRef.current = cachedEntries !== null; + if (cachedEntries) { + // Render the last synced entries immediately; the refresh below replaces them. + setEntries(sortEntries(cachedEntries)); + } void refreshEntries(user.$id); }, [refreshEntries, user]); @@ -314,6 +333,7 @@ function App() { setSyncError(""); try { await account.deleteSession({ sessionId: "current" }); + if (user) clearCachedEntries(user.$id); setUser(null); setEntries([]); setNotice("Logged out."); diff --git a/src/lib/entryCache.ts b/src/lib/entryCache.ts new file mode 100644 index 0000000..3b17799 --- /dev/null +++ b/src/lib/entryCache.ts @@ -0,0 +1,52 @@ +import type { RedBullEntry } from "../types"; + +const ENTRY_CACHE_KEY_PREFIX = "red-bull-intake-tracker.entries.v1."; + +function entryCacheKey(userId: string): string { + return `${ENTRY_CACHE_KEY_PREFIX}${userId}`; +} + +// Best-effort read-through cache: entries persist per user so the app can +// render the last synced state while a network refresh is still in flight. + +export function readCachedEntries(userId: string): RedBullEntry[] | null { + try { + const raw = localStorage.getItem(entryCacheKey(userId)); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return null; + const entries = parsed.filter(isEntryShape); + return entries.length ? entries : null; + } catch { + return null; + } +} + +export function writeCachedEntries(userId: string, entries: RedBullEntry[]): void { + try { + localStorage.setItem(entryCacheKey(userId), JSON.stringify(entries)); + } catch { + // Quota and serialization failures are non-fatal; the cache is best-effort. + } +} + +export function clearCachedEntries(userId: string): void { + try { + localStorage.removeItem(entryCacheKey(userId)); + } catch { + // Storage failures are non-fatal. + } +} + +function isEntryShape(value: unknown): value is RedBullEntry { + if (!value || typeof value !== "object") return false; + const entry = value as Partial; + return ( + typeof entry.id === "string" && + typeof entry.dateTime === "string" && + typeof entry.flavour === "string" && + typeof entry.cans === "number" && + typeof entry.sizeMl === "number" && + typeof entry.pricePerCan === "number" + ); +}