diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index b5b762aea73..b7989b0f3cd 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -449,29 +449,29 @@ function UsageDeviceStrip({ readonly environments: readonly EnvironmentUsageStatus[]; }) { const scanning = environments.filter( - (environment) => environment.summary === null && environment.error === null, + (environment) => environment.isPending && environment.error === null, ); return (
{environments.map((environment) => { - if (environment.summary !== null) { + if (environment.error !== null) { return ( - + {environment.label} ); } - if (environment.error !== null) { + if (!environment.isPending && environment.summary !== null) { return ( - + {environment.label} ); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 9d65b6ad600..3bee01bcac5 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -7,28 +7,21 @@ * @module state/usage */ import { useAtomValue } from "@effect/atom-react"; -import { - USAGE_CONTRACT_VERSION, - type EnvironmentId, - type UsageSummary, - type UsageSummaryInput, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { USAGE_CONTRACT_VERSION, type UsageSummaryInput } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentPresentations } from "./presentation"; import { serverEnvironment } from "./server"; +import { + deriveEnvironmentUsageStatus, + deriveUsageSettlingState, + type EnvironmentUsageStatus, +} from "./usageStatus"; -export interface EnvironmentUsageStatus { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly isPending: boolean; - readonly error: string | null; - readonly summary: UsageSummary | null; -} +export type { EnvironmentUsageStatus } from "./usageStatus"; /** * Reads every environment's summary for one window. @@ -45,13 +38,14 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const statuses: EnvironmentUsageStatus[] = []; for (const [environmentId, presentation] of presentations) { const result = get(serverEnvironment.usageSummary({ environmentId, input })); - statuses.push({ - environmentId, - label: presentation.entry.target.label, - isPending: result.waiting, - error: result._tag === "Failure" ? "This environment could not report usage." : null, - summary: Option.getOrNull(AsyncResult.value(result)), - }); + statuses.push( + deriveEnvironmentUsageStatus({ + connectionPhase: presentation.connection.phase, + result, + environmentId, + label: presentation.entry.target.label, + }), + ); } return statuses; }).pipe(Atom.withLabel(`web-usage:window:${windowKey}`)), @@ -111,16 +105,13 @@ export function useUsage(input: UsageSummaryInput): UsageView { return mergeUsage(answered, USAGE_CONTRACT_VERSION); }, [environments]); - const answeredCount = environments.filter((environment) => environment.summary !== null).length; - const stillReporting = environments.filter( - (environment) => environment.summary === null && environment.error === null, - ).length; + const settling = deriveUsageSettlingState(environments); return { merged, environments, - isPending: answeredCount === 0 && stillReporting > 0, - isPartial: answeredCount > 0 && stillReporting > 0, + isPending: settling.isPending, + isPartial: settling.isPartial, refresh, }; } diff --git a/apps/web/src/state/usageStatus.test.ts b/apps/web/src/state/usageStatus.test.ts new file mode 100644 index 00000000000..50300024703 --- /dev/null +++ b/apps/web/src/state/usageStatus.test.ts @@ -0,0 +1,157 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { + USAGE_CONTRACT_VERSION, + type EnvironmentId, + type UsageDay, + type UsageSummary, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { describe, expect, it } from "vite-plus/test"; + +import { deriveEnvironmentUsageStatus, deriveUsageSettlingState } from "./usageStatus"; + +const summary: UsageSummary = { + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "2026-08-09T00:00:00.000Z", + timeZone: "UTC", + sinceDay: "2026-08-01" as UsageDay, + untilDay: "2026-08-09" as UsageDay, + buckets: [], + sources: [], + pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 0 }, + scanDurationMs: 1, +}; + +const freshSummary: UsageSummary = { + ...summary, + readAt: "2026-08-09T00:01:00.000Z", + scanDurationMs: 2, +}; + +function status( + environmentId: string, + connectionPhase: EnvironmentConnectionPhase, + result: AsyncResult.AsyncResult, +) { + return deriveEnvironmentUsageStatus({ + environmentId: environmentId as EnvironmentId, + label: environmentId, + connectionPhase, + result, + }); +} + +describe("usage status", () => { + it("settles a refresh only after every environment answers the new request", () => { + const macRefreshing = status( + "mac", + "connected", + AsyncResult.success(summary, { waiting: true }), + ); + const linuxRefreshing = status( + "linux", + "connected", + AsyncResult.success(summary, { waiting: true }), + ); + + expect(macRefreshing.summary).toBe(summary); + expect(deriveUsageSettlingState([macRefreshing, linuxRefreshing])).toEqual({ + isPending: true, + isPartial: false, + }); + + const macFinished = status("mac", "connected", AsyncResult.success(summary)); + expect(deriveUsageSettlingState([macFinished, linuxRefreshing])).toEqual({ + isPending: false, + isPartial: true, + }); + + const linuxFinished = status("linux", "connected", AsyncResult.success(summary)); + expect(deriveUsageSettlingState([macFinished, linuxFinished])).toEqual({ + isPending: false, + isPartial: false, + }); + }); + + it("waits while an environment makes its initial connection", () => { + expect( + status("connecting", "connecting", AsyncResult.initial(true)), + ).toEqual(expect.objectContaining({ isPending: true, error: null, summary: null })); + }); + + it("waits for a connected environment's first usage result", () => { + expect(status("connected", "connected", AsyncResult.initial())).toEqual( + expect.objectContaining({ isPending: true, error: null, summary: null }), + ); + }); + + it("waits through reconnect and a fresh scan before completing", () => { + const reconnecting = status("laptop", "reconnecting", AsyncResult.success(summary)); + const refreshing = status( + "laptop", + "connected", + AsyncResult.success(summary, { waiting: true }), + ); + const refreshed = status("laptop", "connected", AsyncResult.success(freshSummary)); + + expect(reconnecting).toEqual( + expect.objectContaining({ isPending: true, error: null, summary }), + ); + expect(refreshing).toEqual(expect.objectContaining({ isPending: true, error: null, summary })); + expect(refreshed).toEqual( + expect.objectContaining({ isPending: false, error: null, summary: freshSummary }), + ); + }); + + it("settles terminal connection phases without a completed summary as failures", () => { + for (const connectionPhase of ["available", "offline", "error"] as const) { + expect( + status("offline", connectionPhase, AsyncResult.initial(true)), + ).toEqual( + expect.objectContaining({ + isPending: false, + error: "This environment could not report usage.", + summary: null, + }), + ); + } + }); + + it("keeps a completed summary when its environment later disconnects", () => { + const result = AsyncResult.success(summary); + const connected = status("laptop", "connected", result); + const disconnected = status("laptop", "offline", result); + + expect(connected).toEqual(expect.objectContaining({ isPending: false, error: null, summary })); + expect(disconnected).toEqual(connected); + }); + + it("drops a retained previous summary when its refresh fails", () => { + const previous = AsyncResult.success(summary); + const retrying = AsyncResult.failWithPrevious("scan failed", { + previous: Option.some(previous), + waiting: true, + }); + const failed = AsyncResult.failWithPrevious("scan failed", { + previous: Option.some(previous), + }); + + expect(status("desktop", "connected", retrying)).toEqual( + expect.objectContaining({ isPending: true, error: null, summary }), + ); + + const failedStatus = status("desktop", "connected", failed); + expect(failedStatus).toEqual( + expect.objectContaining({ + isPending: false, + error: "This environment could not report usage.", + summary: null, + }), + ); + expect(deriveUsageSettlingState([failedStatus])).toEqual({ + isPending: false, + isPartial: false, + }); + }); +}); diff --git a/apps/web/src/state/usageStatus.ts b/apps/web/src/state/usageStatus.ts new file mode 100644 index 00000000000..6e0d898d971 --- /dev/null +++ b/apps/web/src/state/usageStatus.ts @@ -0,0 +1,81 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId, UsageSummary } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; + +const USAGE_REPORT_ERROR = "This environment could not report usage."; + +export interface EnvironmentUsageStatus { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPending: boolean; + readonly error: string | null; + readonly summary: UsageSummary | null; +} + +type UsageConnectionState = "connected" | "transitioning" | "terminal"; + +/** + * Connection phases decide whether a usage request can still make progress. + * Keeping this exhaustive makes a newly added phase an explicit product + * decision instead of silently treating it as a failure. + */ +function classifyUsageConnection(phase: EnvironmentConnectionPhase): UsageConnectionState { + switch (phase) { + case "connected": + return "connected"; + case "connecting": + case "reconnecting": + return "transitioning"; + case "available": + case "offline": + case "error": + return "terminal"; + } +} + +/** Projects transport and SWR state into the status rendered for one environment. */ +export function deriveEnvironmentUsageStatus(input: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly connectionPhase: EnvironmentConnectionPhase; + readonly result: AsyncResult.AsyncResult; +}): EnvironmentUsageStatus { + const connection = classifyUsageConnection(input.connectionPhase); + const isPending = + connection === "transitioning" || + (connection === "connected" && (input.result.waiting || input.result._tag === "Initial")); + const summary = Option.getOrNull(AsyncResult.value(input.result)); + const hasTerminalQueryFailure = input.result._tag === "Failure" && !isPending; + return { + environmentId: input.environmentId, + label: input.label, + isPending, + error: + hasTerminalQueryFailure || (connection === "terminal" && summary === null) + ? USAGE_REPORT_ERROR + : null, + summary: hasTerminalQueryFailure ? null : summary, + }; +} + +/** Derives the page gate for the current request generation. */ +export function deriveUsageSettlingState(environments: readonly EnvironmentUsageStatus[]): { + readonly isPending: boolean; + readonly isPartial: boolean; +} { + // SWR preserves the previous summary while a refresh is in flight. A + // retained value belongs to the previous request, so it does not count as + // this request having answered until `waiting` clears. + const answeredCount = environments.filter( + (environment) => environment.summary !== null && !environment.isPending, + ).length; + const stillReporting = environments.filter( + (environment) => environment.isPending && environment.error === null, + ).length; + + return { + isPending: answeredCount === 0 && stillReporting > 0, + isPartial: answeredCount > 0 && stillReporting > 0, + }; +}