diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 8c09383d56c18..622f77abc6a12 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -1110,18 +1110,21 @@ describe("UsageService", () => { 1, ); assert.strictEqual(local[0]?.day, "2026-08-01"); - const hourly = muse( - yield* service.readSummary({ - ...window, - resolution: "hour", - sinceTime: "2026-08-01T00:00:00.000Z", - untilTime: "2026-08-02T00:00:00.000Z", - }), - ); - assert.strictEqual( - hourly.reduce((sum, b) => sum + b.records, 0), - 3, - ); + for (const days of [1, 7]) { + const hourly = muse( + yield* service.readSummary({ + ...window, + untilDay: UsageDay.make(days === 1 ? "2026-08-01" : "2026-08-07"), + resolution: "hour", + sinceTime: "2026-08-01T00:00:00.000Z", + untilTime: days === 1 ? "2026-08-02T00:00:00.000Z" : "2026-08-08T00:00:00.000Z", + }), + ); + assert.strictEqual( + hourly.reduce((sum, b) => sum + b.records, 0), + days === 1 ? 3 : 4, + ); + } const appended = encodeUnknownJsonString(entry("appended", "child")); const file = NodePath.join(child, "session.jsonl"); yield* Effect.promise(() => NodeFSP.appendFile(file, appended.slice(0, -10))); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 7e2e8586ed95f..9d1630d41d58a 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -68,7 +68,7 @@ const RATES_REFRESH_FLOOR_MS = 60 * 1000; // mtime slack includes writes just before local midnight on the window's first day. const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; -const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; +const MAX_HOURLY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; const CACHE_RETENTION_DAYS = 92; @@ -686,7 +686,7 @@ export const make = Effect.gen(function* () { if (durationMs <= 0 || durationMs > MAX_HOURLY_WINDOW_MS) { return yield* new UsageReadError({ reason: "invalidWindow", - detail: "Hourly usage window must be greater than zero and at most 24 hours", + detail: "Hourly usage window must be greater than zero and at most 7 days", }); } hourlyWindow = { sinceTimeMs, untilTimeMs }; diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index fb7e29948ecb0..3d46fad0e46a6 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -56,6 +56,7 @@ import { formatUsageContractMismatch, formatUsd, makeWindow, + resolutionForWindow, usageSourceMessages, } from "@t3tools/shared/usageFormat"; import { Button, InlineButton } from "../ui/button"; @@ -191,7 +192,7 @@ export function UsagePage() { window: makeWindow( preferences.windowDays, undefined, - preferences.windowDays === 1 ? "hour" : "day", + resolutionForWindow(preferences.windowDays), ), })); const metric = preferences.metric; @@ -204,7 +205,6 @@ export function UsagePage() { useState | null>(null); const hiddenProviders = new Set(preferences.hiddenProviders); const { days: windowDays, window } = windowSelection; - const isPast24Hours = windowDays === 1; const { merged: answeredUsage, environments, @@ -217,7 +217,7 @@ export function UsagePage() { const shownWindow = shown?.window ?? window; const shownHourly = shownWindow.resolution === "hour"; const refreshingUsage = isRefreshing && !showingLimits; - const showingKept = shown !== null && shown.window !== window; + const showingKept = shown !== null && shown.merged !== answeredUsage; const presentations = useAtomValue(environmentPresentations.presentationsAtom); const presentationsWithProviders = useAtomValue( environmentPresentations.presentationsWithProvidersAtom, @@ -243,7 +243,7 @@ export function UsagePage() { updatePreferences({ windowDays: days }); setWindowSelection({ days, - window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), + window: makeWindow(days, undefined, resolutionForWindow(days)), }); }; const selectMetric = (nextMetric: UsageMetric) => { @@ -309,7 +309,7 @@ export function UsagePage() { }); return; } - const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); + const nextWindow = makeWindow(windowDays, undefined, resolutionForWindow(windowDays)); if ( nextWindow.sinceDay !== window.sinceDay || nextWindow.untilDay !== window.untilDay || @@ -1031,7 +1031,11 @@ function UsageOverview({ > {"hourStart" in period - ? formatHourShort(period.hourStart, shownWindow.timeZone, hour12) + ? (hours.length > 24 ? formatDateTimeShort : formatHourShort)( + period.hourStart, + shownWindow.timeZone, + hour12, + ) : formatDayShort(period.day)} {activeProviders.map((provider) => ( diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 1745be4df897b..384d3d1448dfe 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -4,6 +4,7 @@ import { useLayoutEffect, useRef, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; import { + formatDateTimeShort, formatDayShort, formatHourShort, formatRelativeHourShort, @@ -436,7 +437,9 @@ export function UsageProviderChart({ cn("transition-opacity", loading && "opacity-40 delay-150"); const format = metric === "tokens" ? formatTokens : formatUsd; const formatPeriod = (period: string) => - resolution === "hour" ? formatHourShort(period, timeZone, hour12) : formatDayShort(period); + resolution === "hour" + ? (periods.length > 24 ? formatDateTimeShort : formatHourShort)(period, timeZone, hour12) + : formatDayShort(period); const formatTooltipPeriod = (period: string) => resolution === "hour" && referenceTime !== undefined ? formatRelativeHourShort(period, referenceTime, timeZone, hour12) diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index f47a65cd08268..cf92ddcd397de 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -11,9 +11,12 @@ import { import { needsCursorKeychainAccess, refreshUsage, + usageFallbackWindow, + dailyUsageWindow, usageSummaryInput, } from "@t3tools/client-runtime/state/usage"; import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; +import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/reactivity"; import { useState } from "react"; @@ -34,57 +37,73 @@ export interface EnvironmentUsageStatus { readonly needsCursorKeychainAccess: boolean; } -const usageByWindowAtom = Atom.family((windowKey: string) => - Atom.make((get): readonly EnvironmentUsageStatus[] => { - const input = JSON.parse(windowKey) as UsageSummaryInput; - const presentations = get(environmentPresentations.presentationsAtom); +interface UsageWindowResult { + readonly environments: readonly EnvironmentUsageStatus[]; + readonly window: UsageSummaryInput; +} + +const usageByWindowAtom: (key: string) => Atom.Atom = Atom.family( + (windowKey: string) => + Atom.make((get): UsageWindowResult => { + const { selectedIds, ...input } = JSON.parse(windowKey) as UsageSummaryInput & { + selectedIds: readonly EnvironmentId[] | null; + }; + const presentations = get(environmentPresentations.presentationsAtom); - const statuses: EnvironmentUsageStatus[] = []; - for (const [environmentId, presentation] of presentations) { - const isConnected = presentation.connection.phase === "connected"; - const sessionResult = get(environmentSession.sessionStateAtom(environmentId)); - const access = resolveUsageAccess({ - connectionPhase: presentation.connection.phase, - session: AsyncResult.getOrElse(sessionResult, () => null), - hasSessionError: sessionResult._tag === "Failure", - }); - if (!access.canReadDiagnostics) { + const statuses: EnvironmentUsageStatus[] = []; + for (const [environmentId, presentation] of presentations) { + const isConnected = presentation.connection.phase === "connected"; + const sessionResult = get(environmentSession.sessionStateAtom(environmentId)); + const access = resolveUsageAccess({ + connectionPhase: presentation.connection.phase, + session: AsyncResult.getOrElse(sessionResult, () => null), + hasSessionError: sessionResult._tag === "Failure", + }); + if (!access.canReadDiagnostics) { + statuses.push({ + environmentId, + label: presentation.entry.target.label, + isConnected, + ...access, + summary: null, + needsCursorKeychainAccess: false, + }); + continue; + } + const result = get( + serverEnvironment.usageSummary({ + environmentId, + input: usageSummaryInput( + input, + get(serverEnvironment.usageModelFilterAtom(environmentId)), + ), + }), + ); + if ( + result._tag === "Failure" && + (selectedIds === null || selectedIds.includes(environmentId)) + ) { + const fallback = usageFallbackWindow(input, Cause.squash(result.cause)); + if (fallback !== null) + return get(usageByWindowAtom(JSON.stringify({ ...fallback, selectedIds }))); + } + const summary = AsyncResult.getOrElse(result, () => null); statuses.push({ environmentId, label: presentation.entry.target.label, + isPending: result.waiting, + canReadDiagnostics: true, isConnected, - ...access, - summary: null, - needsCursorKeychainAccess: false, + error: result._tag === "Failure" ? "This environment could not report usage." : null, + summary, + needsCursorKeychainAccess: needsCursorKeychainAccess( + summary, + get(serverEnvironment.providersValueAtom(environmentId)), + ), }); - continue; } - const result = get( - serverEnvironment.usageSummary({ - environmentId, - input: usageSummaryInput( - input, - get(serverEnvironment.usageModelFilterAtom(environmentId)), - ), - }), - ); - const summary = AsyncResult.getOrElse(result, () => null); - statuses.push({ - environmentId, - label: presentation.entry.target.label, - isPending: result.waiting, - canReadDiagnostics: true, - isConnected, - error: result._tag === "Failure" ? "This environment could not report usage." : null, - summary, - needsCursorKeychainAccess: needsCursorKeychainAccess( - summary, - get(serverEnvironment.providersValueAtom(environmentId)), - ), - }); - } - return statuses; - }).pipe(Atom.withLabel(`web-usage:window:${windowKey}`)), + return { environments: statuses, window: input }; + }).pipe(Atom.withLabel(`web-usage:window:${windowKey}`)), ); export interface UsageView { @@ -167,6 +186,7 @@ export function useUsage( hiddenProviders: ReadonlySet = NO_HIDDEN_PROVIDERS, ): UsageView { const windowKey = JSON.stringify({ + selectedIds: selectedEnvironmentIds === null ? null : [...selectedEnvironmentIds].sort(), sinceDay: input.sinceDay, untilDay: input.untilDay, timeZone: input.timeZone, @@ -175,7 +195,7 @@ export function useUsage( untilTime: input.untilTime, }); const atom = usageByWindowAtom(windowKey); - const environments = useAtomValue(atom); + const { environments, window: effectiveWindow } = useAtomValue(atom); // Identity-based render sync needs a derivation retained by inputs across renders. const [derived, setDerived] = useState(() => deriveSelection(environments, selectedEnvironmentIds, hiddenProviders), @@ -203,7 +223,14 @@ export function useUsage( readEnvironmentScope(environment.environmentId, AuthDiagnosticsReadScope), ) .map(({ environmentId }) => environmentId), - input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), + input: + nextInput === undefined + ? effectiveWindow + : input.resolution === "hour" && + effectiveWindow.resolution === "day" && + nextInput.resolution === "hour" + ? dailyUsageWindow(nextInput) + : nextInput, }); const answeredCount = selectedEnvironments.filter( @@ -224,12 +251,12 @@ export function useUsage( if ( answeredCount > 0 && (lastAnswered?.merged !== merged || - lastAnswered.window !== input || + lastAnswered.window !== effectiveWindow || lastAnswered.selection !== selectedEnvironmentIds || lastAnswered.hidden !== hiddenProviders) ) { setLastAnswered({ - window: input, + window: effectiveWindow, merged, selection: selectedEnvironmentIds, hidden: hiddenProviders, @@ -241,8 +268,8 @@ export function useUsage( : null; const shown = answeredCount > 0 - ? { window: input, merged } - : (kept ?? (isPending ? null : { window: input, merged })); + ? { window: effectiveWindow, merged } + : (kept ?? (isPending ? null : { window: effectiveWindow, merged })); return { merged, diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 0650b1f2093c7..719322fb4614c 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -3,6 +3,7 @@ import { ProviderDriverKind, ProviderInstanceId, UsageDay, + UsageReadError, USAGE_CONTRACT_VERSION, type ServerProvider, type UsageModelFilter, @@ -19,6 +20,7 @@ import { needsCursorKeychainAccess, refreshUsage, refreshUsageLimits, + usageFallbackWindow, } from "./usage.ts"; const input = { @@ -287,3 +289,32 @@ describe("needsCursorKeychainAccess", () => { ).toEqual([]); }); }); + +describe("usageFallbackWindow", () => { + it("falls back only for unsupported seven-day hourly windows", () => { + const window = { + ...input, + sinceDay: UsageDay.make("2026-08-29"), + resolution: "hour" as const, + sinceTime: "2026-08-29T12:00:00Z", + untilTime: "2026-09-05T12:00:00Z", + }; + const invalid = new UsageReadError({ reason: "invalidWindow", detail: "24 hours maximum" }); + expect(usageFallbackWindow(window, invalid)).toEqual({ + sinceDay: "2026-08-30", + untilDay: "2026-09-05", + timeZone: "UTC", + resolution: "day", + }); + expect( + usageFallbackWindow( + window, + new UsageReadError({ reason: "scanFailed", detail: "scan failed" }), + ), + ).toBeNull(); + expect(usageFallbackWindow({ ...window, resolution: "day" }, invalid)).toBeNull(); + expect( + usageFallbackWindow({ ...window, sinceTime: "2026-09-04T12:00:00Z" }, invalid), + ).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 2dea725be1f14..fef0b2e877134 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -5,6 +5,7 @@ import type { UsageSummary, UsageSummaryInput, } from "@t3tools/contracts"; +import { UsageDay, UsageReadError } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import type { AtomRegistry } from "effect/reactivity"; @@ -13,6 +14,36 @@ import type { createEnvironmentPresentationAtoms } from "./presentation.ts"; import { executeAtomQuery, runAtomCommand, squashAtomCommandFailure } from "./runtime.ts"; import type { createServerEnvironmentAtoms } from "./server.ts"; +const isUsageReadError = Schema.is(UsageReadError); + +export function usageFallbackWindow( + input: UsageSummaryInput, + error: unknown, +): UsageSummaryInput | null { + if ( + !isUsageReadError(error) || + error.reason !== "invalidWindow" || + input.resolution !== "hour" || + input.sinceTime === undefined || + input.untilTime === undefined || + Date.parse(input.untilTime) - Date.parse(input.sinceTime) !== 7 * 24 * 60 * 60_000 + ) + return null; + return dailyUsageWindow(input); +} + +export function dailyUsageWindow(input: UsageSummaryInput): UsageSummaryInput { + // @effect-diagnostics-next-line globalDate:off -- Calendar-only UTC bounds preserve seven dates across DST. + const start = new Date(input.untilDay + "T00:00:00Z"); + start.setUTCDate(start.getUTCDate() - 6); + return { + sinceDay: UsageDay.make(start.toISOString().slice(0, 10)), + untilDay: input.untilDay, + timeZone: input.timeZone, + resolution: "day", + }; +} + const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); export function needsCursorKeychainAccess( diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index 6682a194ab278..c1e53a77789ab 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it, vi } from "vite-plus/test"; import type { UsageSource } from "@t3tools/contracts"; -import { formatHourShort, makeWindow, usageSourceMessages } from "./usageFormat.ts"; +import { + formatDateTimeShort, + formatHourShort, + makeWindow, + resolutionForWindow, + usageSourceMessages, +} from "./usageFormat.ts"; function malformedSource(count: number, overrides: Partial = {}): UsageSource { return { @@ -176,4 +182,21 @@ describe("hourly usage formatting", () => { resolvedOptions.mockRestore(); } }); + it("disambiguates repeated hours in date-plus-hour labels", () => { + expect(formatDateTimeShort("2026-11-01T05:37:00.000Z", "America/New_York")).toBe( + "Nov 1, 1 AM EDT", + ); + expect(formatDateTimeShort("2026-11-01T06:37:00.000Z", "America/New_York")).toBe( + "Nov 1, 1 AM EST", + ); + }); + + it("spans the whole week when a 7-day window is hourly", () => { + const window = makeWindow(7, new Date("2026-08-11T12:37:42.123Z"), resolutionForWindow(7)); + + expect(window.resolution).toBe("hour"); + expect(window.sinceTime).toBe("2026-08-04T12:37:00.000Z"); + expect(window.untilTime).toBe("2026-08-11T12:37:00.000Z"); + expect(resolutionForWindow(30)).toBe("day"); + }); }); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 51398982e04c8..a05d26cc4c2d5 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -175,16 +175,9 @@ function formatHour(formatter: Intl.DateTimeFormat, date: Date, hour12: boolean) * Repeated wall-clock hours during a fall-back transition include their short zone name so the * two distinct buckets remain distinguishable. */ -export function formatHourShort(hourStart: string, timeZone?: string, hour12 = true): string { - const instant = new Date(hourStart); - if (Number.isNaN(instant.getTime())) return hourStart; - const options = timeZone === undefined ? {} : { timeZone }; - const hourFormat = dateTimeFormatter("en-US", { - ...options, - ...hourOptions(hour12), - }); +function isRepeatedWallHour(instant: Date, timeZone?: string): boolean { const wallHourFormat = dateTimeFormatter("en-CA", { - ...options, + ...(timeZone === undefined ? {} : { timeZone }), year: "numeric", month: "2-digit", day: "2-digit", @@ -192,9 +185,20 @@ export function formatHourShort(hourStart: string, timeZone?: string, hour12 = t hourCycle: "h23", }); const wallHour = wallHourFormat.format(instant); - const isRepeatedHour = [-HOUR_MS, HOUR_MS].some( + return [-HOUR_MS, HOUR_MS].some( (offset) => wallHourFormat.format(new Date(instant.getTime() + offset)) === wallHour, ); +} + +export function formatHourShort(hourStart: string, timeZone?: string, hour12 = true): string { + const instant = new Date(hourStart); + if (Number.isNaN(instant.getTime())) return hourStart; + const options = timeZone === undefined ? {} : { timeZone }; + const hourFormat = dateTimeFormatter("en-US", { + ...options, + ...hourOptions(hour12), + }); + const isRepeatedHour = isRepeatedWallHour(instant, timeZone); if (!isRepeatedHour) return formatHour(hourFormat, instant, hour12); const zonedHourFormat = dateTimeFormatter("en-US", { @@ -213,6 +217,7 @@ export function formatDateTimeShort(instant: string, timeZone?: string, hour12 = month: "short", day: "numeric", ...hourOptions(hour12), + ...(isRepeatedWallHour(date, timeZone) ? { timeZoneName: "short" } : {}), }); return formatHour(dateHourFormat, date, hour12); } @@ -245,6 +250,10 @@ export function formatRelativeHourShort( return formatDateTimeShort(hourStart, timeZone, hour12); } +export function resolutionForWindow(days: number): UsageResolution { + return days <= 7 ? "hour" : "day"; +} + export function makeWindow( days: number, now = new Date(), @@ -271,7 +280,7 @@ export function makeWindow( const untilDay = format.format(now); if (resolution === "hour") { const untilTimeMs = Math.floor(now.getTime() / 60_000) * 60_000; - const sinceTimeMs = untilTimeMs - 24 * HOUR_MS; + const sinceTimeMs = untilTimeMs - days * 24 * HOUR_MS; const sinceTime = new Date(sinceTimeMs); const untilTime = new Date(untilTimeMs); return {