From 7f64eec207f7e30232edf93816b56b7074f7ee9b Mon Sep 17 00:00:00 2001 From: nh9961 Date: Sun, 19 Jul 2026 21:19:43 +0000 Subject: [PATCH] feat(pwa): web manifest, icons, read-through entry cache - Add manifest.webmanifest, generated 192/512 icons, theme-color, apple-touch-icon - Cache entries in localStorage per user; hydrate on load before network refresh - Single info toast when offline on cached data; cache cleared on logout - Fix inaccurate local-first meta description --- index.html | 5 +++- public/icons/icon-192.png | Bin 0 -> 1488 bytes public/icons/icon-512.png | Bin 0 -> 4377 bytes public/manifest.webmanifest | 22 +++++++++++++++ src/App.tsx | 50 +++++++++++++++++++++++----------- src/lib/entryCache.ts | 52 ++++++++++++++++++++++++++++++++++++ 6 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/manifest.webmanifest create mode 100644 src/lib/entryCache.ts 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 0000000000000000000000000000000000000000..8da5f71b3049116a26c578eff7df0aba027ecf11 GIT binary patch literal 1488 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 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" + ); +}