Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/mobile/lib/api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Map<String, dynamic>> _cache = {};

Map<String, dynamic>? peek(String path) => _cache[path];

void clearCache() => _cache.clear();

Future<File> _sessionFile() async {
final dir = await getApplicationSupportDirectory();
if (!await dir.exists()) {
Expand Down Expand Up @@ -109,6 +117,8 @@ class Api {

Future<void> 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');
Expand Down Expand Up @@ -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;
}
}
44 changes: 32 additions & 12 deletions apps/mobile/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -969,8 +969,8 @@ class TodayScreen extends StatefulWidget {
const _meals = ['breakfast', 'lunch', 'dinner', 'snacks'];

class _TodayScreenState extends State<TodayScreen> {
Map<String, dynamic>? day;
Map<String, dynamic>? fasting;
late Map<String, dynamic>? day = widget.api.peek('/api/days/today');
late Map<String, dynamic>? fasting = widget.api.peek('/api/fasting');
String error = '';
Timer? ticker;

Expand Down Expand Up @@ -1020,6 +1020,7 @@ class _TodayScreenState extends State<TodayScreen> {
void initState() {
super.initState();
AppleHealth.synced.addListener(_onHealthSynced);
_ensureTicker();
unawaited(load());
}

Expand Down Expand Up @@ -2004,7 +2005,7 @@ class FastingScreen extends StatefulWidget {
}

class _FastingScreenState extends State<FastingScreen> {
Map<String, dynamic>? fasting;
late Map<String, dynamic>? fasting = widget.api.peek('/api/fasting');
String error = '';
Timer? ticker;

Expand Down Expand Up @@ -2250,7 +2251,7 @@ class WeekScreen extends StatefulWidget {
}

class _WeekScreenState extends State<WeekScreen> {
Map<String, dynamic>? week;
late Map<String, dynamic>? week = widget.api.peek('/api/weeks');
String? on;

@override
Expand All @@ -2260,8 +2261,11 @@ class _WeekScreenState extends State<WeekScreen> {
}

Future<void> 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);
}

Expand Down Expand Up @@ -2548,7 +2552,9 @@ class WeightScreen extends StatefulWidget {
}

class _WeightScreenState extends State<WeightScreen> {
List<Map<String, dynamic>> weights = [];
late List<Map<String, dynamic>> weights =
((widget.api.peek('/api/weights')?['weights'] as List?) ?? const [])
.cast<Map<String, dynamic>>();
final controller = TextEditingController();
String error = '';

Expand Down Expand Up @@ -2759,11 +2765,17 @@ class SettingsScreen extends StatefulWidget {
}

class _SettingsScreenState extends State<SettingsScreen> {
Map<String, dynamic>? profile;
Map<String, dynamic>? fasting;
Map<String, dynamic>? healthState;
final stepGoal = TextEditingController();
final exerciseGoal = TextEditingController();
late Map<String, dynamic>? profile =
(widget.api.peek('/api/profile')?['profile'] as Map?)
?.cast<String, dynamic>();
late Map<String, dynamic>? fasting = widget.api.peek('/api/fasting');
late Map<String, dynamic>? 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;
Expand Down Expand Up @@ -3602,6 +3614,14 @@ class _PermissionsScreenState extends State<PermissionsScreen> {
@override
void initState() {
super.initState();
final cached = widget.api.peek('/api/rbac/permissions');
if (cached != null) {
operations = ((cached['operations'] as List?) ?? []).cast<String>();
permissions = ((cached['permissions'] as Map?) ?? {}).map(
(key, value) => MapEntry(key.toString(), value == true),
);
loading = false;
}
unawaited(load());
}

Expand Down
35 changes: 20 additions & 15 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -459,11 +460,11 @@ const MEAL_COLOR: Record<MealCategory, string> = {

function Today({ canVerifyFoods, onFastingChange }: { canVerifyFoods: boolean; onFastingChange: () => void }) {
const { t } = useI18n();
const [loadedDay, setDay] = useState<DayResponse | null>(null);
const [loadedDay, setDay] = useState<DayResponse | null>(() => peek<DayResponse>("/api/days/today"));
// Removed entry waiting out its Undo window; deleted on the server afterwards.
const [pendingRemoval, setPendingRemoval] = useState<DayEntry | null>(null);
const removalTimer = useRef<number | null>(null);
const [fasting, setFasting] = useState<FastingState | null>(null);
const [fasting, setFasting] = useState<FastingState | null>(() => peek<FastingState>("/api/fasting"));
const [now, setNow] = useState(Date.now());
const [loggingMeal, setLoggingMeal] = useState<MealCategory | null>(null);
const [query, setQuery] = useState("");
Expand Down Expand Up @@ -878,7 +879,7 @@ function mealLabel(t: Copy, meal: MealCategory) {

function FastingPage({ onFastingChange }: { onFastingChange: () => void }) {
const { t } = useI18n();
const [fasting, setFasting] = useState<FastingState | null>(null);
const [fasting, setFasting] = useState<FastingState | null>(() => peek<FastingState>("/api/fasting"));
const [error, setError] = useState("");
const [now, setNow] = useState(Date.now());

Expand Down Expand Up @@ -1102,12 +1103,14 @@ function WeekPage() {
const { t } = useI18n();
const [params, setParams] = useSearchParams();
const on = params.get("on") ?? "";
const [week, setWeek] = useState<WeekResponse | null>(null);
const weekPath = `/api/weeks${on ? `?on=${on}` : ""}`;
const [week, setWeek] = useState<WeekResponse | null>(() => peek<WeekResponse>(weekPath));
const [error, setError] = useState("");
useEffect(() => {
const query = on ? `?on=${on}` : "";
api<WeekResponse>(`/api/weeks${query}`).then(setWeek).catch((err) => setError(message(t, err)));
}, [on, t]);
const cached = peek<WeekResponse>(weekPath);
if (cached) setWeek(cached);
api<WeekResponse>(weekPath).then(setWeek).catch((err) => setError(message(t, err)));
}, [weekPath, t]);
if (!week) return error ? <p className="error">{error}</p> : <p className="muted loading">…</p>;
const today = todayJerusalemDay();
const isCurrentWeek = addDays(week.sunday, 7) > today;
Expand Down Expand Up @@ -1176,7 +1179,7 @@ function formatKcal(value: number): string {

function WeightPage() {
const { t } = useI18n();
const [weights, setWeights] = useState<WeightPoint[]>([]);
const [weights, setWeights] = useState<WeightPoint[]>(() => peek<{ weights: WeightPoint[] }>("/api/weights")?.weights ?? []);
const [error, setError] = useState("");
async function load() {
const data = await api<{ weights: WeightPoint[] }>("/api/weights");
Expand Down Expand Up @@ -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<Profile | null>(null);
const [fasting, setFasting] = useState<FastingState | null>(null);
const [fastingEnabledDraft, setFastingEnabledDraft] = useState(false);
const [reminderOffsets, setReminderOffsets] = useState<ReminderOffset[]>([0]);
const cachedProfile = peek<{ profile: Profile }>("/api/profile");
const cachedFasting = peek<FastingState>("/api/fasting");
const [profile, setProfile] = useState<Profile | null>(cachedProfile?.profile ?? null);
const [fasting, setFasting] = useState<FastingState | null>(cachedFasting);
const [fastingEnabledDraft, setFastingEnabledDraft] = useState(cachedFasting?.enabled ?? false);
const [reminderOffsets, setReminderOffsets] = useState<ReminderOffset[]>(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<AppleHealthState | null>(null);
const [health, setHealth] = useState<AppleHealthState | null>(() => peek<AppleHealthState>("/api/apple-health"));
const [error, setError] = useState("");
const pwChecks = checkSignupPassword(newPassword, confirmPassword);
const canReadHealth = me.permissions.includes("appleHealth:read");
Expand Down Expand Up @@ -1623,7 +1628,7 @@ function Chevron({ back = false }: { back?: boolean }) {

function PermissionsPage() {
const { t } = useI18n();
const [permissions, setPermissions] = useState<Record<Operation, boolean> | null>(null);
const [permissions, setPermissions] = useState<Record<Operation, boolean> | null>(() => peek<PermissionsResponse>("/api/rbac/permissions")?.permissions ?? null);
const [error, setError] = useState("");

useEffect(() => {
Expand Down Expand Up @@ -1684,7 +1689,7 @@ function FoodForm() {
const { id } = useParams();
const [params] = useSearchParams();
const navigate = useNavigate();
const [food, setFood] = useState<Food | null>(null);
const [food, setFood] = useState<Food | null>(() => (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");
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>();

export function peek<T>(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);
Expand Down Expand Up @@ -51,6 +65,7 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
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;
}

Expand Down
Loading