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"
+ );
+}