Files
MathApp_frontend/src/state/AppData.tsx
AliReza f7009b11e8 Instant home on reopen: snapshot of profile/grades/map/stats on the device, refreshed behind the scenes
A signed-in user with a snapshot goes straight to home; the same boot requests run in the
background and replace it. Snapshot is cleared whenever tokens are cleared and on a new login,
ignored from another build or when older than 7 days, and dropped when the fresh profile is a
different user (reboot remounts MainApp via a generation key; AnimatePresence otherwise kept the
exiting one). Notifications/characters wait for the profile answer so an expired access token
costs the same requests as before. refreshHome applies only the latest answer. Home shows a
"saved data" strip when the map could not be refreshed.

Measured locally (old vs new build, same backend, Chrome throttling, median of 3, time to map):
Fast 3G 2.46s -> 1.80s (expired token 3.63s -> 1.80s); Slow 3G 8.52s -> 6.14s (expired 12.6s ->
6.13s); API requests per open unchanged (6 valid, 11 expired). 12/12 behaviour checks passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:57:42 +03:30

225 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { api, type Character, type Grade, type HomeResponse, type UserProfile, type UserSummary } from '@/api';
import { seyarFromSlug, type SeyarChar } from '@/mascots/seyar';
import { companionStore, useCompanion } from '@/state/companion';
import { homeSnapshot } from '@/state/homeSnapshot';
import { fetchHome, subjectStore } from '@/state/subject';
/**
* داده‌هایی که چند صفحه با هم لازم دارند: پروفایل، آمار، نقشه، همراهِ انتخابی
* و تعدادِ اعلانِ نخوانده. هر صفحه بعد از کاری که این‌ها را عوض می‌کند
* (پایانِ درس، عوض کردنِ پایه، …) refresh را صدا می‌زند.
*/
interface AppData {
profile: UserProfile;
grades: Grade[];
gradeTitle: string;
summary: UserSummary | null;
home: HomeResponse | null;
homeError: unknown;
/** همراهی که کاربر در صفحه‌ی کاراکترها انتخاب کرده؛ پیش‌فرض سیگما. */
companion: SeyarChar;
unreadCount: number;
/** `gradeId` را فقط وقتی بدهید که پایه همین الان عوض شده و state هنوز رندر نشده. */
refreshHome: (gradeId?: number) => Promise<void>;
/** عوضِ کتابِ نقشه (هندسه، حسابان، …)؛ آزمون‌ها و درس‌ها هر دو همین را می‌خوانند. */
selectSubject: (key: string) => void;
/** کتابی که روی `home` است؛ بی‌نقشه (هنوز نرسیده) null. */
subjectKey: string | null;
refreshSummary: () => Promise<void>;
refreshUnread: () => Promise<void>;
setProfile: (profile: UserProfile) => void;
setCompanion: (character: Character) => void;
setUnreadCount: (count: number) => void;
}
const AppDataContext = createContext<AppData | null>(null);
/** آنچه در اسپلش پیش از ورود به اپ گرفته شد (App.prefetchMain). */
export interface Prefetch {
home: HomeResponse | null;
summary: UserSummary | null;
}
export function AppDataProvider({
initialProfile,
grades: initialGrades,
prefetch,
stale = false,
onIdentityChanged,
children,
}: {
initialProfile: UserProfile;
grades: Grade[];
prefetch?: Prefetch;
/**
* داده‌ی اولیه عکسِ ذخیره‌شده روی گوشی است (state/homeSnapshot.ts)، نه پاسخِ همین حالا: همه‌چیز پشتِ صحنه
* دوباره گرفته می‌شود. همان چهار درخواستِ بوت، پس بارِ سرور بیشتر نمی‌شود؛ فقط کاربر منتظرشان نمی‌ماند.
*/
stale?: boolean;
/** پروفایلِ تازه با عکس نمی‌خواند (کاربرِ دیگر، یا بی‌نام/بی‌پایه که باید به تکمیلِ پروفایل برود). */
onIdentityChanged?: () => void;
children: ReactNode;
}) {
const [profile, setProfile] = useState(initialProfile);
const [grades, setGrades] = useState(initialGrades);
const [summary, setSummary] = useState<UserSummary | null>(prefetch?.summary ?? null);
const [home, setHome] = useState<HomeResponse | null>(prefetch?.home ?? null);
const [homeError, setHomeError] = useState<unknown>(null);
// همراه در companionStore است تا بیرون از AppData (اسپلش، نشان، لودرها) هم خوانده شود.
const companion = useCompanion().char;
const setCompanionChar = companionStore.set;
const [unreadCount, setUnreadCount] = useState(0);
const gradeId = profile.gradeId;
// نقشه‌ی کتاب‌هایی که دیده شده؛ جابه‌جایی به کتابِ دیده‌شده فوری است و پشت‌صحنه تازه می‌شود.
const homeCache = useRef(new Map<string, HomeResponse>());
// فقط پاسخِ آخرین درخواست روی صفحه می‌نشیند: وقتی پایه یا کتاب وسطِ راه عوض شود (مثلاً پروفایلِ تازه‌ی
// پشتِ صحنه پایه‌ی دیگری گفت)، پاسخِ کندترِ درخواستِ قبلی نباید نقشه‌ی تازه را بپوشاند.
const homeSeq = useRef(0);
const refreshHome = useCallback(async (forGrade?: number) => {
const target = forGrade ?? gradeId;
const seq = ++homeSeq.current;
try {
const fresh = await fetchHome(target);
if (fresh.subject) homeCache.current.set(`${target}:${fresh.subject.key}`, fresh);
if (seq !== homeSeq.current) return;
setHome(fresh);
setHomeError(null);
} catch (err) {
if (seq === homeSeq.current) setHomeError(err);
}
}, [gradeId]);
const selectSubject = useCallback(
(key: string) => {
if (gradeId == null) return;
subjectStore.set(gradeId, key);
const cached = homeCache.current.get(`${gradeId}:${key}`);
// کتابِ دیده‌شده فوری می‌آید؛ دیده‌نشده یک لحظه «در حال آوردن» است تا نقشه‌ی کتابِ قبلی زیرِ نامِ
// کتابِ تازه نماند.
setHome(cached ?? null);
setHomeError(null);
void refreshHome();
},
[gradeId, refreshHome],
);
const refreshSummary = useCallback(async () => {
try {
setSummary(await api.summary());
} catch {
/* آمار تزئینی است؛ نبودنش صفحه را خراب نمی‌کند */
}
}, []);
const refreshUnread = useCallback(async () => {
try {
setUnreadCount((await api.notifications.list()).unreadCount);
} catch {
/* نشانِ اعلان اختیاری است */
}
}, []);
// بارِ اول فقط یک بار؛ عوض شدنِ پایه refreshHome را تازه می‌سازد و نباید همه‌ی این‌ها دوباره گرفته شوند.
const started = useRef(false);
// عکس مالِ این کاربر نبود: این AppData تا برداشته شدن دیگر عکس نمی‌نویسد (پاسخِ دیرِ نقشه با پروفایلِ غلط).
const identityLost = useRef(false);
useEffect(() => {
if (started.current) return;
started.current = true;
// اعلان‌ها و همراه برای صفحه‌ی اول لازم نیستند.
const loadExtras = () => {
if (!api.auth.isSignedIn()) return;
void refreshUnread();
api.characters
.list()
.then((list) => {
// بی‌انتخاب یعنی پیش‌فرضِ سرور (سیگما)؛ همراهِ کاربرِ قبلی روی این دستگاه نباید بماند.
setCompanionChar(seyarFromSlug(list.find((c) => c.selected)?.slug) ?? 'sigma');
})
.catch((err: unknown) => console.warn('companion lookup failed; keeping the stored one', err));
};
if (stale) {
api.profile
.get()
.then((fresh) => {
if (fresh.userId !== initialProfile.userId || !fresh.name || fresh.gradeId == null) {
identityLost.current = true;
onIdentityChanged?.();
return false;
}
setProfile(fresh);
// پایه در دستگاهِ دیگری عوض شده؛ نقشه‌ای که هم‌زمان با پایه‌ی عکس گرفته شد به کار نمی‌آید.
if (fresh.gradeId !== initialProfile.gradeId) void refreshHome(fresh.gradeId);
return true;
})
// ۴۰۱ را http خودش به خروج می‌برد. بقیه (اینترنتِ قطع، ۵xx): پروفایلِ عکس می‌ماند و نوارِ «اطلاعاتِ
// ذخیره‌شده» در خانه (homeError) خبر می‌دهد؛ نقشه هم‌زمان همین درخواست‌ها را می‌زند و همان‌جا شکست می‌خورد.
.catch((err: unknown) => {
console.warn('profile refresh failed; showing the stored one', err);
return true;
})
// بعد از پروفایل، نه هم‌زمان: با توکنِ یک‌ساعته‌ی منقضی هر درخواستِ هم‌زمان یک ۴۰۱ و یک تکرار می‌شود؛
// این‌طور تعدادِ درخواست‌ها همان بوتِ قبلی است (۴ + refresh + ۴ تکرار + ۲).
.then((stays) => stays && loadExtras());
api
.grades()
.then(setGrades)
.catch((err: unknown) => console.warn('grades refresh failed; keeping the stored list', err));
void refreshHome();
void refreshSummary();
} else {
// آنچه در اسپلش رسیده دوباره گرفته نمی‌شود؛ فقط کم‌ها.
if (!prefetch?.home) void refreshHome();
if (!prefetch?.summary) void refreshSummary();
loadExtras();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshHome, refreshSummary, refreshUnread]);
// هر داده‌ی تازه عکسِ خانه را به‌روز می‌کند. بارِ اولِ حالتِ stale همان عکسِ خوانده‌شده است و نوشته نمی‌شود،
// تا savedAt فقط با پاسخِ سرور جلو برود.
const skipSnapshotWrite = useRef(stale);
useEffect(() => {
if (skipSnapshotWrite.current) {
skipSnapshotWrite.current = false;
return;
}
// پاسخی که بعد از خروج رسید (توکن پاک شده) نباید عکسِ تازه بسازد.
if (!home || !summary || identityLost.current || !api.auth.isSignedIn()) return;
homeSnapshot.save({ profile, grades, home, summary });
}, [profile, grades, home, summary]);
const value = useMemo<AppData>(
() => ({
profile,
grades,
gradeTitle: grades.find((g) => g.id === profile.gradeId)?.title ?? '',
summary,
home,
homeError,
companion,
unreadCount,
refreshHome,
selectSubject,
subjectKey: home?.subject?.key ?? null,
refreshSummary,
refreshUnread,
setProfile,
setCompanion: (character) => setCompanionChar(seyarFromSlug(character.slug) ?? 'sigma'),
setUnreadCount,
}),
[profile, grades, summary, home, homeError, companion, unreadCount, refreshHome, selectSubject, refreshSummary, refreshUnread],
);
return <AppDataContext.Provider value={value}>{children}</AppDataContext.Provider>;
}
export function useAppData(): AppData {
const data = useContext(AppDataContext);
if (!data) throw new Error('useAppData بیرون از AppDataProvider صدا زده شد');
return data;
}