@@ -566,6 +580,17 @@ function UsageDeviceStrip({
);
}
+ if (environment.offline) {
+ return (
+
+
+ {environment.label} ยท offline
+
+ );
+ }
return (
isPending: result.waiting,
error: result._tag === "Failure" ? "This environment could not report usage." : null,
summary: Option.getOrNull(AsyncResult.value(result)),
+ connected: presentation.connection.phase === "connected",
+ offline: false,
});
}
return statuses;
@@ -64,8 +77,8 @@ export interface UsageView {
readonly isPending: boolean;
/**
* True while environments that have not failed are still answering. Failed
- * environments are reported through their own error rows: totals will not
- * improve by waiting on them, so they must not read as "still reporting".
+ * and offline environments are reported through their own rows: totals will
+ * not improve by waiting on them, so they must not read as "still reporting".
*/
readonly isPartial: boolean;
readonly refresh: () => void;
@@ -92,7 +105,27 @@ export function useUsage(input: UsageSummaryInput): UsageView {
],
);
const atom = usageByWindowAtom(windowKey);
- const environments = useAtomValue(atom);
+ const reported = useAtomValue(atom);
+
+ const [scanNonce, setScanNonce] = useState(0);
+ const [deadlinePassed, setDeadlinePassed] = useState(false);
+ useEffect(() => {
+ setDeadlinePassed(false);
+ const timer = setTimeout(() => setDeadlinePassed(true), OFFLINE_DEADLINE_MS);
+ return () => clearTimeout(timer);
+ }, [windowKey, scanNonce]);
+
+ const environments = useMemo(
+ () =>
+ deadlinePassed
+ ? reported.map((environment) =>
+ environment.summary === null && environment.error === null && !environment.connected
+ ? { ...environment, offline: true }
+ : environment,
+ )
+ : reported,
+ [reported, deadlinePassed],
+ );
// Refreshing only the derived atom would re-read the per-environment SWR
// queries within their stale window and change nothing. Refresh each
@@ -104,6 +137,7 @@ export function useUsage(input: UsageSummaryInput): UsageView {
serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }),
);
}
+ setScanNonce((nonce) => nonce + 1);
}, [environments, windowKey]);
const merged = useMemo(() => {
@@ -123,7 +157,8 @@ export function useUsage(input: UsageSummaryInput): UsageView {
const answeredCount = environments.filter((environment) => environment.summary !== null).length;
const stillReporting = environments.filter(
- (environment) => environment.summary === null && environment.error === null,
+ (environment) =>
+ environment.summary === null && environment.error === null && !environment.offline,
).length;
return {
From da333cb778bfe6f233b320cbdbfd88a309ce7bef Mon Sep 17 00:00:00 2001
From: Mark S <49048586+msegec@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:22:11 +0800
Subject: [PATCH 2/4] fix: offline deadline resets with the window instead of
one render late
---
apps/mobile/src/state/usage.ts | 11 +++++++----
apps/web/src/state/usage.ts | 11 +++++++----
2 files changed, 14 insertions(+), 8 deletions(-)
diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts
index 4c5d89bd8aaf..592e13201263 100644
--- a/apps/mobile/src/state/usage.ts
+++ b/apps/mobile/src/state/usage.ts
@@ -111,12 +111,15 @@ export function useUsage(input: UsageSummaryInput): UsageView {
const reported = useAtomValue(atom);
const [scanNonce, setScanNonce] = useState(0);
- const [deadlinePassed, setDeadlinePassed] = useState(false);
+ // The passed flag names the scan it belongs to, so a new window or refresh
+ // invalidates it on the same render instead of one effect tick later.
+ const scanKey = `${scanNonce}:${windowKey}`;
+ const [passedScanKey, setPassedScanKey] = useState(null);
useEffect(() => {
- setDeadlinePassed(false);
- const timer = setTimeout(() => setDeadlinePassed(true), OFFLINE_DEADLINE_MS);
+ const timer = setTimeout(() => setPassedScanKey(scanKey), OFFLINE_DEADLINE_MS);
return () => clearTimeout(timer);
- }, [windowKey, scanNonce]);
+ }, [scanKey]);
+ const deadlinePassed = passedScanKey === scanKey;
const environments = useMemo(
() =>
diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts
index a0ffd55e9285..7084127e5b18 100644
--- a/apps/web/src/state/usage.ts
+++ b/apps/web/src/state/usage.ts
@@ -108,12 +108,15 @@ export function useUsage(input: UsageSummaryInput): UsageView {
const reported = useAtomValue(atom);
const [scanNonce, setScanNonce] = useState(0);
- const [deadlinePassed, setDeadlinePassed] = useState(false);
+ // The passed flag names the scan it belongs to, so a new window or refresh
+ // invalidates it on the same render instead of one effect tick later.
+ const scanKey = `${scanNonce}:${windowKey}`;
+ const [passedScanKey, setPassedScanKey] = useState(null);
useEffect(() => {
- setDeadlinePassed(false);
- const timer = setTimeout(() => setDeadlinePassed(true), OFFLINE_DEADLINE_MS);
+ const timer = setTimeout(() => setPassedScanKey(scanKey), OFFLINE_DEADLINE_MS);
return () => clearTimeout(timer);
- }, [windowKey, scanNonce]);
+ }, [scanKey]);
+ const deadlinePassed = passedScanKey === scanKey;
const environments = useMemo(
() =>
From 4071a5cc7ba61f081e047194618f903316fa006d Mon Sep 17 00:00:00 2001
From: Mark S <49048586+msegec@users.noreply.github.com>
Date: Thu, 27 Aug 2026 08:26:36 +0800
Subject: [PATCH 3/4] fix(web,mobile): show unreachable state when no usage
device reports
When every device missed the scan deadline, the page rendered the empty
merge as $0.00 real totals. The hook now reports an unreachable state
both pages surface instead, and a revisited window gets its offline
grace period back rather than inheriting the stale deadline flag.
---
.../src/features/usage/UsageRouteScreen.tsx | 6 +-
apps/mobile/src/state/usage.ts | 14 +
.../src/components/usage/UsagePage.test.tsx | 30 ++
apps/web/src/components/usage/UsagePage.tsx | 13 +-
apps/web/src/state/usage.test.tsx | 297 ++++++++++++++++++
apps/web/src/state/usage.ts | 14 +
6 files changed, 372 insertions(+), 2 deletions(-)
create mode 100644 apps/web/src/state/usage.test.tsx
diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx
index ec1d6fa49a94..3ca8d66823a3 100644
--- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx
+++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx
@@ -43,7 +43,7 @@ export function UsageRouteScreen() {
const [metric, setMetric] = useState("cost");
const { days: windowDays, window } = windowSelection;
const isPast24Hours = windowDays === 1;
- const { merged, environments, isPending, isPartial, refresh } = useUsage(window);
+ const { merged, environments, isPending, isPartial, isUnreachable, refresh } = useUsage(window);
const days = useMemo(
() => enumerateDays(window.sinceDay, window.untilDay),
@@ -125,6 +125,10 @@ export function UsageRouteScreen() {
Connect an environment to see usage.
+ ) : isUnreachable ? (
+
+ No device reported usage. Reconnect a device or pull to scan again.
+
) : (
<>
void;
}
@@ -115,6 +121,13 @@ export function useUsage(input: UsageSummaryInput): UsageView {
// invalidates it on the same render instead of one effect tick later.
const scanKey = `${scanNonce}:${windowKey}`;
const [passedScanKey, setPassedScanKey] = useState(null);
+ // Returning to a previously viewed window would match the flag that window
+ // left behind and skip the grace period, so drop it when the scan changes.
+ const [lastScanKey, setLastScanKey] = useState(scanKey);
+ if (lastScanKey !== scanKey) {
+ setLastScanKey(scanKey);
+ setPassedScanKey(null);
+ }
useEffect(() => {
const timer = setTimeout(() => setPassedScanKey(scanKey), OFFLINE_DEADLINE_MS);
return () => clearTimeout(timer);
@@ -172,6 +185,7 @@ export function useUsage(input: UsageSummaryInput): UsageView {
environments,
isPending: answeredCount === 0 && stillReporting > 0,
isPartial: answeredCount > 0 && stillReporting > 0,
+ isUnreachable: environments.length > 0 && answeredCount === 0 && stillReporting === 0,
refresh,
};
}
diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx
index 8e86b521e890..c840351569ae 100644
--- a/apps/web/src/components/usage/UsagePage.test.tsx
+++ b/apps/web/src/components/usage/UsagePage.test.tsx
@@ -130,6 +130,7 @@ beforeEach(() => {
environments: [],
isPending: false,
isPartial: false,
+ isUnreachable: false,
refresh: vi.fn(),
});
});
@@ -155,6 +156,35 @@ describe("UsagePage hourly breakdown", () => {
});
});
+describe("UsagePage unreachable devices", () => {
+ it("shows the no-report notice instead of zero totals when nothing answered", () => {
+ testState.useUsage.mockReturnValue({
+ merged: mergeUsage([], USAGE_CONTRACT_VERSION),
+ environments: [
+ {
+ environmentId: "env-away",
+ label: "Away Laptop",
+ isPending: true,
+ error: null,
+ summary: null,
+ connected: false,
+ offline: true,
+ },
+ ],
+ isPending: false,
+ isPartial: false,
+ isUnreachable: true,
+ refresh: vi.fn(),
+ });
+
+ const markup = renderToStaticMarkup();
+
+ expect(markup).toContain("No device reported usage.");
+ expect(markup).toContain("Away Laptop is offline and excluded from totals.");
+ expect(markup).not.toContain("$0.00");
+ });
+});
+
describe("UsagePage model breakdown", () => {
it("sorts models by cost when the cost metric is selected", () => {
testState.breakdown = "model";
diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx
index e6d222fe55c9..77a89a342c38 100644
--- a/apps/web/src/components/usage/UsagePage.tsx
+++ b/apps/web/src/components/usage/UsagePage.tsx
@@ -50,7 +50,7 @@ export function UsagePage() {
const [breakdown, setBreakdown] = useState<"model" | "time">("model");
const { days: windowDays, window } = windowSelection;
const isPast24Hours = windowDays === 1;
- const { merged, environments, isPending, isPartial, refresh } = useUsage(window);
+ const { merged, environments, isPending, isPartial, isUnreachable, refresh } = useUsage(window);
// Hold the content until every environment is terminal. Rendering merged
// totals while devices are still answering makes every number on the page
@@ -213,6 +213,17 @@ export function UsagePage() {
{environments.length > 1 ? : null}
>
+ ) : isUnreachable ? (
+ <>
+
+
+ No device reported usage. Reconnect a device or refresh to scan again.
+
+ >
) : (
<>
({
+ statuses: [] as unknown[],
+ refresh: vi.fn(),
+ usageSummary: vi.fn((args: unknown) => args),
+}));
+
+// The hook reads per-environment statuses through one derived atom; feeding
+// that read directly keeps the test on the hook's own logic (deadline timer,
+// offline mapping, scan reset) instead of the atom runtime.
+vi.mock("@effect/atom-react", () => ({ useAtomValue: () => mocks.statuses }));
+vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: { refresh: mocks.refresh } }));
+vi.mock("./presentation", () => ({ environmentPresentations: { presentationsAtom: {} } }));
+vi.mock("./server", () => ({ serverEnvironment: { usageSummary: mocks.usageSummary } }));
+
+import { useUsage, type EnvironmentUsageStatus, type UsageView } from "./usage";
+
+const WINDOW: UsageSummaryInput = {
+ sinceDay: "2026-08-01" as UsageDay,
+ untilDay: "2026-08-31" as UsageDay,
+ timeZone: "UTC",
+};
+const OTHER_WINDOW: UsageSummaryInput = { ...WINDOW, sinceDay: "2026-07-01" as UsageDay };
+
+function summary(hostId = "host-a"): UsageSummary {
+ return {
+ contractVersion: USAGE_CONTRACT_VERSION,
+ readAt: "2026-08-27T00:00:00.000Z",
+ timeZone: "UTC",
+ sinceDay: WINDOW.sinceDay,
+ untilDay: WINDOW.untilDay,
+ buckets: [
+ {
+ day: "2026-08-07" as UsageDay,
+ provider: "claude",
+ model: "claude-fable-5",
+ totals: {
+ uncachedInputTokens: 100,
+ cachedInputTokens: 1000,
+ cacheCreationTokens: 10,
+ outputTokens: 50,
+ reasoningTokens: 0,
+ },
+ costUsd: 10,
+ cacheSavingsUsd: 2,
+ costSource: "modelPriced",
+ records: 5,
+ unpricedRecords: 0,
+ sessions: 1,
+ },
+ ],
+ sources: [
+ {
+ fingerprint: {
+ hostId,
+ provider: "claude",
+ resolvedHomePath: `/${hostId}/.claude`,
+ volumeId: `vol-${hostId}`,
+ },
+ status: "ok",
+ scannedFiles: 1,
+ skippedFiles: 0,
+ malformedRecords: 0,
+ distinctSessions: 1,
+ message: null,
+ },
+ ],
+ pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 },
+ scanDurationMs: 1,
+ };
+}
+
+function status(
+ id: string,
+ overrides: Partial = {},
+): EnvironmentUsageStatus {
+ return {
+ environmentId: id as EnvironmentId,
+ label: id,
+ isPending: true,
+ error: null,
+ summary: null,
+ connected: false,
+ offline: false,
+ ...overrides,
+ };
+}
+
+// ReactDOM needs a host, but this unit suite intentionally has no DOM dependency.
+class TestNode {
+ parentNode: TestNode | null = null;
+ childNodes: TestNode[] = [];
+ readonly nodeName: string;
+ readonly tagName: string;
+ readonly namespaceURI = "http://www.w3.org/1999/xhtml";
+ readonly style = {};
+
+ constructor(
+ name: string,
+ readonly ownerDocument: TestNode | null = null,
+ readonly nodeType = 1,
+ ) {
+ this.nodeName = name.toUpperCase();
+ this.tagName = this.nodeName;
+ }
+
+ set textContent(_value: string) {
+ this.childNodes = [];
+ }
+
+ appendChild(child: TestNode) {
+ child.parentNode = this;
+ this.childNodes.push(child);
+ return child;
+ }
+
+ removeChild(child: TestNode) {
+ this.childNodes.splice(this.childNodes.indexOf(child), 1);
+ child.parentNode = null;
+ return child;
+ }
+
+ createElement(name: string) {
+ return new TestNode(name, this);
+ }
+
+ addEventListener() {}
+ removeEventListener() {}
+ setAttribute() {}
+}
+
+function installTestDom() {
+ const document = new TestNode("#document", null, 9);
+ const window = {
+ document,
+ HTMLIFrameElement: TestNode,
+ setInterval: globalThis.setInterval,
+ clearInterval: globalThis.clearInterval,
+ setTimeout: globalThis.setTimeout,
+ clearTimeout: globalThis.clearTimeout,
+ addEventListener() {},
+ removeEventListener() {},
+ };
+ vi.stubGlobal("document", document);
+ vi.stubGlobal("window", window);
+ vi.stubGlobal("HTMLIFrameElement", window.HTMLIFrameElement);
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ return document;
+}
+
+let view: UsageView;
+function Probe({ input }: { readonly input: UsageSummaryInput }) {
+ view = useUsage(input);
+ return null;
+}
+
+async function mount(input: UsageSummaryInput = WINDOW) {
+ const document = installTestDom();
+ const { createRoot } = await import("react-dom/client");
+ const root = createRoot(document.createElement("div") as unknown as Element);
+ const render = (nextInput: UsageSummaryInput = input) =>
+ act(() => root.render());
+ await render();
+ return {
+ render,
+ unmount: () => act(() => root.unmount()),
+ };
+}
+
+const advancePastDeadline = () => act(() => vi.advanceTimersByTimeAsync(7_500));
+
+const byId = (id: string) => view.environments.find((entry) => entry.environmentId === id);
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ mocks.refresh.mockClear();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+describe("useUsage offline deadline", () => {
+ it("marks a disconnected device offline once the deadline passes", async () => {
+ mocks.statuses = [
+ status("env-answered", { summary: summary(), isPending: false }),
+ status("env-away"),
+ ];
+ const { unmount } = await mount();
+ try {
+ expect(view.isPartial).toBe(true);
+ expect(byId("env-away")?.offline).toBe(false);
+
+ await advancePastDeadline();
+
+ expect(byId("env-away")?.offline).toBe(true);
+ expect(view.isPartial).toBe(false);
+ expect(view.isPending).toBe(false);
+ expect(view.isUnreachable).toBe(false);
+ } finally {
+ await unmount();
+ }
+ });
+
+ it("merges a late answer and clears the offline flag", async () => {
+ mocks.statuses = [
+ status("env-answered", { summary: summary(), isPending: false }),
+ status("env-away"),
+ ];
+ const { render, unmount } = await mount();
+ try {
+ await advancePastDeadline();
+ expect(byId("env-away")?.offline).toBe(true);
+ expect(view.merged.contributingEnvironments).toHaveLength(1);
+
+ mocks.statuses = [
+ status("env-answered", { summary: summary(), isPending: false }),
+ status("env-away", { summary: summary("host-b"), isPending: false }),
+ ];
+ await render();
+
+ expect(byId("env-away")?.offline).toBe(false);
+ expect(view.merged.contributingEnvironments).toHaveLength(2);
+ } finally {
+ await unmount();
+ }
+ });
+
+ it("restarts the grace period on refresh", async () => {
+ mocks.statuses = [
+ status("env-answered", { summary: summary(), isPending: false }),
+ status("env-away"),
+ ];
+ const { unmount } = await mount();
+ try {
+ await advancePastDeadline();
+ expect(byId("env-away")?.offline).toBe(true);
+
+ await act(() => view.refresh());
+
+ expect(mocks.refresh).toHaveBeenCalledTimes(2);
+ expect(byId("env-away")?.offline).toBe(false);
+
+ await advancePastDeadline();
+ expect(byId("env-away")?.offline).toBe(true);
+ } finally {
+ await unmount();
+ }
+ });
+
+ it("reports unreachable instead of zero totals when no device answers", async () => {
+ mocks.statuses = [status("env-away"), status("env-also-away")];
+ const { unmount } = await mount();
+ try {
+ expect(view.isPending).toBe(true);
+ expect(view.isUnreachable).toBe(false);
+
+ await advancePastDeadline();
+
+ expect(view.isPending).toBe(false);
+ expect(view.isPartial).toBe(false);
+ expect(view.isUnreachable).toBe(true);
+ expect(view.environments.every((entry) => entry.offline)).toBe(true);
+ } finally {
+ await unmount();
+ }
+ });
+
+ it("gives a revisited window a fresh grace period", async () => {
+ mocks.statuses = [status("env-away")];
+ const { render, unmount } = await mount();
+ try {
+ await advancePastDeadline();
+ expect(byId("env-away")?.offline).toBe(true);
+
+ await render(OTHER_WINDOW);
+ await render(WINDOW);
+
+ expect(byId("env-away")?.offline).toBe(false);
+
+ await advancePastDeadline();
+ expect(byId("env-away")?.offline).toBe(true);
+ } finally {
+ await unmount();
+ }
+ });
+});
diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts
index 7084127e5b18..242893569e86 100644
--- a/apps/web/src/state/usage.ts
+++ b/apps/web/src/state/usage.ts
@@ -81,6 +81,12 @@ export interface UsageView {
* not improve by waiting on them, so they must not read as "still reporting".
*/
readonly isPartial: boolean;
+ /**
+ * True when environments exist but none answered: every one is offline or
+ * failed. The merged zeros are not data, so pages must not show them as
+ * totals.
+ */
+ readonly isUnreachable: boolean;
readonly refresh: () => void;
}
@@ -112,6 +118,13 @@ export function useUsage(input: UsageSummaryInput): UsageView {
// invalidates it on the same render instead of one effect tick later.
const scanKey = `${scanNonce}:${windowKey}`;
const [passedScanKey, setPassedScanKey] = useState(null);
+ // Returning to a previously viewed window would match the flag that window
+ // left behind and skip the grace period, so drop it when the scan changes.
+ const [lastScanKey, setLastScanKey] = useState(scanKey);
+ if (lastScanKey !== scanKey) {
+ setLastScanKey(scanKey);
+ setPassedScanKey(null);
+ }
useEffect(() => {
const timer = setTimeout(() => setPassedScanKey(scanKey), OFFLINE_DEADLINE_MS);
return () => clearTimeout(timer);
@@ -169,6 +182,7 @@ export function useUsage(input: UsageSummaryInput): UsageView {
environments,
isPending: answeredCount === 0 && stillReporting > 0,
isPartial: answeredCount > 0 && stillReporting > 0,
+ isUnreachable: environments.length > 0 && answeredCount === 0 && stillReporting === 0,
refresh,
};
}
From 0518c1529eb9cfac3aa08007dcbcbeb68b84de4e Mon Sep 17 00:00:00 2001
From: Mark S <49048586+msegec@users.noreply.github.com>
Date: Thu, 27 Aug 2026 13:29:01 +0800
Subject: [PATCH 4/4] fix: new devices get the usage grace period after a scan
deadline
The offline deadline was keyed only on the scan nonce and window, so an
environment appearing after the deadline was marked offline with no grace
period. The environment set is now part of the deadline key in both the web
and mobile mirrors.
---
apps/mobile/src/state/usage.ts | 13 ++++++++++++-
apps/web/src/state/usage.test.tsx | 23 +++++++++++++++++++++++
apps/web/src/state/usage.ts | 13 ++++++++++++-
docs/user/usage.md | 4 ++++
4 files changed, 51 insertions(+), 2 deletions(-)
diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts
index 92fd61bfbcb9..69fa5b487f9d 100644
--- a/apps/mobile/src/state/usage.ts
+++ b/apps/mobile/src/state/usage.ts
@@ -117,9 +117,20 @@ export function useUsage(input: UsageSummaryInput): UsageView {
const reported = useAtomValue(atom);
const [scanNonce, setScanNonce] = useState(0);
+ // An environment that appears mid-scan must get the full grace period, so
+ // the set of scanned environments is part of the key: a set change restarts
+ // the deadline just like a refresh or a new window.
+ const environmentSetKey = useMemo(
+ () =>
+ reported
+ .map((environment) => environment.environmentId)
+ .sort()
+ .join(","),
+ [reported],
+ );
// The passed flag names the scan it belongs to, so a new window or refresh
// invalidates it on the same render instead of one effect tick later.
- const scanKey = `${scanNonce}:${windowKey}`;
+ const scanKey = `${scanNonce}:${environmentSetKey}:${windowKey}`;
const [passedScanKey, setPassedScanKey] = useState(null);
// Returning to a previously viewed window would match the flag that window
// left behind and skip the grace period, so drop it when the scan changes.
diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx
index 3c625adec0f8..6949e66b6b97 100644
--- a/apps/web/src/state/usage.test.tsx
+++ b/apps/web/src/state/usage.test.tsx
@@ -276,6 +276,29 @@ describe("useUsage offline deadline", () => {
}
});
+ it("gives an environment added after the deadline a fresh grace period", async () => {
+ mocks.statuses = [
+ status("env-answered", { summary: summary(), isPending: false }),
+ status("env-away"),
+ ];
+ const { render, unmount } = await mount();
+ try {
+ await advancePastDeadline();
+ expect(byId("env-away")?.offline).toBe(true);
+
+ mocks.statuses = [...mocks.statuses, status("env-new")];
+ await render();
+
+ expect(byId("env-new")?.offline).toBe(false);
+
+ await advancePastDeadline();
+ expect(byId("env-new")?.offline).toBe(true);
+ expect(byId("env-away")?.offline).toBe(true);
+ } finally {
+ await unmount();
+ }
+ });
+
it("gives a revisited window a fresh grace period", async () => {
mocks.statuses = [status("env-away")];
const { render, unmount } = await mount();
diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts
index 242893569e86..a31ba309a30e 100644
--- a/apps/web/src/state/usage.ts
+++ b/apps/web/src/state/usage.ts
@@ -114,9 +114,20 @@ export function useUsage(input: UsageSummaryInput): UsageView {
const reported = useAtomValue(atom);
const [scanNonce, setScanNonce] = useState(0);
+ // An environment that appears mid-scan must get the full grace period, so
+ // the set of scanned environments is part of the key: a set change restarts
+ // the deadline just like a refresh or a new window.
+ const environmentSetKey = useMemo(
+ () =>
+ reported
+ .map((environment) => environment.environmentId)
+ .sort()
+ .join(","),
+ [reported],
+ );
// The passed flag names the scan it belongs to, so a new window or refresh
// invalidates it on the same render instead of one effect tick later.
- const scanKey = `${scanNonce}:${windowKey}`;
+ const scanKey = `${scanNonce}:${environmentSetKey}:${windowKey}`;
const [passedScanKey, setPassedScanKey] = useState(null);
// Returning to a previously viewed window would match the flag that window
// left behind and skip the grace period, so drop it when the scan changes.
diff --git a/docs/user/usage.md b/docs/user/usage.md
index 72d19ba77f37..e32d97cb1943 100644
--- a/docs/user/usage.md
+++ b/docs/user/usage.md
@@ -8,3 +8,7 @@ cost shown here.
Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**,
**30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the
headline and chart, and refreshing rescans every connected environment.
+
+Devices that stay offline are left out of the totals after a short wait, and a coverage notice
+lists which ones are missing. If no device reports at all, the page shows an empty state instead
+of zero totals.