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
4 changes: 3 additions & 1 deletion apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/hooks/useLiveRefresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
26 changes: 21 additions & 5 deletions apps/web/src/hooks/useLiveRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
};
Expand Down Expand Up @@ -178,5 +194,5 @@ export function useLiveRefresh(
document.removeEventListener("visibilitychange", onVisibilityChange);
stopWatchingInteraction();
};
}, [enabled, viewId]);
}, [enabled, refreshWhileVisible, viewId]);
}
Loading