diff --git a/src/components/my-day/my-day-page.tsx b/src/components/my-day/my-day-page.tsx index 4d0a1d4392..79fbfa94ec 100644 --- a/src/components/my-day/my-day-page.tsx +++ b/src/components/my-day/my-day-page.tsx @@ -413,7 +413,10 @@ export function MyDayPage({ now: nowProp }: { now?: Date } = {}) { // When the sources last answered, for "Checked … at 14:05" and the offline note. Set when the // answer changes, never on the minute tick. // A plain-text summary of what answered, so a re-read that changes nothing keeps the time. - const loadedKey = `${state.status}|${state.sources.map((source) => source.status).join(",")}|${state.items.length}`; + // A retry that is still reading adds its own mark, so the answer it brings back restamps the time even when + // nothing visible changed; until it answers, the earlier time stays. + const refreshing = state.refreshing === true; + const loadedKey = `${state.status}|${state.sources.map((source) => source.status).join(",")}|${state.items.length}${refreshing ? "|refreshing" : ""}`; const [loaded, setLoaded] = useState<{ readonly key: string; readonly at: string | null; @@ -423,7 +426,9 @@ export function MyDayPage({ now: nowProp }: { now?: Date } = {}) { at: state.status === "ready" ? perthTimeOf(now) : null, atMs: state.status === "ready" ? now.getTime() : null, })); - if (loaded.key !== loadedKey) { + if (loaded.key !== loadedKey && refreshing) { + setLoaded({ ...loaded, key: loadedKey }); + } else if (loaded.key !== loadedKey) { const ready = state.status === "ready"; // The minute tick can be up to a minute stale, so record the real moment unless a clock is injected. const answered = nowProp ?? new Date(); diff --git a/src/components/my-day/sources/cme.ts b/src/components/my-day/sources/cme.ts index 9b1907b9b5..3dc07e789b 100644 --- a/src/components/my-day/sources/cme.ts +++ b/src/components/my-day/sources/cme.ts @@ -155,12 +155,14 @@ const noRoutines: readonly CmeRoutine[] = []; export function useCmeMyDaySource({ enabled, now }: { enabled: boolean; now: Date }): { result: MyDaySourceResult; retry: () => void; + /** True while a retry is reading again over the answer still shown. */ + refreshing: boolean; /** The reader's routines once loaded (for the Week page); empty until then. */ routines: readonly CmeRoutine[]; } { const reminders = useAppPreferences().preferences.reminders; const { authEpoch } = useAuthSession(); - const [stored, setStored] = useState<{ epoch: number; loaded: Loaded } | null>(null); + const [stored, setStored] = useState<{ epoch: number; generation: number; loaded: Loaded } | null>(null); const [generation, setGeneration] = useState(0); const retry = useCallback(() => setGeneration((value) => value + 1), []); @@ -168,17 +170,18 @@ export function useCmeMyDaySource({ enabled, now }: { enabled: boolean; now: Dat if (!enabled) return; const controller = new AbortController(); void loadCme(controller.signal, ruleEnginesOn(new Date()).cpd).then((next) => { - if (next && !controller.signal.aborted) setStored({ epoch: authEpoch, loaded: next }); + if (next && !controller.signal.aborted) setStored({ epoch: authEpoch, generation, loaded: next }); }); return () => controller.abort(); }, [enabled, generation, authEpoch]); - if (!enabled) return { result: signedOut, retry, routines: noRoutines }; + if (!enabled) return { result: signedOut, retry, refreshing: false, routines: noRoutines }; // Data from another account (a different auth epoch) is never shown. - if (!stored || stored.epoch !== authEpoch) return { result: loading, retry, routines: noRoutines }; + if (!stored || stored.epoch !== authEpoch) return { result: loading, retry, refreshing: false, routines: noRoutines }; const loaded = stored.loaded; + const refreshing = stored.generation !== generation; if (loaded.status !== "ready") - return { result: { mode: "cme", status: loaded.status, items: [] }, retry, routines: noRoutines }; + return { result: { mode: "cme", status: loaded.status, items: [] }, retry, refreshing, routines: noRoutines }; return { result: { mode: "cme", @@ -192,6 +195,7 @@ export function useCmeMyDaySource({ enabled, now }: { enabled: boolean; now: Dat ...(loaded.sample ? { sample: true } : {}), }, retry, + refreshing, routines: loaded.routines, }; } diff --git a/src/components/my-day/sources/entries.ts b/src/components/my-day/sources/entries.ts index 6e36108e17..3f33d58819 100644 --- a/src/components/my-day/sources/entries.ts +++ b/src/components/my-day/sources/entries.ts @@ -222,6 +222,8 @@ export function useEntriesMyDaySources({ enabled, now }: { enabled: boolean; now /** The New job countdown for My records (work-mode redesign, 6 Oct 2026); null without a start date. */ newJob: NewJobProgress | null; retry: () => void; + /** True while On Call is reading again, including a retry after a failed read. */ + refreshing: boolean; } { // `useOnCallEntries` fetches unconditionally and cannot be disabled; it is // still called every render (hooks rules) and ignored while signed out. @@ -274,5 +276,6 @@ export function useEntriesMyDaySources({ enabled, now }: { enabled: boolean; now : null, [ready, demoMode, own, entries, now], ); - return { admin, onCall, nextRenewal, renewals, helpItems, adminEntries, newJob, retry }; + const refreshing = enabled && state.loading; + return { admin, onCall, nextRenewal, renewals, helpItems, adminEntries, newJob, retry, refreshing }; } diff --git a/src/components/my-day/sources/roster.ts b/src/components/my-day/sources/roster.ts index d0a373af01..a0788bace7 100644 --- a/src/components/my-day/sources/roster.ts +++ b/src/components/my-day/sources/roster.ts @@ -193,12 +193,14 @@ async function loadRosterTeams(signal: AbortSignal): Promise { export function useRosterMyDaySource({ enabled, now }: { enabled: boolean; now: Date }): { result: MyDaySourceResult; retry: () => void; + /** True while a retry is reading again over the answer still shown. */ + refreshing: boolean; } { const { authEpoch } = useAuthSession(); // A sick report links to Sick for tomorrow, a new work mode screen the classic work mode does not have. const routeVisible = useWorkModeRouteVisible(); const { zone } = useWorkTimeZone(); - const [stored, setStored] = useState<{ epoch: number; loaded: Loaded } | null>(null); + const [stored, setStored] = useState<{ epoch: number; generation: number; loaded: Loaded } | null>(null); const [generation, setGeneration] = useState(0); const retry = useCallback(() => setGeneration((value) => value + 1), []); @@ -206,15 +208,16 @@ export function useRosterMyDaySource({ enabled, now }: { enabled: boolean; now: if (!enabled) return; const controller = new AbortController(); void loadRoster(controller.signal, ruleEnginesOn(new Date()).fatigue).then((next) => { - if (next && !controller.signal.aborted) setStored({ epoch: authEpoch, loaded: next }); + if (next && !controller.signal.aborted) setStored({ epoch: authEpoch, generation, loaded: next }); }); return () => controller.abort(); }, [enabled, generation, authEpoch]); - if (!enabled) return { result: signedOut, retry }; + if (!enabled) return { result: signedOut, retry, refreshing: false }; // Data from another account (a different auth epoch) is never shown. - if (!stored || stored.epoch !== authEpoch) return { result: loading, retry }; + if (!stored || stored.epoch !== authEpoch) return { result: loading, retry, refreshing: false }; const loaded = stored.loaded; + const refreshing = stored.generation !== generation; if (loaded.status === "ready") { return { result: { @@ -223,7 +226,8 @@ export function useRosterMyDaySource({ enabled, now }: { enabled: boolean; now: items: rosterMyDayItems(loaded.input, now, zone).filter((item) => routeVisible(item.href)), }, retry, + refreshing, }; } - return { result: { mode: "roster", status: loaded.status, items: [] }, retry }; + return { result: { mode: "roster", status: loaded.status, items: [] }, retry, refreshing }; } diff --git a/src/components/my-day/sources/teaching.ts b/src/components/my-day/sources/teaching.ts index 905fc01204..5f3288673b 100644 --- a/src/components/my-day/sources/teaching.ts +++ b/src/components/my-day/sources/teaching.ts @@ -121,6 +121,8 @@ function combine(resources: readonly { status: TeachingResourceStatus; code: str export function useTeachingMyDaySource({ enabled, now }: { enabled: boolean; now: Date }): { result: MyDaySourceResult; retry: () => void; + /** True while a retry is reading again over the answer still shown. */ + refreshing: boolean; } { const unlogged = useTeachingResource<{ count: number }>(enabled ? "/api/teaching?view=unlogged-count" : null); // The depth routes refuse the synthetic demo build, so it is not asked (the answer is known). @@ -166,5 +168,6 @@ export function useTeachingMyDaySource({ enabled, now }: { enabled: boolean; now retryFeedback(); retryWeek(); }, [retryUnlogged, retryTeach, retryFeedback, retryWeek]); - return { result, retry }; + const refreshing = enabled && [unlogged, teach, feedback, week].some((read) => read.refreshing); + return { result, retry, refreshing }; } diff --git a/src/components/my-day/use-my-day-items.ts b/src/components/my-day/use-my-day-items.ts index d92a047303..e62ee65fc9 100644 --- a/src/components/my-day/use-my-day-items.ts +++ b/src/components/my-day/use-my-day-items.ts @@ -71,12 +71,16 @@ export function useMyDayItems({ enabled, now }: { readonly enabled: boolean; rea retryTeaching(); }, [retryEntries, retryRoster, retryCme, retryTeaching]); + // A retry reads again over the answer still shown, so the page waits for it before restamping the time. + const refreshing = enabled && (entries.refreshing || roster.refreshing || cme.refreshing || teaching.refreshing); + const signedOut = !enabled || sessionLapsed; const loading = sources.some((source) => source.status === "loading"); return { status: signedOut ? "signed-out" : loading ? "loading" : "ready", items: signedOut ? [] : items, sources, + refreshing: signedOut ? false : refreshing, // Only a source that actually loaded sample data makes the view "demo". demoMode: sources.some((source) => source.status === "ready" && source.sample === true), retry, diff --git a/src/lib/my-day/model.ts b/src/lib/my-day/model.ts index 3506988c18..eb83a9245d 100644 --- a/src/lib/my-day/model.ts +++ b/src/lib/my-day/model.ts @@ -84,6 +84,8 @@ export interface MyDayState { /** Merged and sorted with `mergeMyDayItems`. */ readonly items: readonly MyDayItem[]; readonly sources: readonly MyDaySourceResult[]; + /** True while a source is reading again over the answer it still shows (a retry in flight). */ + readonly refreshing?: boolean; /** True when any source is showing demo/sample data. */ readonly demoMode: boolean; readonly retry: () => void; diff --git a/tests/my-day-page.dom.test.tsx b/tests/my-day-page.dom.test.tsx index babd5a4f85..9343049f26 100644 --- a/tests/my-day-page.dom.test.tsx +++ b/tests/my-day-page.dom.test.tsx @@ -531,6 +531,21 @@ describe("MyDayPage", () => { expect(screen.getAllByText(/Updated just now/)).toHaveLength(1); }); + it("restamps the age when a retry answers, even when nothing visible changed", () => { + const later = (minutes: number) => new Date(NOW.getTime() + minutes * 60_000); + const { rerender } = render(); + rerender(); + expect(screen.getByTestId("my-day-stamp").textContent).toContain("Updated 5 min ago"); + // While the retry reads again the earlier answer stays on screen, so its age stays too. + setState({ refreshing: true }); + rerender(); + expect(screen.getByTestId("my-day-stamp").textContent).toContain("Updated 6 min ago"); + // The retry answers with the same statuses and item count: the age starts again. + setState({ refreshing: false }); + rerender(); + expect(screen.getByTestId("my-day-stamp").textContent).toContain("Updated just now"); + }); + it("names only the modes that were checked, and never says nothing needs you while one failed", () => { setState({ sources: readySources.map((source) => diff --git a/tests/ui-chrome-scroll.spec.ts b/tests/ui-chrome-scroll.spec.ts index f85aedad0a..4b0020f6e7 100644 --- a/tests/ui-chrome-scroll.spec.ts +++ b/tests/ui-chrome-scroll.spec.ts @@ -425,21 +425,29 @@ test.describe("pages that fit the window have no scroll range", () => { () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))), ); - const geometry = await page.evaluate(() => { - const home = document.querySelector('[data-testid="shared-home-empty-state"]'); - const canvas = document.querySelector("[data-mode-home-canvas]"); - const main = document.getElementById("main-content"); - if (!home || !canvas || !main) return null; - - const homeRect = home.getBoundingClientRect(); - const canvasRect = canvas.getBoundingClientRect(); - return { - homeMidY: homeRect.top + homeRect.height / 2, - canvasMidY: canvasRect.top + canvasRect.height / 2, - maxScrollTop: Math.max(0, main.scrollHeight - main.clientHeight), - }; - }); + // WebKit (the tablet project) can paint the home card a frame or two before + // the centring canvas is marked, so wait for all three nodes instead of + // reading once and failing on a transient null. + const readGeometry = () => + page.evaluate(() => { + const home = document.querySelector('[data-testid="shared-home-empty-state"]'); + const canvas = document.querySelector("[data-mode-home-canvas]"); + const main = document.getElementById("main-content"); + if (!home || !canvas || !main) return null; + + const homeRect = home.getBoundingClientRect(); + const canvasRect = canvas.getBoundingClientRect(); + return { + homeMidY: homeRect.top + homeRect.height / 2, + canvasMidY: canvasRect.top + canvasRect.height / 2, + maxScrollTop: Math.max(0, main.scrollHeight - main.clientHeight), + }; + }); + await expect + .poll(readGeometry, { message: "home card, centring canvas and main scroller all mount" }) + .not.toBeNull(); + const geometry = await readGeometry(); expect(geometry).not.toBeNull(); expect(Math.abs(geometry!.homeMidY - geometry!.canvasMidY)).toBeLessThanOrEqual(1); expect(geometry!.maxScrollTop).toBe(0);