diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 46759b7d41f2..eae054cc0d7b 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -100,7 +100,9 @@ export function UsagePage() { setWindowSelection({ days: windowDays, window: nextWindow }); } }; - useLiveRefresh(refreshWindow, { key: "usage-dashboard" }); + // Usage is a live operational dashboard, so a visible tab should keep + // reporting even when the reader is watching without interacting. + useLiveRefresh(refreshWindow, { key: "usage-dashboard", refreshWhileVisible: true }); return ( diff --git a/apps/web/src/hooks/useLiveRefresh.test.ts b/apps/web/src/hooks/useLiveRefresh.test.ts index 632dd0d45cbe..7e4a5484febc 100644 --- a/apps/web/src/hooks/useLiveRefresh.test.ts +++ b/apps/web/src/hooks/useLiveRefresh.test.ts @@ -72,8 +72,14 @@ describe("shouldRefreshOnArrival", () => { }); describe("shouldRefreshOnInterval", () => { - const tick = (now: number, lastInteractedAt: number) => - shouldRefreshOnInterval({ visible: true, now, lastRefreshedAt: 0, lastInteractedAt }); + const tick = (now: number, lastInteractedAt: number, refreshWhileVisible = false) => + shouldRefreshOnInterval({ + visible: true, + now, + lastRefreshedAt: 0, + lastInteractedAt, + refreshWhileVisible, + }); it("reads for a reader who is here", () => { expect(tick(LIVE_REFRESH_INTERVAL_MS, LIVE_REFRESH_INTERVAL_MS - 1_000)).toBe(true); @@ -83,6 +89,10 @@ describe("shouldRefreshOnInterval", () => { expect(tick(LIVE_REFRESH_IDLE_AFTER_MS + 60_000, 0)).toBe(false); }); + it("keeps a visible dashboard current after the normal idle cutoff", () => { + expect(tick(LIVE_REFRESH_IDLE_AFTER_MS + 60_000, 0, true)).toBe(true); + }); + it("starts reading again once the reader touches the window", () => { const away = LIVE_REFRESH_IDLE_AFTER_MS + 60_000; expect(tick(away + LIVE_REFRESH_INTERVAL_MS, away)).toBe(true); diff --git a/apps/web/src/hooks/useLiveRefresh.ts b/apps/web/src/hooks/useLiveRefresh.ts index 9d9b11295129..8913973364b8 100644 --- a/apps/web/src/hooks/useLiveRefresh.ts +++ b/apps/web/src/hooks/useLiveRefresh.ts @@ -68,9 +68,12 @@ export function shouldRefreshOnInterval(input: { readonly now: number; readonly lastRefreshedAt: number; readonly lastInteractedAt: number; + /** Continue reading while visible, even after the normal idle cutoff. */ + readonly refreshWhileVisible?: boolean; }): boolean { return ( - input.now - input.lastInteractedAt < LIVE_REFRESH_IDLE_AFTER_MS && shouldLiveRefresh(input) + (input.refreshWhileVisible || input.now - input.lastInteractedAt < LIVE_REFRESH_IDLE_AFTER_MS) && + shouldLiveRefresh(input) ); } @@ -116,9 +119,14 @@ function watchInteraction(): () => void { export function useLiveRefresh( refresh: (() => void) | null, - options: { readonly enabled?: boolean; readonly key?: string } = {}, + options: { + readonly enabled?: boolean; + readonly key?: string; + /** Keep refreshing while the window is visible instead of stopping after idle time. */ + readonly refreshWhileVisible?: boolean; + } = {}, ): void { - const { enabled = true, key } = options; + const { enabled = true, key, refreshWhileVisible = false } = options; // Held in a ref so a caller can pass a fresh closure every render without re-arming the // listeners, which would otherwise refresh on every render that changed anything at all. const latest = useRef(refresh); @@ -149,7 +157,15 @@ export function useLiveRefresh( const onInterval = () => { const now = Date.now(); const lastRefreshedAt = lastRefreshedAtByView.get(viewId) ?? now; - if (shouldRefreshOnInterval({ visible: visible(), now, lastRefreshedAt, lastInteractedAt })) { + if ( + shouldRefreshOnInterval({ + visible: visible(), + now, + lastRefreshedAt, + lastInteractedAt, + refreshWhileVisible, + }) + ) { read(now); } }; @@ -178,5 +194,5 @@ export function useLiveRefresh( document.removeEventListener("visibilitychange", onVisibilityChange); stopWatchingInteraction(); }; - }, [enabled, viewId]); + }, [enabled, refreshWhileVisible, viewId]); }