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="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 |
@@ -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
@@ -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.");
|
||||||
|
|||||||
@@ -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