diff --git a/apps/web/src/state/usage.test.ts b/apps/web/src/state/usage.test.ts index d78a0294cae5..8903caaae6e8 100644 --- a/apps/web/src/state/usage.test.ts +++ b/apps/web/src/state/usage.test.ts @@ -113,3 +113,37 @@ describe("usage environment scope", () => { expect(scope.environments).toEqual(options); }); }); + +describe("usage refresh settling", () => { + it("settles a refresh only after every environment answers the new request", () => { + // Retained summaries from the previous request, both refreshing. + const macRefreshing = { + phase: "connected" as const, + isPending: true, + summary: {}, + error: null, + }; + const linuxRefreshing = { + phase: "connected" as const, + isPending: true, + summary: {}, + error: null, + }; + expect(getEnvironmentUsageLoadingState([macRefreshing, linuxRefreshing])).toEqual({ + isPending: true, + isPartial: false, + }); + + const macAnswered = { ...macRefreshing, isPending: false }; + expect(getEnvironmentUsageLoadingState([macAnswered, linuxRefreshing])).toEqual({ + isPending: false, + isPartial: true, + }); + + const linuxAnswered = { ...linuxRefreshing, isPending: false }; + expect(getEnvironmentUsageLoadingState([macAnswered, linuxAnswered])).toEqual({ + isPending: false, + isPartial: false, + }); + }); +}); diff --git a/apps/web/src/state/usageEnvironmentScope.ts b/apps/web/src/state/usageEnvironmentScope.ts index 51e080b781af..0c5d106526b4 100644 --- a/apps/web/src/state/usageEnvironmentScope.ts +++ b/apps/web/src/state/usageEnvironmentScope.ts @@ -38,13 +38,20 @@ interface EnvironmentUsageLoadingEntry { export function isEnvironmentUsageStillReporting( environment: EnvironmentUsageLoadingEntry, ): boolean { - return environment.isPending && environment.summary === null && environment.error === null; + // A retained summary does not mean this environment has reported: it is the + // previous request's answer, still on screen while the refresh runs. + return environment.isPending && environment.error === null; } export function getEnvironmentUsageLoadingState( environments: readonly EnvironmentUsageLoadingEntry[], ): { readonly isPending: boolean; readonly isPartial: boolean } { - const answeredCount = environments.filter((environment) => environment.summary !== null).length; + // SWR keeps the previous summary on screen while a refresh is in flight, so + // a retained value belongs to the *previous* request. Counting it as an + // answer settled a refresh before any environment had reported new numbers. + const answeredCount = environments.filter( + (environment) => environment.summary !== null && !environment.isPending, + ).length; const stillReporting = environments.filter(isEnvironmentUsageStillReporting).length; return { diff --git a/apps/web/src/state/usageStatus.test.ts b/apps/web/src/state/usageStatus.test.ts deleted file mode 100644 index 50300024703e..000000000000 --- a/apps/web/src/state/usageStatus.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -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 deleted file mode 100644 index 6e0d898d9719..000000000000 --- a/apps/web/src/state/usageStatus.ts +++ /dev/null @@ -1,81 +0,0 @@ -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, - }; -}