From d589158addbe665d624b53e77d860fb65584eb24 Mon Sep 17 00:00:00 2001 From: Yarin Levi <41652571+YarinQuapi@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:37:15 +0200 Subject: [PATCH] Cache loaded data on web and mobile for instant page switches GET responses are kept in memory and screens seed their state from them, then revalidate in the background. The cache is cleared on login, logout and session expiry. Co-Authored-By: Claude Sonnet 5.5 --- apps/mobile/lib/api.dart | 11 ++++++++++ apps/mobile/lib/main.dart | 44 ++++++++++++++++++++++++++++----------- apps/web/src/App.tsx | 35 ++++++++++++++++++------------- apps/web/src/api.ts | 15 +++++++++++++ 4 files changed, 78 insertions(+), 27 deletions(-) diff --git a/apps/mobile/lib/api.dart b/apps/mobile/lib/api.dart index faf3011..aa46a27 100644 --- a/apps/mobile/lib/api.dart +++ b/apps/mobile/lib/api.dart @@ -25,6 +25,14 @@ class Api { final FlutterSecureStorage _storage = const FlutterSecureStorage(); String? token; + /// Last successful GET response per path. Screens seed their state from it so + /// switching tabs shows data instantly while a fresh copy loads behind it. + final Map> _cache = {}; + + Map? peek(String path) => _cache[path]; + + void clearCache() => _cache.clear(); + Future _sessionFile() async { final dir = await getApplicationSupportDirectory(); if (!await dir.exists()) { @@ -109,6 +117,8 @@ class Api { Future setToken(String? value) async { token = value; + // Login, logout and session expiry change whose data the cache holds. + clearCache(); try { if (value == null) { await _storage.delete(key: 'token'); @@ -176,6 +186,7 @@ class Api { if (sent.statusCode >= 400) { throw ApiException(data['error'] as String? ?? 'server_error', statusCode: sent.statusCode); } + if (method.toUpperCase() == 'GET') _cache[path] = data; return data; } } diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart index 665857e..319d5be 100644 --- a/apps/mobile/lib/main.dart +++ b/apps/mobile/lib/main.dart @@ -969,8 +969,8 @@ class TodayScreen extends StatefulWidget { const _meals = ['breakfast', 'lunch', 'dinner', 'snacks']; class _TodayScreenState extends State { - Map? day; - Map? fasting; + late Map? day = widget.api.peek('/api/days/today'); + late Map? fasting = widget.api.peek('/api/fasting'); String error = ''; Timer? ticker; @@ -1020,6 +1020,7 @@ class _TodayScreenState extends State { void initState() { super.initState(); AppleHealth.synced.addListener(_onHealthSynced); + _ensureTicker(); unawaited(load()); } @@ -2004,7 +2005,7 @@ class FastingScreen extends StatefulWidget { } class _FastingScreenState extends State { - Map? fasting; + late Map? fasting = widget.api.peek('/api/fasting'); String error = ''; Timer? ticker; @@ -2250,7 +2251,7 @@ class WeekScreen extends StatefulWidget { } class _WeekScreenState extends State { - Map? week; + late Map? week = widget.api.peek('/api/weeks'); String? on; @override @@ -2260,8 +2261,11 @@ class _WeekScreenState extends State { } Future load() async { - final query = on == null ? '' : '?on=$on'; - final data = await widget.api.send('GET', '/api/weeks$query'); + final path = '/api/weeks${on == null ? '' : '?on=$on'}'; + final cached = widget.api.peek(path); + if (cached != null && week != cached) setState(() => week = cached); + final data = await widget.api.send('GET', path); + if (!mounted || path != '/api/weeks${on == null ? '' : '?on=$on'}') return; setState(() => week = data); } @@ -2548,7 +2552,9 @@ class WeightScreen extends StatefulWidget { } class _WeightScreenState extends State { - List> weights = []; + late List> weights = + ((widget.api.peek('/api/weights')?['weights'] as List?) ?? const []) + .cast>(); final controller = TextEditingController(); String error = ''; @@ -2759,11 +2765,17 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State { - Map? profile; - Map? fasting; - Map? healthState; - final stepGoal = TextEditingController(); - final exerciseGoal = TextEditingController(); + late Map? profile = + (widget.api.peek('/api/profile')?['profile'] as Map?) + ?.cast(); + late Map? fasting = widget.api.peek('/api/fasting'); + late Map? healthState = widget.api.peek('/api/apple-health'); + late final stepGoal = TextEditingController( + text: healthState == null ? '' : '${healthState!['stepGoal']}', + ); + late final exerciseGoal = TextEditingController( + text: healthState == null ? '' : '${healthState!['exerciseGoalMinutes']}', + ); String healthError = ''; bool healthBusy = false; bool autoPublish = false; @@ -3602,6 +3614,14 @@ class _PermissionsScreenState extends State { @override void initState() { super.initState(); + final cached = widget.api.peek('/api/rbac/permissions'); + if (cached != null) { + operations = ((cached['operations'] as List?) ?? []).cast(); + permissions = ((cached['permissions'] as Map?) ?? {}).map( + (key, value) => MapEntry(key.toString(), value == true), + ); + loading = false; + } unawaited(load()); } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 05c8ffa..a0605f6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; import { Link, NavLink, Navigate, Route, Routes, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { api, + peek, ApiError, type AppleHealthState, type DayEntry, @@ -459,11 +460,11 @@ const MEAL_COLOR: Record = { function Today({ canVerifyFoods, onFastingChange }: { canVerifyFoods: boolean; onFastingChange: () => void }) { const { t } = useI18n(); - const [loadedDay, setDay] = useState(null); + const [loadedDay, setDay] = useState(() => peek("/api/days/today")); // Removed entry waiting out its Undo window; deleted on the server afterwards. const [pendingRemoval, setPendingRemoval] = useState(null); const removalTimer = useRef(null); - const [fasting, setFasting] = useState(null); + const [fasting, setFasting] = useState(() => peek("/api/fasting")); const [now, setNow] = useState(Date.now()); const [loggingMeal, setLoggingMeal] = useState(null); const [query, setQuery] = useState(""); @@ -878,7 +879,7 @@ function mealLabel(t: Copy, meal: MealCategory) { function FastingPage({ onFastingChange }: { onFastingChange: () => void }) { const { t } = useI18n(); - const [fasting, setFasting] = useState(null); + const [fasting, setFasting] = useState(() => peek("/api/fasting")); const [error, setError] = useState(""); const [now, setNow] = useState(Date.now()); @@ -1102,12 +1103,14 @@ function WeekPage() { const { t } = useI18n(); const [params, setParams] = useSearchParams(); const on = params.get("on") ?? ""; - const [week, setWeek] = useState(null); + const weekPath = `/api/weeks${on ? `?on=${on}` : ""}`; + const [week, setWeek] = useState(() => peek(weekPath)); const [error, setError] = useState(""); useEffect(() => { - const query = on ? `?on=${on}` : ""; - api(`/api/weeks${query}`).then(setWeek).catch((err) => setError(message(t, err))); - }, [on, t]); + const cached = peek(weekPath); + if (cached) setWeek(cached); + api(weekPath).then(setWeek).catch((err) => setError(message(t, err))); + }, [weekPath, t]); if (!week) return error ?

{error}

:

…

; const today = todayJerusalemDay(); const isCurrentWeek = addDays(week.sunday, 7) > today; @@ -1176,7 +1179,7 @@ function formatKcal(value: number): string { function WeightPage() { const { t } = useI18n(); - const [weights, setWeights] = useState([]); + const [weights, setWeights] = useState(() => peek<{ weights: WeightPoint[] }>("/api/weights")?.weights ?? []); const [error, setError] = useState(""); async function load() { const data = await api<{ weights: WeightPoint[] }>("/api/weights"); @@ -1258,17 +1261,19 @@ function SettingsPage({ me, setMe, onFastingChange, onLogout }: { me: Me; setMe: const { section } = useParams(); const navigate = useNavigate(); const { mode: themeMode, setMode: setThemeMode } = useTheme(); - const [profile, setProfile] = useState(null); - const [fasting, setFasting] = useState(null); - const [fastingEnabledDraft, setFastingEnabledDraft] = useState(false); - const [reminderOffsets, setReminderOffsets] = useState([0]); + const cachedProfile = peek<{ profile: Profile }>("/api/profile"); + const cachedFasting = peek("/api/fasting"); + const [profile, setProfile] = useState(cachedProfile?.profile ?? null); + const [fasting, setFasting] = useState(cachedFasting); + const [fastingEnabledDraft, setFastingEnabledDraft] = useState(cachedFasting?.enabled ?? false); + const [reminderOffsets, setReminderOffsets] = useState(cachedFasting?.reminderOffsets ?? [0]); const [autoPublish, setAutoPublish] = useState(me.user.autoPublishFoods); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [passwordStatus, setPasswordStatus] = useState(""); const [passwordError, setPasswordError] = useState(""); - const [health, setHealth] = useState(null); + const [health, setHealth] = useState(() => peek("/api/apple-health")); const [error, setError] = useState(""); const pwChecks = checkSignupPassword(newPassword, confirmPassword); const canReadHealth = me.permissions.includes("appleHealth:read"); @@ -1623,7 +1628,7 @@ function Chevron({ back = false }: { back?: boolean }) { function PermissionsPage() { const { t } = useI18n(); - const [permissions, setPermissions] = useState | null>(null); + const [permissions, setPermissions] = useState | null>(() => peek("/api/rbac/permissions")?.permissions ?? null); const [error, setError] = useState(""); useEffect(() => { @@ -1684,7 +1689,7 @@ function FoodForm() { const { id } = useParams(); const [params] = useSearchParams(); const navigate = useNavigate(); - const [food, setFood] = useState(null); + const [food, setFood] = useState(() => (id ? peek<{ food: Food }>(`/api/foods/${id}`)?.food ?? null : null)); const [error, setError] = useState(""); const barcode = params.get("barcode") ?? food?.barcode ?? ""; const returnMeal = params.get("meal"); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 61feb80..dcbd2f5 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -17,7 +17,21 @@ export function getStoredToken(): string | null { } } +// Last successful GET response per path. Pages seed their state from it so +// navigating back shows data instantly while a fresh copy loads behind it. +const responseCache = new Map(); + +export function peek(path: string): T | null { + return (responseCache.get(path) as T | undefined) ?? null; +} + +export function clearApiCache(): void { + responseCache.clear(); +} + export function setStoredToken(token: string | null): void { + // Login, logout and session expiry all change who the cached data belongs to. + clearApiCache(); try { if (token) { localStorage.setItem(TOKEN_KEY, token); @@ -51,6 +65,7 @@ export async function api(path: string, init: RequestInit = {}): Promise { if (typeof data.token === "string" && data.token.length > 0) { setStoredToken(data.token); } + if (!init.method || init.method.toUpperCase() === "GET") responseCache.set(path, data); return data as T; }