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
9 changes: 7 additions & 2 deletions src/components/my-day/my-day-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down
14 changes: 9 additions & 5 deletions src/components/my-day/sources/cme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,30 +155,33 @@ 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), []);

useEffect(() => {
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",
Expand All @@ -192,6 +195,7 @@ export function useCmeMyDaySource({ enabled, now }: { enabled: boolean; now: Dat
...(loaded.sample ? { sample: true } : {}),
},
retry,
refreshing,
routines: loaded.routines,
};
}
5 changes: 4 additions & 1 deletion src/components/my-day/sources/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
return { admin, onCall, nextRenewal, renewals, helpItems, adminEntries, newJob, retry, refreshing };
}
14 changes: 9 additions & 5 deletions src/components/my-day/sources/roster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,28 +193,31 @@ async function loadRosterTeams(signal: AbortSignal): Promise<Loaded | null> {
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), []);

useEffect(() => {
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: {
Expand All @@ -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 };
}
5 changes: 4 additions & 1 deletion src/components/my-day/sources/teaching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 };
}
4 changes: 4 additions & 0 deletions src/components/my-day/use-my-day-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/my-day/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions tests/my-day-page.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<MyDayPage now={NOW} />);
rerender(<MyDayPage now={later(5)} />);
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(<MyDayPage now={later(6)} />);
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(<MyDayPage now={later(7)} />);
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) =>
Expand Down
36 changes: 22 additions & 14 deletions tests/ui-chrome-scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,21 +425,29 @@ test.describe("pages that fit the window have no scroll range", () => {
() => new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
);

const geometry = await page.evaluate(() => {
const home = document.querySelector<HTMLElement>('[data-testid="shared-home-empty-state"]');
const canvas = document.querySelector<HTMLElement>("[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<HTMLElement>('[data-testid="shared-home-empty-state"]');
const canvas = document.querySelector<HTMLElement>("[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);
Expand Down
Loading