authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-11 16:47:13+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-11 16:47:13+02:00
logc842c6f5b90122ad88b1d2295df6a99e94954571
tree46d66475fbc011f2be7e03c52c791e69534abf33
parent6676f9c83b6a6dd1e2076eaa68dff48845ad955b
signature Signed by PGP key B5690EEEBB952194

Add hourly past-24-hour usage view (#6170)


16 files changed, 668 insertions(+), 95 deletions(-)

apps/mobile/src/features/usage/UsageRouteScreen.tsx+76-18
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1import { useNavigation } from "@react-navigation/native";1import { useNavigation } from "@react-navigation/native";
2import type { MergedUsage } from "@t3tools/shared/usageMerge";2import type { DailyTotals, MergedUsage } from "@t3tools/shared/usageMerge";
3import {3import {
4 enumerateDays,4 enumerateDays,
5 enumerateHourStarts,
5 formatCount,6 formatCount,
6 formatDayShort,7 formatDayShort,
8 formatHourShort,
7 formatPercent,9 formatPercent,
8 formatTokens,10 formatTokens,
9 formatUsd,11 formatUsd,
...@@ -23,6 +25,7 @@ import type { UsageChartMetric } from "./usageChartData";...@@ -23,6 +25,7 @@ import type { UsageChartMetric } from "./usageChartData";
23import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";25import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";
2426
25const WINDOW_OPTIONS = [27const WINDOW_OPTIONS = [
28 { days: 1, label: "Past 24h" },
26 { days: 7, label: "7 days" },29 { days: 7, label: "7 days" },
27 { days: 30, label: "30 days" },30 { days: 30, label: "30 days" },
28 { days: 90, label: "90 days" },31 { days: 90, label: "90 days" },
...@@ -33,23 +36,62 @@ const CHART_HEIGHT = 180;...@@ -33,23 +36,62 @@ const CHART_HEIGHT = 180;
33export function UsageRouteScreen() {36export function UsageRouteScreen() {
34 const navigation = useNavigation();37 const navigation = useNavigation();
35 const insets = useSafeAreaInsets();38 const insets = useSafeAreaInsets();
36 const [windowDays, setWindowDays] = useState<number>(30);39 const [windowSelection, setWindowSelection] = useState(() => ({
40 days: 30,
41 window: makeWindow(30),
42 }));
37 const [metric, setMetric] = useState<UsageChartMetric>("cost");43 const [metric, setMetric] = useState<UsageChartMetric>("cost");
3844 const { days: windowDays, window } = windowSelection;
39 // Recomputed only when the window length changes, so a re-render does not45 const isPast24Hours = windowDays === 1;
40 // shift the range and refetch every environment.
41 const window = useMemo(() => makeWindow(windowDays), [windowDays]);
42 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);46 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);
4347
44 const days = useMemo(48 const days = useMemo(
45 () => enumerateDays(window.sinceDay, window.untilDay),49 () => enumerateDays(window.sinceDay, window.untilDay),
46 [window.sinceDay, window.untilDay],50 [window.sinceDay, window.untilDay],
47 );51 );
52 const chartDays = useMemo(
53 () =>
54 isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined
55 ? enumerateHourStarts(window.sinceTime, window.untilTime)
56 : days,
57 [days, isPast24Hours, window.sinceTime, window.untilTime],
58 );
59 const chartTotals = useMemo(
60 (): readonly DailyTotals[] =>
61 isPast24Hours
62 ? merged.hourly.map((hour) => ({
63 day: hour.hourStart,
64 costUsd: hour.costUsd,
65 totalTokens: hour.totalTokens,
66 byProvider: hour.byProvider,
67 }))
68 : merged.daily,
69 [isPast24Hours, merged.daily, merged.hourly],
70 );
4871
49 // The pull spinner tracks re-scans of environments that have answered72 // The pull spinner tracks re-scans of environments that have answered
50 // before. The initial scan renders its own placeholder, and an unreachable73 // before. The initial scan renders its own placeholder, and an unreachable
51 // environment stays pending forever — neither may pin the spinner on.74 // environment stays pending forever — neither may pin the spinner on.
52 const refreshing = environments.some((entry) => entry.isPending && entry.summary !== null);75 const refreshing = environments.some((entry) => entry.isPending && entry.summary !== null);
76 const selectWindow = (days: number) => {
77 setWindowSelection({
78 days,
79 window: makeWindow(days, undefined, days === 1 ? "hour" : "day"),
80 });
81 };
82 const refreshWindow = () => {
83 const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day");
84 if (
85 nextWindow.sinceDay === window.sinceDay &&
86 nextWindow.untilDay === window.untilDay &&
87 nextWindow.sinceTime === window.sinceTime &&
88 nextWindow.untilTime === window.untilTime
89 ) {
90 refresh();
91 } else {
92 setWindowSelection({ days: windowDays, window: nextWindow });
93 }
94 };
5395
54 return (96 return (
55 <View collapsable={false} className="flex-1 bg-sheet">97 <View collapsable={false} className="flex-1 bg-sheet">
...@@ -65,12 +107,12 @@ export function UsageRouteScreen() {...@@ -65,12 +107,12 @@ export function UsageRouteScreen() {
65 className="flex-1"107 className="flex-1"
66 contentContainerClassName="gap-6 px-5 pt-4"108 contentContainerClassName="gap-6 px-5 pt-4"
67 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}109 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
68 refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} />}110 refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refreshWindow} />}
69 >111 >
70 <SegmentedControl112 <SegmentedControl
71 options={WINDOW_OPTIONS.map((option) => ({ value: option.days, label: option.label }))}113 options={WINDOW_OPTIONS.map((option) => ({ value: option.days, label: option.label }))}
72 selected={windowDays}114 selected={windowDays}
73 onSelect={setWindowDays}115 onSelect={selectWindow}
74 />116 />
75117
76 <UsageCoverageNotice environments={environments} merged={merged} isPartial={isPartial} />118 <UsageCoverageNotice environments={environments} merged={merged} isPartial={isPartial} />
...@@ -87,14 +129,17 @@ export function UsageRouteScreen() {...@@ -87,14 +129,17 @@ export function UsageRouteScreen() {
87 <>129 <>
88 <ChartCard130 <ChartCard
89 merged={merged}131 merged={merged}
90 days={days}132 days={chartDays}
133 daily={chartTotals}
91 metric={metric}134 metric={metric}
92 onMetricChange={setMetric}135 onMetricChange={setMetric}
93 sinceDay={window.sinceDay}136 sinceDay={window.sinceDay}
94 untilDay={window.untilDay}137 untilDay={window.untilDay}
138 isPast24Hours={isPast24Hours}
139 timeZone={window.timeZone}
95 />140 />
96 <ProviderSection merged={merged} metric={metric} />141 <ProviderSection merged={merged} metric={metric} />
97 <TotalsSection merged={merged} />142 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
98 <ModelsSection merged={merged} />143 <ModelsSection merged={merged} />
99 </>144 </>
100 )}145 )}
...@@ -142,14 +187,17 @@ function SegmentedControl<Value extends number | string>(props: {...@@ -142,14 +187,17 @@ function SegmentedControl<Value extends number | string>(props: {
142function ChartCard(props: {187function ChartCard(props: {
143 readonly merged: MergedUsage;188 readonly merged: MergedUsage;
144 readonly days: readonly string[];189 readonly days: readonly string[];
190 readonly daily: readonly DailyTotals[];
145 readonly metric: UsageChartMetric;191 readonly metric: UsageChartMetric;
146 readonly onMetricChange: (metric: UsageChartMetric) => void;192 readonly onMetricChange: (metric: UsageChartMetric) => void;
147 readonly sinceDay: string;193 readonly sinceDay: string;
148 readonly untilDay: string;194 readonly untilDay: string;
195 readonly isPast24Hours: boolean;
196 readonly timeZone: string;
149}) {197}) {
150 const { merged, metric } = props;198 const { merged, metric } = props;
151 const colors = useProviderColors();199 const colors = useProviderColors();
152 const hasActivity = merged.daily.some((day) => day.totalTokens > 0);200 const hasActivity = props.daily.some((period) => period.totalTokens > 0);
153201
154 return (202 return (
155 <View className="gap-4 rounded-[24px] border-continuous bg-card p-4">203 <View className="gap-4 rounded-[24px] border-continuous bg-card p-4">
...@@ -173,7 +221,7 @@ function ChartCard(props: {...@@ -173,7 +221,7 @@ function ChartCard(props: {
173 {hasActivity ? (221 {hasActivity ? (
174 <UsageDailyChart222 <UsageDailyChart
175 days={props.days}223 days={props.days}
176 daily={merged.daily}224 daily={props.daily}
177 metric={metric}225 metric={metric}
178 height={CHART_HEIGHT}226 height={CHART_HEIGHT}
179 />227 />
...@@ -184,7 +232,11 @@ function ChartCard(props: {...@@ -184,7 +232,11 @@ function ChartCard(props: {
184 )}232 )}
185233
186 <View className="flex-row items-center justify-between">234 <View className="flex-row items-center justify-between">
187 <Text className="text-xs text-foreground-tertiary">{formatDayShort(props.sinceDay)}</Text>235 <Text className="text-xs text-foreground-tertiary">
236 {props.isPast24Hours
237 ? formatHourShort(props.days[0] ?? "", props.timeZone)
238 : formatDayShort(props.sinceDay)}
239 </Text>
188 <View className="flex-row items-center gap-4">240 <View className="flex-row items-center gap-4">
189 {merged.providers.map((provider) => (241 {merged.providers.map((provider) => (
190 <View key={provider.provider} className="flex-row items-center gap-1.5">242 <View key={provider.provider} className="flex-row items-center gap-1.5">
...@@ -198,7 +250,11 @@ function ChartCard(props: {...@@ -198,7 +250,11 @@ function ChartCard(props: {
198 </View>250 </View>
199 ))}251 ))}
200 </View>252 </View>
201 <Text className="text-xs text-foreground-tertiary">{formatDayShort(props.untilDay)}</Text>253 <Text className="text-xs text-foreground-tertiary">
254 {props.isPast24Hours
255 ? formatHourShort(props.days[props.days.length - 1] ?? "", props.timeZone)
256 : formatDayShort(props.untilDay)}
257 </Text>
202 </View>258 </View>
203 </View>259 </View>
204 );260 );
...@@ -292,10 +348,12 @@ function ProviderSection(props: {...@@ -292,10 +348,12 @@ function ProviderSection(props: {
292 );348 );
293}349}
294350
295function TotalsSection(props: { readonly merged: MergedUsage }) {351function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24Hours: boolean }) {
296 const { merged } = props;352 const { merged } = props;
297 const activeDays = merged.daily.filter((day) => day.totalTokens > 0).length;353 const activePeriods = (props.isPast24Hours ? merged.hourly : merged.daily).filter(
298 const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays;354 (period) => period.totalTokens > 0,
355 ).length;
356 const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods;
299 const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens;357 const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens;
300 const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput;358 const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput;
301359
...@@ -305,7 +363,7 @@ function TotalsSection(props: { readonly merged: MergedUsage }) {...@@ -305,7 +363,7 @@ function TotalsSection(props: { readonly merged: MergedUsage }) {
305 <MetricCell363 <MetricCell
306 label="Processed tokens"364 label="Processed tokens"
307 value={formatTokens(merged.totalTokens)}365 value={formatTokens(merged.totalTokens)}
308 detail={`${formatTokens(dailyAverage)} per active day`}366 detail={`${formatTokens(periodAverage)} per active ${props.isPast24Hours ? "hour" : "day"}`}
309 />367 />
310 <MetricCell368 <MetricCell
311 label="Cache savings"369 label="Cache savings"
apps/mobile/src/state/usage.ts+11-1
...@@ -81,8 +81,18 @@ export function useUsage(input: UsageSummaryInput): UsageView {...@@ -81,8 +81,18 @@ export function useUsage(input: UsageSummaryInput): UsageView {
81 sinceDay: input.sinceDay,81 sinceDay: input.sinceDay,
82 untilDay: input.untilDay,82 untilDay: input.untilDay,
83 timeZone: input.timeZone,83 timeZone: input.timeZone,
84 resolution: input.resolution,
85 sinceTime: input.sinceTime,
86 untilTime: input.untilTime,
84 }),87 }),
85 [input.sinceDay, input.untilDay, input.timeZone],88 [
89 input.sinceDay,
90 input.untilDay,
91 input.timeZone,
92 input.resolution,
93 input.sinceTime,
94 input.untilTime,
95 ],
86 );96 );
87 const atom = usageByWindowAtom(windowKey);97 const atom = usageByWindowAtom(windowKey);
88 const environments = useAtomValue(atom);98 const environments = useAtomValue(atom);
apps/server/src/usage/UsageService.ts+30-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1/**1/**
2 * UsageService - scans provider transcripts and returns priced daily usage.2 * UsageService - scans provider transcripts and returns priced usage buckets.
3 *3 *
4 * The scan reads the provider CLIs' own session files rather than T3 Code's4 * The scan reads the provider CLIs' own session files rather than T3 Code's
5 * orchestration projections, so usage covers turns driven outside T3 Code too.5 * orchestration projections, so usage covers turns driven outside T3 Code too.
...@@ -64,6 +64,7 @@ const RATES_TTL_MS = 24 * 60 * 60 * 1000;...@@ -64,6 +64,7 @@ const RATES_TTL_MS = 24 * 60 * 60 * 1000;
64 * last write lands just before local midnight on the window's first day.64 * last write lands just before local midnight on the window's first day.
65 */65 */
66const MTIME_SLACK_MS = 36 * 60 * 60 * 1000;66const MTIME_SLACK_MS = 36 * 60 * 60 * 1000;
67const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000;
6768
68/** Longest window the UI offers, plus slack. Older entries are pruned. */69/** Longest window the UI offers, plus slack. Older entries are pruned. */
69const CACHE_RETENTION_DAYS = 90;70const CACHE_RETENTION_DAYS = 90;
...@@ -297,6 +298,30 @@ export const make = Effect.gen(function* () {...@@ -297,6 +298,30 @@ export const make = Effect.gen(function* () {
297 });298 });
298 }299 }
299300
301 let hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null = null;
302 if (input.resolution === "hour") {
303 const sinceTime =
304 input.sinceTime === undefined ? Option.none() : DateTime.make(input.sinceTime);
305 const untilTime =
306 input.untilTime === undefined ? Option.none() : DateTime.make(input.untilTime);
307 if (Option.isNone(sinceTime) || Option.isNone(untilTime)) {
308 return yield* new UsageReadError({
309 reason: "invalidWindow",
310 detail: "Hourly usage requires valid sinceTime and untilTime instants",
311 });
312 }
313 const sinceTimeMs = DateTime.toEpochMillis(sinceTime.value);
314 const untilTimeMs = DateTime.toEpochMillis(untilTime.value);
315 const durationMs = untilTimeMs - sinceTimeMs;
316 if (durationMs <= 0 || durationMs > MAX_HOURLY_WINDOW_MS) {
317 return yield* new UsageReadError({
318 reason: "invalidWindow",
319 detail: "Hourly usage window must be greater than zero and at most 24 hours",
320 });
321 }
322 hourlyWindow = { sinceTimeMs, untilTimeMs };
323 }
324
300 const startedAtMs = yield* Clock.currentTimeMillis;325 const startedAtMs = yield* Clock.currentTimeMillis;
301 yield* ensureRates();326 yield* ensureRates();
302 yield* ensureScanCacheLoaded;327 yield* ensureScanCacheLoaded;
...@@ -312,12 +337,15 @@ export const make = Effect.gen(function* () {...@@ -312,12 +337,15 @@ export const make = Effect.gen(function* () {
312 detail: `sinceDay '${input.sinceDay}' is not a valid date`,337 detail: `sinceDay '${input.sinceDay}' is not a valid date`,
313 });338 });
314 }339 }
315 const windowStartMs = DateTime.toEpochMillis(windowStart.value) - MTIME_SLACK_MS;340 const windowStartMs =
341 (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS;
316342
317 const aggregator = new UsageAggregator({343 const aggregator = new UsageAggregator({
318 timeZone: input.timeZone,344 timeZone: input.timeZone,
319 sinceDay: input.sinceDay,345 sinceDay: input.sinceDay,
320 untilDay: input.untilDay,346 untilDay: input.untilDay,
347 resolution: input.resolution ?? "day",
348 ...hourlyWindow,
321 rates,349 rates,
322 });350 });
323351
apps/server/src/usage/usageAggregation.test.ts+73-1
...@@ -36,11 +36,24 @@ function record(overrides: Partial<UsageRecord> = {}): UsageRecord {...@@ -36,11 +36,24 @@ function record(overrides: Partial<UsageRecord> = {}): UsageRecord {
36 };36 };
37}37}
3838
39function aggregate(records: readonly UsageRecord[], timeZone = "UTC") {39function aggregate(
40 records: readonly UsageRecord[],
41 timeZone = "UTC",
42 resolution: "day" | "hour" = "day",
43) {
44 const hourlyBounds =
45 resolution === "hour"
46 ? {
47 sinceTimeMs: Date.parse("2026-08-06T04:37:00.000Z"),
48 untilTimeMs: Date.parse("2026-08-07T04:37:00.000Z"),
49 }
50 : {};
40 const aggregator = new UsageAggregator({51 const aggregator = new UsageAggregator({
41 timeZone,52 timeZone,
42 sinceDay: "2026-08-01",53 sinceDay: "2026-08-01",
43 untilDay: "2026-08-31",54 untilDay: "2026-08-31",
55 resolution,
56 ...hourlyBounds,
44 rates,57 rates,
45 });58 });
46 for (const item of records) aggregator.add(item);59 for (const item of records) aggregator.add(item);
...@@ -48,6 +61,19 @@ function aggregate(records: readonly UsageRecord[], timeZone = "UTC") {...@@ -48,6 +61,19 @@ function aggregate(records: readonly UsageRecord[], timeZone = "UTC") {
48}61}
4962
50describe("UsageAggregator", () => {63describe("UsageAggregator", () => {
64 it("requires exact bounds for hourly aggregation", () => {
65 expect(
66 () =>
67 new UsageAggregator({
68 timeZone: "UTC",
69 sinceDay: "2026-08-01",
70 untilDay: "2026-08-31",
71 resolution: "hour",
72 rates,
73 }),
74 ).toThrow("requires exact time bounds");
75 });
76
51 it("keeps only the first record for a repeated dedupe key", () => {77 it("keeps only the first record for a repeated dedupe key", () => {
52 const result = aggregate([78 const result = aggregate([
53 record({ dedupeKey: "msg_1:" }),79 record({ dedupeKey: "msg_1:" }),
...@@ -76,6 +102,52 @@ describe("UsageAggregator", () => {...@@ -76,6 +102,52 @@ describe("UsageAggregator", () => {
76 expect(losAngeles.buckets[0]?.day).toBe("2026-08-06");102 expect(losAngeles.buckets[0]?.day).toBe("2026-08-06");
77 });103 });
78104
105 it("splits an hourly request into fixed buckets anchored to its exact start", () => {
106 const result = aggregate(
107 [
108 record({ timestampMs: Date.parse("2026-08-07T02:40:13.944Z") }),
109 record({ timestampMs: Date.parse("2026-08-07T03:40:13.944Z") }),
110 ],
111 "America/Los_Angeles",
112 "hour",
113 );
114
115 expect(result.buckets.map((bucket) => [bucket.day, bucket.hourStart])).toEqual([
116 ["2026-08-06", "2026-08-07T02:37:00.000Z"],
117 ["2026-08-06", "2026-08-07T03:37:00.000Z"],
118 ]);
119 });
120
121 it("uses an inclusive start and exclusive end for rolling windows", () => {
122 const result = aggregate(
123 [
124 record({ timestampMs: Date.parse("2026-08-06T04:36:59.999Z") }),
125 record({ timestampMs: Date.parse("2026-08-06T04:37:00.000Z") }),
126 record({ timestampMs: Date.parse("2026-08-07T04:36:59.999Z") }),
127 record({ timestampMs: Date.parse("2026-08-07T04:37:00.000Z") }),
128 ],
129 "UTC",
130 "hour",
131 );
132
133 expect(result.outOfWindow).toBe(2);
134 expect(result.buckets.map((bucket) => bucket.hourStart)).toEqual([
135 "2026-08-06T04:37:00.000Z",
136 "2026-08-07T03:37:00.000Z",
137 ]);
138 });
139
140 it("keeps daily payloads collapsed when hourly resolution is not requested", () => {
141 const result = aggregate([
142 record({ timestampMs: Date.parse("2026-08-07T04:05:13.944Z") }),
143 record({ timestampMs: Date.parse("2026-08-07T05:05:13.944Z") }),
144 ]);
145
146 expect(result.buckets).toHaveLength(1);
147 expect(result.buckets[0]?.hourStart).toBeUndefined();
148 expect(result.buckets[0]?.records).toBe(2);
149 });
150
79 it("prices against the rate table", () => {151 it("prices against the rate table", () => {
80 const result = aggregate([record()]);152 const result = aggregate([record()]);
81153
apps/server/src/usage/usageAggregation.ts
Binary files a/apps/server/src/usage/usageAggregation.ts and b/apps/server/src/usage/usageAggregation.ts differ
apps/web/src/components/usage/UsagePage.tsx+98-33
...@@ -2,13 +2,18 @@ import type { UsageProviderKind } from "@t3tools/contracts";...@@ -2,13 +2,18 @@ import type { UsageProviderKind } from "@t3tools/contracts";
2import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react";2import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react";
3import { useMemo, useState } from "react";3import { useMemo, useState } from "react";
44
5import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";
6
5import { isElectron } from "../../env";7import { isElectron } from "../../env";
6import { cn } from "../../lib/utils";8import { cn } from "../../lib/utils";
7import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";9import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
8import {10import {
9 enumerateDays,11 enumerateDays,
12 enumerateHourStarts,
10 formatCount,13 formatCount,
14 formatDateTimeShort,
11 formatDayShort,15 formatDayShort,
16 formatHourShort,
12 formatPercent,17 formatPercent,
13 formatTokens,18 formatTokens,
14 formatUsd,19 formatUsd,
...@@ -22,19 +27,21 @@ import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./U...@@ -22,19 +27,21 @@ import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./U
22import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";27import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";
2328
24const WINDOW_OPTIONS = [29const WINDOW_OPTIONS = [
30 { days: 1, label: "Past 24h" },
25 { days: 7, label: "7 days" },31 { days: 7, label: "7 days" },
26 { days: 30, label: "30 days" },32 { days: 30, label: "30 days" },
27 { days: 90, label: "90 days" },33 { days: 90, label: "90 days" },
28] as const;34] as const;
2935
30export function UsagePage() {36export function UsagePage() {
31 const [windowDays, setWindowDays] = useState<number>(30);37 const [windowSelection, setWindowSelection] = useState(() => ({
38 days: 30,
39 window: makeWindow(30),
40 }));
32 const [metric, setMetric] = useState<UsageChartMetric>("cost");41 const [metric, setMetric] = useState<UsageChartMetric>("cost");
33 const [breakdown, setBreakdown] = useState<"model" | "day">("model");42 const [breakdown, setBreakdown] = useState<"model" | "time">("model");
3443 const { days: windowDays, window } = windowSelection;
35 // Recomputed only when the window length changes, so a re-render does not44 const isPast24Hours = windowDays === 1;
36 // shift the range and refetch every environment.
37 const window = useMemo(() => makeWindow(windowDays), [windowDays]);
38 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);45 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);
3946
40 // Hold the content until every environment is terminal. Rendering merged47 // Hold the content until every environment is terminal. Rendering merged
...@@ -46,7 +53,17 @@ export function UsagePage() {...@@ -46,7 +53,17 @@ export function UsagePage() {
46 () => enumerateDays(window.sinceDay, window.untilDay),53 () => enumerateDays(window.sinceDay, window.untilDay),
47 [window.sinceDay, window.untilDay],54 [window.sinceDay, window.untilDay],
48 );55 );
49 const recentDays = useMemo(() => merged.daily.toReversed().slice(0, 8), [merged.daily]);56 const hours = useMemo(
57 () =>
58 window.sinceTime === undefined || window.untilTime === undefined
59 ? []
60 : enumerateHourStarts(window.sinceTime, window.untilTime),
61 [window.sinceTime, window.untilTime],
62 );
63 const recentPeriods = useMemo<readonly (DailyTotals | HourlyTotals)[]>(
64 () => (isPast24Hours ? merged.hourly : merged.daily).toReversed().slice(0, 8),
65 [isPast24Hours, merged.daily, merged.hourly],
66 );
5067
51 // Ranked by whatever the toggle is showing, so the bars always descend.68 // Ranked by whatever the toggle is showing, so the bars always descend.
52 const orderedProviders = useMemo(69 const orderedProviders = useMemo(
...@@ -57,10 +74,31 @@ export function UsagePage() {...@@ -57,10 +74,31 @@ export function UsagePage() {
57 [merged.providers, metric],74 [merged.providers, metric],
58 );75 );
5976
60 const activeDays = merged.daily.filter((day) => day.totalTokens > 0).length;77 const activePeriods = (isPast24Hours ? merged.hourly : merged.daily).filter(
61 const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays;78 (period) => period.totalTokens > 0,
79 ).length;
80 const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods;
62 const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens;81 const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens;
63 const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput;82 const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput;
83 const selectWindow = (days: number) => {
84 setWindowSelection({
85 days,
86 window: makeWindow(days, undefined, days === 1 ? "hour" : "day"),
87 });
88 };
89 const refreshWindow = () => {
90 const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day");
91 if (
92 nextWindow.sinceDay === window.sinceDay &&
93 nextWindow.untilDay === window.untilDay &&
94 nextWindow.sinceTime === window.sinceTime &&
95 nextWindow.untilTime === window.untilTime
96 ) {
97 refresh();
98 } else {
99 setWindowSelection({ days: windowDays, window: nextWindow });
100 }
101 };
64102
65 return (103 return (
66 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">104 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
...@@ -95,17 +133,20 @@ export function UsagePage() {...@@ -95,17 +133,20 @@ export function UsagePage() {
95 <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-6">133 <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-6">
96 <div className="flex flex-wrap items-center justify-between gap-4">134 <div className="flex flex-wrap items-center justify-between gap-4">
97 <p className="text-sm text-muted-foreground">135 <p className="text-sm text-muted-foreground">
98 {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)}136 {isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined
137 ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
138 : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`}
99 </p>139 </p>
100 <div className="flex items-center gap-2">140 <div className="flex items-center gap-2">
101 <div className="flex overflow-hidden rounded-md border border-border">141 <div className="flex rounded-md border border-border">
102 {WINDOW_OPTIONS.map((option) => (142 {WINDOW_OPTIONS.map((option) => (
103 <button143 <button
104 key={option.days}144 key={option.days}
105 type="button"145 type="button"
106 onClick={() => setWindowDays(option.days)}146 aria-pressed={option.days === windowDays}
147 onClick={() => selectWindow(option.days)}
107 className={cn(148 className={cn(
108 "cursor-pointer px-3 py-1.5 text-xs",149 "relative cursor-pointer px-3 py-1.5 text-xs outline-none first:rounded-s-[calc(var(--radius-md)-1px)] last:rounded-e-[calc(var(--radius-md)-1px)] focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
109 option.days === windowDays150 option.days === windowDays
110 ? "bg-muted text-foreground"151 ? "bg-muted text-foreground"
111 : "text-muted-foreground hover:text-foreground",152 : "text-muted-foreground hover:text-foreground",
...@@ -117,7 +158,7 @@ export function UsagePage() {...@@ -117,7 +158,7 @@ export function UsagePage() {
117 </div>158 </div>
118 <button159 <button
119 type="button"160 type="button"
120 onClick={refresh}161 onClick={refreshWindow}
121 aria-label="Refresh usage"162 aria-label="Refresh usage"
122 className="cursor-pointer rounded-md border border-border p-2 text-muted-foreground hover:text-foreground"163 className="cursor-pointer rounded-md border border-border p-2 text-muted-foreground hover:text-foreground"
123 >164 >
...@@ -129,7 +170,7 @@ export function UsagePage() {...@@ -129,7 +170,7 @@ export function UsagePage() {
129 {settling ? (170 {settling ? (
130 <>171 <>
131 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}172 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}
132 <UsageSkeleton />173 <UsageSkeleton resolution={isPast24Hours ? "hour" : "day"} />
133 </>174 </>
134 ) : (175 ) : (
135 <>176 <>
...@@ -197,7 +238,8 @@ export function UsagePage() {...@@ -197,7 +238,8 @@ export function UsagePage() {
197 <div className="flex flex-col gap-3">238 <div className="flex flex-col gap-3">
198 <div className="flex flex-wrap items-center justify-between gap-3">239 <div className="flex flex-wrap items-center justify-between gap-3">
199 <h2 className="text-sm font-medium text-foreground">240 <h2 className="text-sm font-medium text-foreground">
200 Daily {metric === "tokens" ? "processed tokens" : "cost"}241 {isPast24Hours ? "Hourly" : "Daily"}{" "}
242 {metric === "tokens" ? "processed tokens" : "cost"}
201 </h2>243 </h2>
202 <div className="flex items-center gap-4">244 <div className="flex items-center gap-4">
203 <div className="flex overflow-hidden rounded-md border border-border">245 <div className="flex overflow-hidden rounded-md border border-border">
...@@ -220,7 +262,16 @@ export function UsagePage() {...@@ -220,7 +262,16 @@ export function UsagePage() {
220 <UsageChartLegend />262 <UsageChartLegend />
221 </div>263 </div>
222 </div>264 </div>
223 <UsageProviderChart days={days} daily={merged.daily} metric={metric} />265 <UsageProviderChart
266 days={days}
267 daily={merged.daily}
268 hours={hours}
269 hourly={merged.hourly}
270 metric={metric}
271 referenceTime={window.untilTime}
272 resolution={isPast24Hours ? "hour" : "day"}
273 timeZone={window.timeZone}
274 />
224 </div>275 </div>
225 </section>276 </section>
226277
...@@ -228,7 +279,7 @@ export function UsagePage() {...@@ -228,7 +279,7 @@ export function UsagePage() {
228 <Metric279 <Metric
229 label="Processed tokens"280 label="Processed tokens"
230 value={formatTokens(merged.totalTokens)}281 value={formatTokens(merged.totalTokens)}
231 detail={`${formatTokens(dailyAverage)} per active day`}282 detail={`${formatTokens(periodAverage)} per active ${isPast24Hours ? "hour" : "day"}`}
232 />283 />
233 <Metric284 <Metric
234 label="Cached input"285 label="Cached input"
...@@ -260,19 +311,24 @@ export function UsagePage() {...@@ -260,19 +311,24 @@ export function UsagePage() {
260 <div className="flex items-center justify-between gap-3">311 <div className="flex items-center justify-between gap-3">
261 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>312 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>
262 <div className="flex overflow-hidden rounded-md border border-border">313 <div className="flex overflow-hidden rounded-md border border-border">
263 {(["model", "day"] as const).map((option) => (314 {(
315 [
316 { value: "model", label: "model" },
317 { value: "time", label: isPast24Hours ? "hour" : "day" },
318 ] as const
319 ).map((option) => (
264 <button320 <button
265 key={option}321 key={option.value}
266 type="button"322 type="button"
267 onClick={() => setBreakdown(option)}323 onClick={() => setBreakdown(option.value)}
268 className={cn(324 className={cn(
269 "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase",325 "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase",
270 option === breakdown326 option.value === breakdown
271 ? "bg-muted text-foreground"327 ? "bg-muted text-foreground"
272 : "text-muted-foreground hover:text-foreground",328 : "text-muted-foreground hover:text-foreground",
273 )}329 )}
274 >330 >
275 {option}331 {option.label}
276 </button>332 </button>
277 ))}333 ))}
278 </div>334 </div>
...@@ -325,7 +381,7 @@ export function UsagePage() {...@@ -325,7 +381,7 @@ export function UsagePage() {
325 <table className="w-full text-sm">381 <table className="w-full text-sm">
326 <thead>382 <thead>
327 <tr className="border-b border-border text-left text-xs text-muted-foreground">383 <tr className="border-b border-border text-left text-xs text-muted-foreground">
328 <th className="py-2 font-normal">Day</th>384 <th className="py-2 font-normal">{isPast24Hours ? "Hour" : "Day"}</th>
329 {PROVIDER_ORDER.map((provider) => (385 {PROVIDER_ORDER.map((provider) => (
330 <th key={provider} className="py-2 text-right font-normal">386 <th key={provider} className="py-2 text-right font-normal">
331 {PROVIDER_LABEL[provider]}387 {PROVIDER_LABEL[provider]}
...@@ -336,29 +392,36 @@ export function UsagePage() {...@@ -336,29 +392,36 @@ export function UsagePage() {
336 </tr>392 </tr>
337 </thead>393 </thead>
338 <tbody>394 <tbody>
339 {recentDays.length === 0 ? (395 {recentPeriods.length === 0 ? (
340 <tr>396 <tr>
341 <td colSpan={5} className="py-6 text-center text-muted-foreground">397 <td colSpan={5} className="py-6 text-center text-muted-foreground">
342 No activity in this window.398 No activity in this window.
343 </td>399 </td>
344 </tr>400 </tr>
345 ) : (401 ) : (
346 recentDays.map((day) => (402 recentPeriods.map((period) => (
347 <tr key={day.day} className="border-b border-border/50">403 <tr
348 <td className="py-2 text-foreground">{formatDayShort(day.day)}</td>404 key={"hourStart" in period ? period.hourStart : period.day}
405 className="border-b border-border/50"
406 >
407 <td className="py-2 text-foreground">
408 {"hourStart" in period
409 ? formatHourShort(period.hourStart, window.timeZone)
410 : formatDayShort(period.day)}
411 </td>
349 {PROVIDER_ORDER.map((provider) => (412 {PROVIDER_ORDER.map((provider) => (
350 <td413 <td
351 key={provider}414 key={provider}
352 className="py-2 text-right text-muted-foreground tabular-nums"415 className="py-2 text-right text-muted-foreground tabular-nums"
353 >416 >
354 {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)}417 {formatUsd(period.byProvider.get(provider)?.costUsd ?? 0)}
355 </td>418 </td>
356 ))}419 ))}
357 <td className="py-2 text-right text-foreground tabular-nums">420 <td className="py-2 text-right text-foreground tabular-nums">
358 {formatUsd(day.costUsd)}421 {formatUsd(period.costUsd)}
359 </td>422 </td>
360 <td className="py-2 text-right text-muted-foreground tabular-nums">423 <td className="py-2 text-right text-muted-foreground tabular-nums">
361 {formatTokens(day.totalTokens)}424 {formatTokens(period.totalTokens)}
362 </td>425 </td>
363 </tr>426 </tr>
364 ))427 ))
...@@ -513,7 +576,7 @@ const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44...@@ -513,7 +576,7 @@ const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44
513 * chart and metrics strip. No shimmer; blocks fill in exactly once when the576 * chart and metrics strip. No shimmer; blocks fill in exactly once when the
514 * last device answers.577 * last device answers.
515 */578 */
516function UsageSkeleton() {579function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) {
517 return (580 return (
518 <>581 <>
519 <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">582 <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
...@@ -542,7 +605,9 @@ function UsageSkeleton() {...@@ -542,7 +605,9 @@ function UsageSkeleton() {
542 </div>605 </div>
543606
544 <div className="flex flex-col gap-3">607 <div className="flex flex-col gap-3">
545 <h2 className="py-1 text-sm font-medium text-foreground">Daily cost</h2>608 <h2 className="py-1 text-sm font-medium text-foreground">
609 {resolution === "hour" ? "Hourly" : "Daily"} cost
610 </h2>
546 {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a611 {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a
547 relayout when the real chart swaps in. */}612 relayout when the real chart swaps in. */}
548 <div className="flex h-56 items-end gap-1 pl-16">613 <div className="flex h-56 items-end gap-1 pl-16">
apps/web/src/components/usage/UsageProviderChart.test.ts+25
...@@ -95,3 +95,28 @@ describe("buildDayColumns", () => {...@@ -95,3 +95,28 @@ describe("buildDayColumns", () => {
95 }95 }
96 });96 });
97});97});
98
99describe("hourly chart columns", () => {
100 it("zero-fills inactive hours and preserves hourly provider values", () => {
101 const byHour = new Map([
102 [
103 "2026-08-11T09:37:00.000Z",
104 {
105 day: "2026-08-11",
106 hourStart: "2026-08-11T09:37:00.000Z",
107 costUsd: 4,
108 totalTokens: 40,
109 byProvider: new Map([["codex" as const, { costUsd: 4, totalTokens: 40 }]]),
110 },
111 ],
112 ]);
113
114 expect(
115 buildDayColumns(
116 ["2026-08-11T08:37:00.000Z", "2026-08-11T09:37:00.000Z", "2026-08-11T10:37:00.000Z"],
117 byHour,
118 "cost",
119 ).map((column) => column.total),
120 ).toEqual([0, 4, 0]);
121 });
122});
apps/web/src/components/usage/UsageProviderChart.tsx+74-31
...@@ -1,8 +1,14 @@...@@ -1,8 +1,14 @@
1import type { UsageProviderKind } from "@t3tools/contracts";1import type { UsageProviderKind } from "@t3tools/contracts";
2import { useCallback, useMemo, useRef, useState } from "react";2import { useCallback, useMemo, useRef, useState } from "react";
33
4import type { DailyTotals } from "@t3tools/shared/usageMerge";4import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";
5import { formatDayShort, formatTokens, formatUsd } from "@t3tools/shared/usageFormat";5import {
6 formatDayShort,
7 formatHourShort,
8 formatRelativeHourShort,
9 formatTokens,
10 formatUsd,
11} from "@t3tools/shared/usageFormat";
6import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";12import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";
713
8const VIEW_WIDTH = 960;14const VIEW_WIDTH = 960;
...@@ -15,7 +21,12 @@ export type UsageChartMetric = "tokens" | "cost";...@@ -15,7 +21,12 @@ export type UsageChartMetric = "tokens" | "cost";
15interface UsageProviderChartProps {21interface UsageProviderChartProps {
16 readonly days: readonly string[];22 readonly days: readonly string[];
17 readonly daily: readonly DailyTotals[];23 readonly daily: readonly DailyTotals[];
24 readonly hours: readonly string[];
25 readonly hourly: readonly HourlyTotals[];
18 readonly metric: UsageChartMetric;26 readonly metric: UsageChartMetric;
27 readonly referenceTime: string | undefined;
28 readonly resolution: "day" | "hour";
29 readonly timeZone: string;
19}30}
2031
21/** One day's per-provider values, shared by the paths and the hover readout. */32/** One day's per-provider values, shared by the paths and the hover readout. */
...@@ -33,15 +44,30 @@ interface Point {...@@ -33,15 +44,30 @@ interface Point {
33}44}
3445
35function valueFor(46function valueFor(
36 daily: DailyTotals | undefined,47 totals: DailyTotals | HourlyTotals | undefined,
37 provider: UsageProviderKind,48 provider: UsageProviderKind,
38 metric: UsageChartMetric,49 metric: UsageChartMetric,
39): number {50): number {
40 const entry = daily?.byProvider.get(provider);51 const entry = totals?.byProvider.get(provider);
41 if (entry === undefined) return 0;52 if (entry === undefined) return 0;
42 return metric === "tokens" ? entry.totalTokens : entry.costUsd;53 return metric === "tokens" ? entry.totalTokens : entry.costUsd;
43}54}
4455
56function buildPeriodColumns(
57 periods: readonly string[],
58 byPeriod: ReadonlyMap<string, DailyTotals | HourlyTotals>,
59 metric: UsageChartMetric,
60): readonly DayColumn[] {
61 return periods.map((period) => {
62 const entry = byPeriod.get(period);
63 const bands = PROVIDER_ORDER.map((provider) => ({
64 provider,
65 value: valueFor(entry, provider, metric),
66 }));
67 return { bands, total: bands.reduce((sum, band) => sum + band.value, 0) };
68 });
69}
70
45/**71/**
46 * Monotone cubic tangents (Fritsch-Carlson).72 * Monotone cubic tangents (Fritsch-Carlson).
47 *73 *
...@@ -167,23 +193,32 @@ export function buildDayColumns(...@@ -167,23 +193,32 @@ export function buildDayColumns(
167 byDay: ReadonlyMap<string, DailyTotals>,193 byDay: ReadonlyMap<string, DailyTotals>,
168 metric: UsageChartMetric,194 metric: UsageChartMetric,
169): readonly DayColumn[] {195): readonly DayColumn[] {
170 return days.map((day) => {196 return buildPeriodColumns(days, byDay, metric);
171 const entry = byDay.get(day);
172 const bands = PROVIDER_ORDER.map((provider) => ({
173 provider,
174 value: valueFor(entry, provider, metric),
175 }));
176 return { bands, total: bands.reduce((sum, band) => sum + band.value, 0) };
177 });
178}197}
179198
180export function UsageProviderChart({ days, daily, metric }: UsageProviderChartProps) {199export function UsageProviderChart({
181 const byDay = useMemo(() => new Map(daily.map((entry) => [entry.day, entry])), [daily]);200 days,
201 daily,
202 hours,
203 hourly,
204 metric,
205 referenceTime,
206 resolution,
207 timeZone,
208}: UsageProviderChartProps) {
209 const periods = resolution === "hour" ? hours : days;
210 const byPeriod = useMemo(
211 () =>
212 resolution === "hour"
213 ? new Map(hourly.map((entry) => [entry.hourStart, entry]))
214 : new Map(daily.map((entry) => [entry.day, entry])),
215 [daily, hourly, resolution],
216 );
182 const [hoverIndex, setHoverIndex] = useState<number | null>(null);217 const [hoverIndex, setHoverIndex] = useState<number | null>(null);
183 const plotRef = useRef<HTMLDivElement | null>(null);218 const plotRef = useRef<HTMLDivElement | null>(null);
184219
185 const { paths, ticks, stepX, toY, series } = useMemo(() => {220 const { paths, ticks, stepX, toY, series } = useMemo(() => {
186 if (days.length === 0) {221 if (periods.length === 0) {
187 return {222 return {
188 paths: [],223 paths: [],
189 ticks: [0] as readonly number[],224 ticks: [0] as readonly number[],
...@@ -193,7 +228,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr...@@ -193,7 +228,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
193 };228 };
194 }229 }
195230
196 const columns = buildDayColumns(days, byDay, metric);231 const columns = buildPeriodColumns(periods, byPeriod, metric);
197232
198 // The scale tops out at the largest single provider-day, not the largest233 // The scale tops out at the largest single provider-day, not the largest
199 // sum: layered series each measure from zero, so a combined peak would234 // sum: layered series each measure from zero, so a combined peak would
...@@ -203,7 +238,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr...@@ -203,7 +238,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
203 0,238 0,
204 );239 );
205 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);240 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);
206 const step = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1);241 const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1);
207 // Reserve a sliver above the top gridline so the series stroke, which is242 // Reserve a sliver above the top gridline so the series stroke, which is
208 // drawn at constant screen width, is not shaved off at a peak.243 // drawn at constant screen width, is not shaved off at a peak.
209 const toY = (value: number) =>244 const toY = (value: number) =>
...@@ -231,24 +266,30 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr...@@ -231,24 +266,30 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
231 const ordered = [...built].sort((a, b) => b.total - a.total);266 const ordered = [...built].sort((a, b) => b.total - a.total);
232267
233 return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns };268 return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns };
234 }, [byDay, days, metric]);269 }, [byPeriod, metric, periods]);
235270
236 const format = metric === "tokens" ? formatTokens : formatUsd;271 const format = metric === "tokens" ? formatTokens : formatUsd;
237272
238 const handleMove = useCallback(273 const handleMove = useCallback(
239 (event: React.MouseEvent<HTMLDivElement>) => {274 (event: React.MouseEvent<HTMLDivElement>) => {
240 const bounds = plotRef.current?.getBoundingClientRect();275 const bounds = plotRef.current?.getBoundingClientRect();
241 if (bounds === undefined || bounds.width === 0 || days.length === 0) return;276 if (bounds === undefined || bounds.width === 0 || periods.length === 0) return;
242 const fraction = (event.clientX - bounds.left) / bounds.width;277 const fraction = (event.clientX - bounds.left) / bounds.width;
243 const index = Math.round(fraction * (days.length - 1));278 const index = Math.round(fraction * (periods.length - 1));
244 setHoverIndex(Math.min(days.length - 1, Math.max(0, index)));279 setHoverIndex(Math.min(periods.length - 1, Math.max(0, index)));
245 },280 },
246 [days.length],281 [periods.length],
247 );282 );
248283
249 const hoveredDay = hoverIndex === null ? undefined : days[hoverIndex];284 const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];
250 const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];285 const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];
251 const hoverLeft = days.length <= 1 ? 0 : ((hoverIndex ?? 0) / (days.length - 1)) * 100;286 const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100;
287 const formatPeriod = (period: string) =>
288 resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);
289 const formatTooltipPeriod = (period: string) =>
290 resolution === "hour" && referenceTime !== undefined
291 ? formatRelativeHourShort(period, referenceTime, timeZone)
292 : formatPeriod(period);
252293
253 return (294 return (
254 <div className="flex flex-col gap-1">295 <div className="flex flex-col gap-1">
...@@ -277,7 +318,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr...@@ -277,7 +318,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
277 viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}318 viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
278 preserveAspectRatio="none"319 preserveAspectRatio="none"
279 role="img"320 role="img"
280 aria-label={`Daily ${metric === "tokens" ? "processed tokens" : "cost"} by provider`}321 aria-label={`${resolution === "hour" ? "Hourly" : "Daily"} ${metric === "tokens" ? "processed tokens" : "cost"} by provider`}
281 >322 >
282 {ticks.map((tick) => {323 {ticks.map((tick) => {
283 const y = toY(tick);324 const y = toY(tick);
...@@ -325,7 +366,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr...@@ -325,7 +366,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
325 )}366 )}
326 </svg>367 </svg>
327368
328 {hoveredDay === undefined ? null : (369 {hoveredPeriod === undefined ? null : (
329 <div370 <div
330 className="pointer-events-none absolute top-0 z-10 min-w-36 border border-border bg-background/95 px-2 py-1.5 text-xs"371 className="pointer-events-none absolute top-0 z-10 min-w-36 border border-border bg-background/95 px-2 py-1.5 text-xs"
331 style={{372 style={{
...@@ -333,7 +374,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr...@@ -333,7 +374,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
333 transform: hoverLeft > 60 ? "translateX(-100%)" : "translateX(0)",374 transform: hoverLeft > 60 ? "translateX(-100%)" : "translateX(0)",
334 }}375 }}
335 >376 >
336 <div className="mb-1 text-muted-foreground">{formatDayShort(hoveredDay)}</div>377 <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div>
337 {PROVIDER_ORDER.map((provider) => {378 {PROVIDER_ORDER.map((provider) => {
338 const Mark = PROVIDER_MARK[provider];379 const Mark = PROVIDER_MARK[provider];
339 return (380 return (
...@@ -362,14 +403,16 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr...@@ -362,14 +403,16 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
362 </div>403 </div>
363404
364 <div className="flex justify-between pl-16 text-[10px] text-muted-foreground uppercase">405 <div className="flex justify-between pl-16 text-[10px] text-muted-foreground uppercase">
365 <span>{days[0] === undefined ? "" : formatDayShort(days[0])}</span>406 <span>{periods[0] === undefined ? "" : formatPeriod(periods[0])}</span>
366 <span>407 <span>
367 {days[Math.floor(days.length / 2)] === undefined408 {periods[Math.floor(periods.length / 2)] === undefined
368 ? ""409 ? ""
369 : formatDayShort(days[Math.floor(days.length / 2)] ?? "")}410 : formatPeriod(periods[Math.floor(periods.length / 2)] ?? "")}
370 </span>411 </span>
371 <span>412 <span>
372 {days[days.length - 1] === undefined ? "" : formatDayShort(days[days.length - 1] ?? "")}413 {periods[periods.length - 1] === undefined
414 ? ""
415 : formatPeriod(periods[periods.length - 1] ?? "")}
373 </span>416 </span>
374 </div>417 </div>
375 </div>418 </div>
apps/web/src/state/usage.ts+11-1
...@@ -78,8 +78,18 @@ export function useUsage(input: UsageSummaryInput): UsageView {...@@ -78,8 +78,18 @@ export function useUsage(input: UsageSummaryInput): UsageView {
78 sinceDay: input.sinceDay,78 sinceDay: input.sinceDay,
79 untilDay: input.untilDay,79 untilDay: input.untilDay,
80 timeZone: input.timeZone,80 timeZone: input.timeZone,
81 resolution: input.resolution,
82 sinceTime: input.sinceTime,
83 untilTime: input.untilTime,
81 }),84 }),
82 [input.sinceDay, input.untilDay, input.timeZone],85 [
86 input.sinceDay,
87 input.untilDay,
88 input.timeZone,
89 input.resolution,
90 input.sinceTime,
91 input.untilTime,
92 ],
83 );93 );
84 const atom = usageByWindowAtom(windowKey);94 const atom = usageByWindowAtom(windowKey);
85 const environments = useAtomValue(atom);95 const environments = useAtomValue(atom);
docs/README.md+1
...@@ -6,6 +6,7 @@...@@ -6,6 +6,7 @@
6- [Permission modes](./user/permission-modes.md)6- [Permission modes](./user/permission-modes.md)
7- [Keyboard shortcuts](./user/keybindings.md)7- [Keyboard shortcuts](./user/keybindings.md)
8- [Organizing threads](./user/thread-sidebar.md)8- [Organizing threads](./user/thread-sidebar.md)
9- [Review usage](./user/usage.md)
9- [Customize a project icon](./user/project-settings.md)10- [Customize a project icon](./user/project-settings.md)
10- [Remote access](./user/remote-access.md)11- [Remote access](./user/remote-access.md)
11- [Keeping app and server in sync](./user/updating.md)12- [Keeping app and server in sync](./user/updating.md)
docs/user/usage.md created+10
...@@ -0,0 +1,10 @@
1# Review usage
2
3The Usage page combines Codex and Claude Code activity from your connected environments. It reads
4the providers' local session history and shows API-equivalent token cost, processed tokens, cache
5savings, provider shares, and model breakdowns. Subscription billing is separate from the raw token
6cost shown here.
7
8Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**,
9**30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the
10headline and chart, and refreshing rescans every connected environment.
packages/contracts/src/usage.ts+15-4
...@@ -7,8 +7,8 @@...@@ -7,8 +7,8 @@
7 * even for turns that were never driven through T3 Code. This mirrors the7 * even for turns that were never driven through T3 Code. This mirrors the
8 * approach `ccusage` takes.8 * approach `ccusage` takes.
9 *9 *
10 * Environments return pre-aggregated `(day, provider, model)` buckets. Raw10 * Environments return pre-aggregated `(day, hourStart?, provider, model)`
11 * transcript records never cross the wire.11 * buckets. Raw transcript records never cross the wire.
12 *12 *
13 * @module usage13 * @module usage
14 */14 */
...@@ -21,7 +21,7 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";...@@ -21,7 +21,7 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
21 * client renders partial coverage when an environment reports an older version21 * client renders partial coverage when an environment reports an older version
22 * rather than failing the whole page.22 * rather than failing the whole page.
23 */23 */
24export const USAGE_CONTRACT_VERSION = 3 as const;24export const USAGE_CONTRACT_VERSION = 4 as const;
2525
26export const UsageProviderKind = Schema.Literals(["claude", "codex"]);26export const UsageProviderKind = Schema.Literals(["claude", "codex"]);
27export type UsageProviderKind = typeof UsageProviderKind.Type;27export type UsageProviderKind = typeof UsageProviderKind.Type;
...@@ -39,6 +39,9 @@ export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_P...@@ -39,6 +39,9 @@ export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_P
39);39);
40export type UsageDay = typeof UsageDay.Type;40export type UsageDay = typeof UsageDay.Type;
4141
42export const UsageResolution = Schema.Literals(["day", "hour"]);
43export type UsageResolution = typeof UsageResolution.Type;
44
42/**45/**
43 * Why a bucket's cost is what it is.46 * Why a bucket's cost is what it is.
44 *47 *
...@@ -68,7 +71,8 @@ export const UsageTokenTotals = Schema.Struct({...@@ -68,7 +71,8 @@ export const UsageTokenTotals = Schema.Struct({
68export type UsageTokenTotals = typeof UsageTokenTotals.Type;71export type UsageTokenTotals = typeof UsageTokenTotals.Type;
6972
70/**73/**
71 * One `(day, provider, model)` cell.74 * One `(day, hourStart?, provider, model)` cell. `hourStart` is the UTC start
75 * instant of a rolling bucket and is present only for hourly requests.
72 *76 *
73 * `costUsd` is the raw API-equivalent cost of these tokens. It is not money77 * `costUsd` is the raw API-equivalent cost of these tokens. It is not money
74 * spent: subscription plans bill separately. `unpricedRecords` counts records78 * spent: subscription plans bill separately. `unpricedRecords` counts records
...@@ -77,6 +81,7 @@ export type UsageTokenTotals = typeof UsageTokenTotals.Type;...@@ -77,6 +81,7 @@ export type UsageTokenTotals = typeof UsageTokenTotals.Type;
77 */81 */
78export const UsageBucket = Schema.Struct({82export const UsageBucket = Schema.Struct({
79 day: UsageDay,83 day: UsageDay,
84 hourStart: Schema.optional(TrimmedNonEmptyString),
80 provider: UsageProviderKind,85 provider: UsageProviderKind,
81 model: TrimmedNonEmptyString,86 model: TrimmedNonEmptyString,
82 totals: UsageTokenTotals,87 totals: UsageTokenTotals,
...@@ -165,6 +170,12 @@ export const UsageSummaryInput = Schema.Struct({...@@ -165,6 +170,12 @@ export const UsageSummaryInput = Schema.Struct({
165 * any window that crosses a DST boundary.170 * any window that crosses a DST boundary.
166 */171 */
167 timeZone: TrimmedNonEmptyString,172 timeZone: TrimmedNonEmptyString,
173 /** Defaults to daily for older clients. */
174 resolution: Schema.optional(UsageResolution),
175 /** Inclusive UTC instant for an hourly rolling window. */
176 sinceTime: Schema.optional(TrimmedNonEmptyString),
177 /** Exclusive UTC instant for an hourly rolling window. */
178 untilTime: Schema.optional(TrimmedNonEmptyString),
168});179});
169export type UsageSummaryInput = typeof UsageSummaryInput.Type;180export type UsageSummaryInput = typeof UsageSummaryInput.Type;
170181
packages/shared/src/usageFormat.test.ts created+57
...@@ -0,0 +1,57 @@
1// @effect-diagnostics globalDate:off -- A fixed instant keeps calendar-window assertions deterministic.
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 enumerateHourStarts,
6 formatDateTimeShort,
7 formatHourShort,
8 formatRelativeHourShort,
9 makeWindow,
10} from "./usageFormat.ts";
11
12describe("hourly usage formatting", () => {
13 it("enumerates 24 fixed buckets across a rolling window", () => {
14 const hours = enumerateHourStarts("2026-08-10T12:37:00.000Z", "2026-08-11T12:37:00.000Z");
15
16 expect(hours).toHaveLength(24);
17 expect(hours[0]).toBe("2026-08-10T12:37:00.000Z");
18 expect(hours[23]).toBe("2026-08-11T11:37:00.000Z");
19 });
20
21 it("formats rolling instants in the requested time zone", () => {
22 expect(formatHourShort("2026-08-11T00:37:00.000Z", "UTC")).toBe("12 AM");
23 expect(formatHourShort("2026-08-11T12:37:00.000Z", "UTC")).toBe("12 PM");
24 expect(formatDateTimeShort("2026-08-11T17:37:00.000Z", "UTC")).toBe("Aug 11, 5 PM");
25 });
26
27 it("disambiguates repeated hours during a fall-back transition", () => {
28 expect(formatHourShort("2026-11-01T05:37:00.000Z", "America/New_York")).toBe("1 AM EDT");
29 expect(formatHourShort("2026-11-01T06:37:00.000Z", "America/New_York")).toBe("1 AM EST");
30 });
31
32 it("makes hourly tooltip dates relative to the window in its requested time zone", () => {
33 const windowEnd = "2026-08-11T14:37:00.000Z";
34
35 expect(formatRelativeHourShort("2026-08-10T17:37:00.000Z", windowEnd, "UTC")).toBe(
36 "5 PM yesterday",
37 );
38 expect(formatRelativeHourShort("2026-08-11T14:37:00.000Z", windowEnd, "UTC")).toBe(
39 "2 PM today",
40 );
41 expect(
42 formatRelativeHourShort(
43 "2026-08-11T01:37:00.000Z",
44 "2026-08-11T10:37:00.000Z",
45 "America/Los_Angeles",
46 ),
47 ).toBe("6 PM yesterday");
48 });
49
50 it("builds an exact minute-aligned 24-hour request", () => {
51 const window = makeWindow(1, new Date("2026-08-11T12:37:42.123Z"), "hour");
52
53 expect(window.resolution).toBe("hour");
54 expect(window.sinceTime).toBe("2026-08-10T12:37:00.000Z");
55 expect(window.untilTime).toBe("2026-08-11T12:37:00.000Z");
56 });
57});
packages/shared/src/usageFormat.ts+116-4
...@@ -4,7 +4,7 @@...@@ -4,7 +4,7 @@
4 *4 *
5 * @module usageFormat5 * @module usageFormat
6 */6 */
7import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts";7import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools/contracts";
88
9const CURRENCY = new Intl.NumberFormat("en-US", {9const CURRENCY = new Intl.NumberFormat("en-US", {
10 style: "currency",10 style: "currency",
...@@ -80,11 +80,105 @@ export function enumerateDays(sinceDay: string, untilDay: string): readonly stri...@@ -80,11 +80,105 @@ export function enumerateDays(sinceDay: string, untilDay: string): readonly stri
80 return days;80 return days;
81}81}
8282
83const HOUR_MS = 60 * 60 * 1000;
84
85/** Every fixed-duration bucket start in an hourly rolling window. */
86export function enumerateHourStarts(sinceTime: string, untilTime: string): readonly string[] {
87 const starts: string[] = [];
88 const start = Date.parse(sinceTime);
89 const end = Date.parse(untilTime);
90 if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return starts;
91
92 for (let cursor = start; cursor < end; cursor += HOUR_MS) {
93 starts.push(new Date(cursor).toISOString());
94 }
95 return starts;
96}
97
98/**
99 * A rolling bucket start rendered in the viewer's requested time zone.
100 *
101 * Repeated wall-clock hours during a fall-back transition include their short
102 * zone name so the two distinct buckets remain distinguishable.
103 */
104export function formatHourShort(hourStart: string, timeZone?: string): string {
105 const instant = new Date(hourStart);
106 if (Number.isNaN(instant.getTime())) return hourStart;
107 const options = timeZone === undefined ? {} : { timeZone };
108 const hourFormat = new Intl.DateTimeFormat("en-US", {
109 ...options,
110 hour: "numeric",
111 });
112 const wallHourFormat = new Intl.DateTimeFormat("en-CA", {
113 ...options,
114 year: "numeric",
115 month: "2-digit",
116 day: "2-digit",
117 hour: "2-digit",
118 hourCycle: "h23",
119 });
120 const wallHour = wallHourFormat.format(instant);
121 const isRepeatedHour = [-HOUR_MS, HOUR_MS].some(
122 (offset) => wallHourFormat.format(new Date(instant.getTime() + offset)) === wallHour,
123 );
124
125 if (!isRepeatedHour) return hourFormat.format(instant);
126 return new Intl.DateTimeFormat("en-US", {
127 ...(timeZone === undefined ? {} : { timeZone }),
128 hour: "numeric",
129 timeZoneName: "short",
130 }).format(instant);
131}
132
133/** `2026-08-11T14:37:00Z` to `Aug 11, 2 PM` in the requested zone. */
134export function formatDateTimeShort(instant: string, timeZone?: string): string {
135 const date = new Date(instant);
136 if (Number.isNaN(date.getTime())) return instant;
137 return new Intl.DateTimeFormat("en-US", {
138 ...(timeZone === undefined ? {} : { timeZone }),
139 month: "short",
140 day: "numeric",
141 hour: "numeric",
142 }).format(date);
143}
144
145/** An hourly tooltip label relative to the rolling window's end date. */
146export function formatRelativeHourShort(
147 hourStart: string,
148 relativeTo: string,
149 timeZone?: string,
150): string {
151 const instant = new Date(hourStart);
152 const reference = new Date(relativeTo);
153 if (Number.isNaN(instant.getTime()) || Number.isNaN(reference.getTime())) {
154 return formatDateTimeShort(hourStart, timeZone);
155 }
156
157 const dayFormat = new Intl.DateTimeFormat("en-CA", {
158 ...(timeZone === undefined ? {} : { timeZone }),
159 year: "numeric",
160 month: "2-digit",
161 day: "2-digit",
162 });
163 const instantDay = Date.parse(`${dayFormat.format(instant)}T00:00:00Z`);
164 const referenceDay = Date.parse(`${dayFormat.format(reference)}T00:00:00Z`);
165 const calendarDaysAgo = Math.round((referenceDay - instantDay) / (24 * HOUR_MS));
166 const hour = formatHourShort(hourStart, timeZone);
167
168 if (calendarDaysAgo === 0) return `${hour} today`;
169 if (calendarDaysAgo === 1) return `${hour} yesterday`;
170 return formatDateTimeShort(hourStart, timeZone);
171}
172
83/**173/**
84 * The window the page requests, expressed in the viewer's own time zone so days174 * The window the page requests, expressed in the viewer's own time zone so days
85 * line up with what they actually experienced.175 * line up with what they actually experienced.
86 */176 */
87export function makeWindow(days: number, now = new Date()): UsageSummaryInput {177export function makeWindow(
178 days: number,
179 now = new Date(),
180 resolution: UsageResolution = "day",
181): UsageSummaryInput {
88 const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";182 const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
89 const format = new Intl.DateTimeFormat("en-CA", {183 const format = new Intl.DateTimeFormat("en-CA", {
90 timeZone,184 timeZone,
...@@ -93,9 +187,26 @@ export function makeWindow(days: number, now = new Date()): UsageSummaryInput {...@@ -93,9 +187,26 @@ export function makeWindow(days: number, now = new Date()): UsageSummaryInput {
93 day: "2-digit",187 day: "2-digit",
94 });188 });
95 const untilDay = format.format(now);189 const untilDay = format.format(now);
190 if (resolution === "hour") {
191 // Minute-aligned bounds keep labels readable while still representing an
192 // exact rolling 24-hour duration. Fixed-duration buckets remain correct
193 // across offset changes and daylight-saving transitions.
194 const untilTimeMs = Math.floor(now.getTime() / 60_000) * 60_000;
195 const sinceTimeMs = untilTimeMs - 24 * HOUR_MS;
196 const sinceTime = new Date(sinceTimeMs);
197 const untilTime = new Date(untilTimeMs);
198 return {
199 sinceDay: UsageDay.make(format.format(sinceTime)),
200 untilDay: UsageDay.make(format.format(untilTime)),
201 timeZone,
202 resolution,
203 sinceTime: sinceTime.toISOString(),
204 untilTime: untilTime.toISOString(),
205 };
206 }
96 // Subtracting fixed milliseconds from `now` lands on the wrong calendar day207 // Subtracting fixed milliseconds from `now` lands on the wrong calendar day
97 // around a DST transition. Only "today" needs the zone; the window start is208 // around a DST transition. The window start is pure calendar arithmetic on
98 // pure calendar arithmetic on that day, done in UTC where days are uniform.209 // the local end day, done in UTC where days are uniform.
99 const [year = 0, month = 1, dayOfMonth = 1] = untilDay210 const [year = 0, month = 1, dayOfMonth = 1] = untilDay
100 .split("-")211 .split("-")
101 .map((part) => Number.parseInt(part, 10));212 .map((part) => Number.parseInt(part, 10));
...@@ -104,5 +215,6 @@ export function makeWindow(days: number, now = new Date()): UsageSummaryInput {...@@ -104,5 +215,6 @@ export function makeWindow(days: number, now = new Date()): UsageSummaryInput {
104 sinceDay: UsageDay.make(start.toISOString().slice(0, 10)),215 sinceDay: UsageDay.make(start.toISOString().slice(0, 10)),
105 untilDay: UsageDay.make(untilDay),216 untilDay: UsageDay.make(untilDay),
106 timeZone,217 timeZone,
218 resolution,
107 };219 };
108}220}
packages/shared/src/usageMerge.test.ts+26
...@@ -254,5 +254,31 @@ describe("mergeUsage", () => {...@@ -254,5 +254,31 @@ describe("mergeUsage", () => {
254 const merged = mergeUsage([], USAGE_CONTRACT_VERSION);254 const merged = mergeUsage([], USAGE_CONTRACT_VERSION);
255 expect(merged.costUsd).toBe(0);255 expect(merged.costUsd).toBe(0);
256 expect(merged.daily).toHaveLength(0);256 expect(merged.daily).toHaveLength(0);
257 expect(merged.hourly).toHaveLength(0);
258 });
259
260 it("derives hourly totals without losing the daily rollup", () => {
261 const merged = mergeUsage(
262 [
263 environment(
264 "env-a",
265 summary(
266 [
267 bucket({ hourStart: "2026-08-07T09:37:00.000Z", costUsd: 3 }),
268 bucket({ hourStart: "2026-08-07T10:37:00.000Z", costUsd: 7 }),
269 ],
270 [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }],
271 ),
272 ),
273 ],
274 USAGE_CONTRACT_VERSION,
275 );
276
277 expect(merged.hourly.map((hour) => [hour.hourStart, hour.costUsd])).toEqual([
278 ["2026-08-07T09:37:00.000Z", 3],
279 ["2026-08-07T10:37:00.000Z", 7],
280 ]);
281 expect(merged.daily).toHaveLength(1);
282 expect(merged.daily[0]?.costUsd).toBe(10);
257 });283 });
258});284});
packages/shared/src/usageMerge.ts+45
...@@ -45,6 +45,14 @@ export interface DailyTotals {...@@ -45,6 +45,14 @@ export interface DailyTotals {
45 readonly byProvider: ReadonlyMap<UsageProviderKind, { costUsd: number; totalTokens: number }>;45 readonly byProvider: ReadonlyMap<UsageProviderKind, { costUsd: number; totalTokens: number }>;
46}46}
4747
48export interface HourlyTotals {
49 readonly day: string;
50 readonly hourStart: string;
51 readonly costUsd: number;
52 readonly totalTokens: number;
53 readonly byProvider: ReadonlyMap<UsageProviderKind, { costUsd: number; totalTokens: number }>;
54}
55
48export interface CostQuality {56export interface CostQuality {
49 readonly providerReportedShare: number;57 readonly providerReportedShare: number;
50 readonly modelPricedShare: number;58 readonly modelPricedShare: number;
...@@ -65,6 +73,7 @@ export interface MergedUsage {...@@ -65,6 +73,7 @@ export interface MergedUsage {
65 readonly providers: readonly ProviderTotals[];73 readonly providers: readonly ProviderTotals[];
66 readonly models: readonly ModelTotals[];74 readonly models: readonly ModelTotals[];
67 readonly daily: readonly DailyTotals[];75 readonly daily: readonly DailyTotals[];
76 readonly hourly: readonly HourlyTotals[];
68 readonly costQuality: CostQuality;77 readonly costQuality: CostQuality;
69 /** Environments whose data was dropped as a duplicate of another's. */78 /** Environments whose data was dropped as a duplicate of another's. */
70 readonly duplicateSources: readonly string[];79 readonly duplicateSources: readonly string[];
...@@ -168,6 +177,7 @@ const EMPTY_MERGED: MergedUsage = {...@@ -168,6 +177,7 @@ const EMPTY_MERGED: MergedUsage = {
168 providers: [],177 providers: [],
169 models: [],178 models: [],
170 daily: [],179 daily: [],
180 hourly: [],
171 costQuality: {181 costQuality: {
172 providerReportedShare: 0,182 providerReportedShare: 0,
173 modelPricedShare: 0,183 modelPricedShare: 0,
...@@ -232,6 +242,16 @@ export function mergeUsage(...@@ -232,6 +242,16 @@ export function mergeUsage(
232 byProvider: Map<UsageProviderKind, { costUsd: number; totalTokens: number }>;242 byProvider: Map<UsageProviderKind, { costUsd: number; totalTokens: number }>;
233 }243 }
234 >();244 >();
245 const hourlyAccumulator = new Map<
246 string,
247 {
248 day: string;
249 hourStart: string;
250 costUsd: number;
251 totalTokens: number;
252 byProvider: Map<UsageProviderKind, { costUsd: number; totalTokens: number }>;
253 }
254 >();
235 const contributingEnvironments: EnvironmentId[] = [];255 const contributingEnvironments: EnvironmentId[] = [];
236256
237 for (const environment of current) {257 for (const environment of current) {
...@@ -290,6 +310,26 @@ export function mergeUsage(...@@ -290,6 +310,26 @@ export function mergeUsage(
290 dayProvider.totalTokens += tokens;310 dayProvider.totalTokens += tokens;
291 day.byProvider.set(bucket.provider, dayProvider);311 day.byProvider.set(bucket.provider, dayProvider);
292 dailyAccumulator.set(bucket.day, day);312 dailyAccumulator.set(bucket.day, day);
313
314 if (bucket.hourStart !== undefined) {
315 const hour = hourlyAccumulator.get(bucket.hourStart) ?? {
316 day: bucket.day,
317 hourStart: bucket.hourStart,
318 costUsd: 0,
319 totalTokens: 0,
320 byProvider: new Map<UsageProviderKind, { costUsd: number; totalTokens: number }>(),
321 };
322 hour.costUsd += bucket.costUsd;
323 hour.totalTokens += tokens;
324 const hourProvider = hour.byProvider.get(bucket.provider) ?? {
325 costUsd: 0,
326 totalTokens: 0,
327 };
328 hourProvider.costUsd += bucket.costUsd;
329 hourProvider.totalTokens += tokens;
330 hour.byProvider.set(bucket.provider, hourProvider);
331 hourlyAccumulator.set(bucket.hourStart, hour);
332 }
293 }333 }
294 }334 }
295335
...@@ -326,6 +366,10 @@ export function mergeUsage(...@@ -326,6 +366,10 @@ export function mergeUsage(
326 }))366 }))
327 .sort((a, b) => a.day.localeCompare(b.day));367 .sort((a, b) => a.day.localeCompare(b.day));
328368
369 const hourly: HourlyTotals[] = [...hourlyAccumulator.values()].sort((a, b) =>
370 a.hourStart.localeCompare(b.hourStart),
371 );
372
329 return {373 return {
330 costUsd,374 costUsd,
331 uncachedInputTokens,375 uncachedInputTokens,
...@@ -339,6 +383,7 @@ export function mergeUsage(...@@ -339,6 +383,7 @@ export function mergeUsage(
339 providers,383 providers,
340 models,384 models,
341 daily,385 daily,
386 hourly,
342 costQuality: {387 costQuality: {
343 providerReportedShare: records === 0 ? 0 : providerReportedRecords / records,388 providerReportedShare: records === 0 ? 0 : providerReportedRecords / records,
344 unpricedShare: records === 0 ? 0 : unpricedRecords / records,389 unpricedShare: records === 0 ? 0 : unpricedRecords / records,