From a2ff5e5be79604cd7ec017b9c682b92895bc2f6b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:53:58 +0000 Subject: [PATCH 1/2] My Day: restamp the data age when a retry answers A retry reads again over the answer still on screen, so when it brought back the same statuses and item count the "Updated N min ago" line kept the earlier time. Each source now says when it is reading again (refreshing), and the page keeps the earlier time until that read answers, then restamps it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0113Rr2NnmLuLKLWdKTwHHqd --- src/components/my-day/my-day-page.tsx | 9 +++++++-- src/components/my-day/sources/cme.ts | 14 +++++++++----- src/components/my-day/sources/entries.ts | 5 ++++- src/components/my-day/sources/roster.ts | 14 +++++++++----- src/components/my-day/sources/teaching.ts | 5 ++++- src/components/my-day/use-my-day-items.ts | 4 ++++ src/lib/my-day/model.ts | 2 ++ tests/my-day-page.dom.test.tsx | 15 +++++++++++++++ 8 files changed, 54 insertions(+), 14 deletions(-) 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) => From 347d89425ba40d176911dda8b6189f06544231ac Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:17:05 +0000 Subject: [PATCH 2/2] test(ui): wait for the documents home canvas before measuring its centring The tablet (WebKit) project read the home card, canvas and main scroller once after two animation frames and failed on a transient null; main's browser matrix is red on this test. Poll until all three mount, then measure. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0113Rr2NnmLuLKLWdKTwHHqd --- tests/ui-chrome-scroll.spec.ts | 36 +++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 14 deletions(-) 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);