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
This commit is contained in:
nh9961
2026-07-19 21:19:43 +00:00
parent 767967f25d
commit 7f64eec207
6 changed files with 113 additions and 16 deletions
+4 -1
View File
@@ -5,8 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta <meta
name="description" name="description"
content="A local-first Red Bull intake web app for tracking cans, spending, caffeine, sugar, flavours, and trends." content="A Red Bull intake tracker for logging cans, spending, caffeine, sugar, flavours, and trends, synced across devices with Appwrite Cloud."
/> />
<meta name="theme-color" content="#2563c7" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>Red Bull Intake Tracker</title> <title>Red Bull Intake Tracker</title>
</head> </head>
<body> <body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+22
View File
@@ -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"
}
]
}
+35 -15
View File
@@ -51,6 +51,7 @@ import {
} from "./lib/userLimits"; } from "./lib/userLimits";
import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel"; import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel";
import { buildEntryDerived, groupByDay, groupByFlavour, groupByWeek, humanDateTime, makeId } from "./lib/metrics"; 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 { buildJsonImportPreview, exportPayload, parseImport } from "./lib/storage";
import { useToasts } from "./lib/toasts"; import { useToasts } from "./lib/toasts";
import type { import type {
@@ -113,6 +114,7 @@ function App() {
const jsonFileInputRef = useRef<HTMLInputElement>(null); const jsonFileInputRef = useRef<HTMLInputElement>(null);
const importInFlightRef = useRef(false); const importInFlightRef = useRef(false);
const refreshDebounceRef = useRef<number | null>(null); const refreshDebounceRef = useRef<number | null>(null);
const cacheHydratedRef = useRef(false);
useEffect(() => { useEffect(() => {
localStorage.setItem(THEME_STORAGE_KEY, normaliseThemeId(themeId)); localStorage.setItem(THEME_STORAGE_KEY, normaliseThemeId(themeId));
@@ -131,21 +133,31 @@ function App() {
return () => media.removeEventListener("change", handleChange); return () => media.removeEventListener("change", handleChange);
}, []); }, []);
const refreshEntries = useCallback(async (userId: string, showLoader = true) => { const refreshEntries = useCallback(
if (showLoader) setDataLoading(true); async (userId: string, showLoader = true) => {
setSyncError(""); if (showLoader) setDataLoading(true);
try { setSyncError("");
const remoteEntries = await listEntries(userId); try {
setEntries(sortEntries(remoteEntries)); const remoteEntries = await listEntries(userId);
setNotice(`Synced ${remoteEntries.length} Appwrite entr${remoteEntries.length === 1 ? "y" : "ies"}.`); setEntries(sortEntries(remoteEntries));
} catch (error) { writeCachedEntries(userId, remoteEntries);
const message = appwriteErrorMessage(error); cacheHydratedRef.current = false;
setSyncError(message); setNotice(`Synced ${remoteEntries.length} Appwrite entr${remoteEntries.length === 1 ? "y" : "ies"}.`);
setNotice("Appwrite sync failed."); } catch (error) {
} finally { if (cacheHydratedRef.current) {
if (showLoader) setDataLoading(false); // 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(() => { useEffect(() => {
let mounted = true; let mounted = true;
@@ -197,9 +209,16 @@ function App() {
useEffect(() => { useEffect(() => {
if (!user) { if (!user) {
cacheHydratedRef.current = false;
setEntries([]); setEntries([]);
return; 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); void refreshEntries(user.$id);
}, [refreshEntries, user]); }, [refreshEntries, user]);
@@ -314,6 +333,7 @@ function App() {
setSyncError(""); setSyncError("");
try { try {
await account.deleteSession({ sessionId: "current" }); await account.deleteSession({ sessionId: "current" });
if (user) clearCachedEntries(user.$id);
setUser(null); setUser(null);
setEntries([]); setEntries([]);
setNotice("Logged out."); setNotice("Logged out.");
+52
View File
@@ -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<RedBullEntry>;
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"
);
}