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:
+4
-1
@@ -5,8 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+22
-2
@@ -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<HTMLInputElement>(null);
|
||||
const importInFlightRef = useRef(false);
|
||||
const refreshDebounceRef = useRef<number | null>(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) => {
|
||||
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.");
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user