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 @@
11import { useNavigation } from "@react-navigation/native";
2import type { MergedUsage } from "@t3tools/shared/usageMerge";
2import type { DailyTotals, MergedUsage } from "@t3tools/shared/usageMerge";
33import {
44 enumerateDays,
5 enumerateHourStarts,
56 formatCount,
67 formatDayShort,
8 formatHourShort,
79 formatPercent,
810 formatTokens,
911 formatUsd,
......@@ -23,6 +25,7 @@ import type { UsageChartMetric } from "./usageChartData";
2325import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";
2426
2527const WINDOW_OPTIONS = [
28 { days: 1, label: "Past 24h" },
2629 { days: 7, label: "7 days" },
2730 { days: 30, label: "30 days" },
2831 { days: 90, label: "90 days" },
......@@ -33,23 +36,62 @@ const CHART_HEIGHT = 180;
3336export function UsageRouteScreen() {
3437 const navigation = useNavigation();
3538 const insets = useSafeAreaInsets();
36 const [windowDays, setWindowDays] = useState<number>(30);
39 const [windowSelection, setWindowSelection] = useState(() => ({
40 days: 30,
41 window: makeWindow(30),
42 }));
3743 const [metric, setMetric] = useState<UsageChartMetric>("cost");
38
39 // Recomputed only when the window length changes, so a re-render does not
40 // shift the range and refetch every environment.
41 const window = useMemo(() => makeWindow(windowDays), [windowDays]);
44 const { days: windowDays, window } = windowSelection;
45 const isPast24Hours = windowDays === 1;
4246 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);
4347
4448 const days = useMemo(
4549 () => enumerateDays(window.sinceDay, window.untilDay),
4650 [window.sinceDay, window.untilDay],
4751 );
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
4972 // The pull spinner tracks re-scans of environments that have answered
5073 // before. The initial scan renders its own placeholder, and an unreachable
5174 // environment stays pending forever — neither may pin the spinner on.
5275 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
5496 return (
5597 <View collapsable={false} className="flex-1 bg-sheet">
......@@ -65,12 +107,12 @@ export function UsageRouteScreen() {
65107 className="flex-1"
66108 contentContainerClassName="gap-6 px-5 pt-4"
67109 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
68 refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} />}
110 refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refreshWindow} />}
69111 >
70112 <SegmentedControl
71113 options={WINDOW_OPTIONS.map((option) => ({ value: option.days, label: option.label }))}
72114 selected={windowDays}
73 onSelect={setWindowDays}
115 onSelect={selectWindow}
74116 />
75117
76118 <UsageCoverageNotice environments={environments} merged={merged} isPartial={isPartial} />
......@@ -87,14 +129,17 @@ export function UsageRouteScreen() {
87129 <>
88130 <ChartCard
89131 merged={merged}
90 days={days}
132 days={chartDays}
133 daily={chartTotals}
91134 metric={metric}
92135 onMetricChange={setMetric}
93136 sinceDay={window.sinceDay}
94137 untilDay={window.untilDay}
138 isPast24Hours={isPast24Hours}
139 timeZone={window.timeZone}
95140 />
96141 <ProviderSection merged={merged} metric={metric} />
97 <TotalsSection merged={merged} />
142 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
98143 <ModelsSection merged={merged} />
99144 </>
100145 )}
......@@ -142,14 +187,17 @@ function SegmentedControl<Value extends number | string>(props: {
142187function ChartCard(props: {
143188 readonly merged: MergedUsage;
144189 readonly days: readonly string[];
190 readonly daily: readonly DailyTotals[];
145191 readonly metric: UsageChartMetric;
146192 readonly onMetricChange: (metric: UsageChartMetric) => void;
147193 readonly sinceDay: string;
148194 readonly untilDay: string;
195 readonly isPast24Hours: boolean;
196 readonly timeZone: string;
149197}) {
150198 const { merged, metric } = props;
151199 const colors = useProviderColors();
152 const hasActivity = merged.daily.some((day) => day.totalTokens > 0);
200 const hasActivity = props.daily.some((period) => period.totalTokens > 0);
153201
154202 return (
155203 <View className="gap-4 rounded-[24px] border-continuous bg-card p-4">
......@@ -173,7 +221,7 @@ function ChartCard(props: {
173221 {hasActivity ? (
174222 <UsageDailyChart
175223 days={props.days}
176 daily={merged.daily}
224 daily={props.daily}
177225 metric={metric}
178226 height={CHART_HEIGHT}
179227 />
......@@ -184,7 +232,11 @@ function ChartCard(props: {
184232 )}
185233
186234 <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>
188240 <View className="flex-row items-center gap-4">
189241 {merged.providers.map((provider) => (
190242 <View key={provider.provider} className="flex-row items-center gap-1.5">
......@@ -198,7 +250,11 @@ function ChartCard(props: {
198250 </View>
199251 ))}
200252 </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>
202258 </View>
203259 </View>
204260 );
......@@ -292,10 +348,12 @@ function ProviderSection(props: {
292348 );
293349}
294350
295function TotalsSection(props: { readonly merged: MergedUsage }) {
351function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24Hours: boolean }) {
296352 const { merged } = props;
297 const activeDays = merged.daily.filter((day) => day.totalTokens > 0).length;
298 const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays;
353 const activePeriods = (props.isPast24Hours ? merged.hourly : merged.daily).filter(
354 (period) => period.totalTokens > 0,
355 ).length;
356 const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods;
299357 const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens;
300358 const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput;
301359
......@@ -305,7 +363,7 @@ function TotalsSection(props: { readonly merged: MergedUsage }) {
305363 <MetricCell
306364 label="Processed tokens"
307365 value={formatTokens(merged.totalTokens)}
308 detail={`${formatTokens(dailyAverage)} per active day`}
366 detail={`${formatTokens(periodAverage)} per active ${props.isPast24Hours ? "hour" : "day"}`}
309367 />
310368 <MetricCell
311369 label="Cache savings"
apps/mobile/src/state/usage.ts+11-1
......@@ -81,8 +81,18 @@ export function useUsage(input: UsageSummaryInput): UsageView {
8181 sinceDay: input.sinceDay,
8282 untilDay: input.untilDay,
8383 timeZone: input.timeZone,
84 resolution: input.resolution,
85 sinceTime: input.sinceTime,
86 untilTime: input.untilTime,
8487 }),
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 ],
8696 );
8797 const atom = usageByWindowAtom(windowKey);
8898 const environments = useAtomValue(atom);
apps/server/src/usage/UsageService.ts+30-2
......@@ -1,5 +1,5 @@
11/**
2 * UsageService - scans provider transcripts and returns priced daily usage.
2 * UsageService - scans provider transcripts and returns priced usage buckets.
33 *
44 * The scan reads the provider CLIs' own session files rather than T3 Code's
55 * orchestration projections, so usage covers turns driven outside T3 Code too.
......@@ -64,6 +64,7 @@ const RATES_TTL_MS = 24 * 60 * 60 * 1000;
6464 * last write lands just before local midnight on the window's first day.
6565 */
6666const MTIME_SLACK_MS = 36 * 60 * 60 * 1000;
67const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000;
6768
6869/** Longest window the UI offers, plus slack. Older entries are pruned. */
6970const CACHE_RETENTION_DAYS = 90;
......@@ -297,6 +298,30 @@ export const make = Effect.gen(function* () {
297298 });
298299 }
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
300325 const startedAtMs = yield* Clock.currentTimeMillis;
301326 yield* ensureRates();
302327 yield* ensureScanCacheLoaded;
......@@ -312,12 +337,15 @@ export const make = Effect.gen(function* () {
312337 detail: `sinceDay '${input.sinceDay}' is not a valid date`,
313338 });
314339 }
315 const windowStartMs = DateTime.toEpochMillis(windowStart.value) - MTIME_SLACK_MS;
340 const windowStartMs =
341 (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS;
316342
317343 const aggregator = new UsageAggregator({
318344 timeZone: input.timeZone,
319345 sinceDay: input.sinceDay,
320346 untilDay: input.untilDay,
347 resolution: input.resolution ?? "day",
348 ...hourlyWindow,
321349 rates,
322350 });
323351
apps/server/src/usage/usageAggregation.test.ts+73-1
......@@ -36,11 +36,24 @@ function record(overrides: Partial<UsageRecord> = {}): UsageRecord {
3636 };
3737}
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 : {};
4051 const aggregator = new UsageAggregator({
4152 timeZone,
4253 sinceDay: "2026-08-01",
4354 untilDay: "2026-08-31",
55 resolution,
56 ...hourlyBounds,
4457 rates,
4558 });
4659 for (const item of records) aggregator.add(item);
......@@ -48,6 +61,19 @@ function aggregate(records: readonly UsageRecord[], timeZone = "UTC") {
4861}
4962
5063describe("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
5177 it("keeps only the first record for a repeated dedupe key", () => {
5278 const result = aggregate([
5379 record({ dedupeKey: "msg_1:" }),
......@@ -76,6 +102,52 @@ describe("UsageAggregator", () => {
76102 expect(losAngeles.buckets[0]?.day).toBe("2026-08-06");
77103 });
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
79151 it("prices against the rate table", () => {
80152 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";
22import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react";
33import { useMemo, useState } from "react";
44
5import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";
6
57import { isElectron } from "../../env";
68import { cn } from "../../lib/utils";
79import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
810import {
911 enumerateDays,
12 enumerateHourStarts,
1013 formatCount,
14 formatDateTimeShort,
1115 formatDayShort,
16 formatHourShort,
1217 formatPercent,
1318 formatTokens,
1419 formatUsd,
......@@ -22,19 +27,21 @@ import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./U
2227import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";
2328
2429const WINDOW_OPTIONS = [
30 { days: 1, label: "Past 24h" },
2531 { days: 7, label: "7 days" },
2632 { days: 30, label: "30 days" },
2733 { days: 90, label: "90 days" },
2834] as const;
2935
3036export function UsagePage() {
31 const [windowDays, setWindowDays] = useState<number>(30);
37 const [windowSelection, setWindowSelection] = useState(() => ({
38 days: 30,
39 window: makeWindow(30),
40 }));
3241 const [metric, setMetric] = useState<UsageChartMetric>("cost");
33 const [breakdown, setBreakdown] = useState<"model" | "day">("model");
34
35 // Recomputed only when the window length changes, so a re-render does not
36 // shift the range and refetch every environment.
37 const window = useMemo(() => makeWindow(windowDays), [windowDays]);
42 const [breakdown, setBreakdown] = useState<"model" | "time">("model");
43 const { days: windowDays, window } = windowSelection;
44 const isPast24Hours = windowDays === 1;
3845 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);
3946
4047 // Hold the content until every environment is terminal. Rendering merged
......@@ -46,7 +53,17 @@ export function UsagePage() {
4653 () => enumerateDays(window.sinceDay, window.untilDay),
4754 [window.sinceDay, window.untilDay],
4855 );
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
5168 // Ranked by whatever the toggle is showing, so the bars always descend.
5269 const orderedProviders = useMemo(
......@@ -57,10 +74,31 @@ export function UsagePage() {
5774 [merged.providers, metric],
5875 );
5976
60 const activeDays = merged.daily.filter((day) => day.totalTokens > 0).length;
61 const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays;
77 const activePeriods = (isPast24Hours ? merged.hourly : merged.daily).filter(
78 (period) => period.totalTokens > 0,
79 ).length;
80 const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods;
6281 const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens;
6382 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
65103 return (
66104 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
......@@ -95,17 +133,20 @@ export function UsagePage() {
95133 <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-6">
96134 <div className="flex flex-wrap items-center justify-between gap-4">
97135 <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)}`}
99139 </p>
100140 <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">
102142 {WINDOW_OPTIONS.map((option) => (
103143 <button
104144 key={option.days}
105145 type="button"
106 onClick={() => setWindowDays(option.days)}
146 aria-pressed={option.days === windowDays}
147 onClick={() => selectWindow(option.days)}
107148 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",
109150 option.days === windowDays
110151 ? "bg-muted text-foreground"
111152 : "text-muted-foreground hover:text-foreground",
......@@ -117,7 +158,7 @@ export function UsagePage() {
117158 </div>
118159 <button
119160 type="button"
120 onClick={refresh}
161 onClick={refreshWindow}
121162 aria-label="Refresh usage"
122163 className="cursor-pointer rounded-md border border-border p-2 text-muted-foreground hover:text-foreground"
123164 >
......@@ -129,7 +170,7 @@ export function UsagePage() {
129170 {settling ? (
130171 <>
131172 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}
132 <UsageSkeleton />
173 <UsageSkeleton resolution={isPast24Hours ? "hour" : "day"} />
133174 </>
134175 ) : (
135176 <>
......@@ -197,7 +238,8 @@ export function UsagePage() {
197238 <div className="flex flex-col gap-3">
198239 <div className="flex flex-wrap items-center justify-between gap-3">
199240 <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"}
201243 </h2>
202244 <div className="flex items-center gap-4">
203245 <div className="flex overflow-hidden rounded-md border border-border">
......@@ -220,7 +262,16 @@ export function UsagePage() {
220262 <UsageChartLegend />
221263 </div>
222264 </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 />
224275 </div>
225276 </section>
226277
......@@ -228,7 +279,7 @@ export function UsagePage() {
228279 <Metric
229280 label="Processed tokens"
230281 value={formatTokens(merged.totalTokens)}
231 detail={`${formatTokens(dailyAverage)} per active day`}
282 detail={`${formatTokens(periodAverage)} per active ${isPast24Hours ? "hour" : "day"}`}
232283 />
233284 <Metric
234285 label="Cached input"
......@@ -260,19 +311,24 @@ export function UsagePage() {
260311 <div className="flex items-center justify-between gap-3">
261312 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>
262313 <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) => (
264320 <button
265 key={option}
321 key={option.value}
266322 type="button"
267 onClick={() => setBreakdown(option)}
323 onClick={() => setBreakdown(option.value)}
268324 className={cn(
269325 "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase",
270 option === breakdown
326 option.value === breakdown
271327 ? "bg-muted text-foreground"
272328 : "text-muted-foreground hover:text-foreground",
273329 )}
274330 >
275 {option}
331 {option.label}
276332 </button>
277333 ))}
278334 </div>
......@@ -325,7 +381,7 @@ export function UsagePage() {
325381 <table className="w-full text-sm">
326382 <thead>
327383 <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>
329385 {PROVIDER_ORDER.map((provider) => (
330386 <th key={provider} className="py-2 text-right font-normal">
331387 {PROVIDER_LABEL[provider]}
......@@ -336,29 +392,36 @@ export function UsagePage() {
336392 </tr>
337393 </thead>
338394 <tbody>
339 {recentDays.length === 0 ? (
395 {recentPeriods.length === 0 ? (
340396 <tr>
341397 <td colSpan={5} className="py-6 text-center text-muted-foreground">
342398 No activity in this window.
343399 </td>
344400 </tr>
345401 ) : (
346 recentDays.map((day) => (
347 <tr key={day.day} className="border-b border-border/50">
348 <td className="py-2 text-foreground">{formatDayShort(day.day)}</td>
402 recentPeriods.map((period) => (
403 <tr
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>
349412 {PROVIDER_ORDER.map((provider) => (
350413 <td
351414 key={provider}
352415 className="py-2 text-right text-muted-foreground tabular-nums"
353416 >
354 {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)}
417 {formatUsd(period.byProvider.get(provider)?.costUsd ?? 0)}
355418 </td>
356419 ))}
357420 <td className="py-2 text-right text-foreground tabular-nums">
358 {formatUsd(day.costUsd)}
421 {formatUsd(period.costUsd)}
359422 </td>
360423 <td className="py-2 text-right text-muted-foreground tabular-nums">
361 {formatTokens(day.totalTokens)}
424 {formatTokens(period.totalTokens)}
362425 </td>
363426 </tr>
364427 ))
......@@ -513,7 +576,7 @@ const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44
513576 * chart and metrics strip. No shimmer; blocks fill in exactly once when the
514577 * last device answers.
515578 */
516function UsageSkeleton() {
579function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) {
517580 return (
518581 <>
519582 <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
......@@ -542,7 +605,9 @@ function UsageSkeleton() {
542605 </div>
543606
544607 <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>
546611 {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a
547612 relayout when the real chart swaps in. */}
548613 <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", () => {
9595 }
9696 });
9797});
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 @@
11import type { UsageProviderKind } from "@t3tools/contracts";
22import { useCallback, useMemo, useRef, useState } from "react";
33
4import type { DailyTotals } from "@t3tools/shared/usageMerge";
5import { formatDayShort, formatTokens, formatUsd } from "@t3tools/shared/usageFormat";
4import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";
5import {
6 formatDayShort,
7 formatHourShort,
8 formatRelativeHourShort,
9 formatTokens,
10 formatUsd,
11} from "@t3tools/shared/usageFormat";
612import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";
713
814const VIEW_WIDTH = 960;
......@@ -15,7 +21,12 @@ export type UsageChartMetric = "tokens" | "cost";
1521interface UsageProviderChartProps {
1622 readonly days: readonly string[];
1723 readonly daily: readonly DailyTotals[];
24 readonly hours: readonly string[];
25 readonly hourly: readonly HourlyTotals[];
1826 readonly metric: UsageChartMetric;
27 readonly referenceTime: string | undefined;
28 readonly resolution: "day" | "hour";
29 readonly timeZone: string;
1930}
2031
2132/** One day's per-provider values, shared by the paths and the hover readout. */
......@@ -33,15 +44,30 @@ interface Point {
3344}
3445
3546function valueFor(
36 daily: DailyTotals | undefined,
47 totals: DailyTotals | HourlyTotals | undefined,
3748 provider: UsageProviderKind,
3849 metric: UsageChartMetric,
3950): number {
40 const entry = daily?.byProvider.get(provider);
51 const entry = totals?.byProvider.get(provider);
4152 if (entry === undefined) return 0;
4253 return metric === "tokens" ? entry.totalTokens : entry.costUsd;
4354}
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
4571/**
4672 * Monotone cubic tangents (Fritsch-Carlson).
4773 *
......@@ -167,23 +193,32 @@ export function buildDayColumns(
167193 byDay: ReadonlyMap<string, DailyTotals>,
168194 metric: UsageChartMetric,
169195): readonly DayColumn[] {
170 return days.map((day) => {
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 });
196 return buildPeriodColumns(days, byDay, metric);
178197}
179198
180export function UsageProviderChart({ days, daily, metric }: UsageProviderChartProps) {
181 const byDay = useMemo(() => new Map(daily.map((entry) => [entry.day, entry])), [daily]);
199export function UsageProviderChart({
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 );
182217 const [hoverIndex, setHoverIndex] = useState<number | null>(null);
183218 const plotRef = useRef<HTMLDivElement | null>(null);
184219
185220 const { paths, ticks, stepX, toY, series } = useMemo(() => {
186 if (days.length === 0) {
221 if (periods.length === 0) {
187222 return {
188223 paths: [],
189224 ticks: [0] as readonly number[],
......@@ -193,7 +228,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
193228 };
194229 }
195230
196 const columns = buildDayColumns(days, byDay, metric);
231 const columns = buildPeriodColumns(periods, byPeriod, metric);
197232
198233 // The scale tops out at the largest single provider-day, not the largest
199234 // sum: layered series each measure from zero, so a combined peak would
......@@ -203,7 +238,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
203238 0,
204239 );
205240 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);
207242 // Reserve a sliver above the top gridline so the series stroke, which is
208243 // drawn at constant screen width, is not shaved off at a peak.
209244 const toY = (value: number) =>
......@@ -231,24 +266,30 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
231266 const ordered = [...built].sort((a, b) => b.total - a.total);
232267
233268 return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns };
234 }, [byDay, days, metric]);
269 }, [byPeriod, metric, periods]);
235270
236271 const format = metric === "tokens" ? formatTokens : formatUsd;
237272
238273 const handleMove = useCallback(
239274 (event: React.MouseEvent<HTMLDivElement>) => {
240275 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;
242277 const fraction = (event.clientX - bounds.left) / bounds.width;
243 const index = Math.round(fraction * (days.length - 1));
244 setHoverIndex(Math.min(days.length - 1, Math.max(0, index)));
278 const index = Math.round(fraction * (periods.length - 1));
279 setHoverIndex(Math.min(periods.length - 1, Math.max(0, index)));
245280 },
246 [days.length],
281 [periods.length],
247282 );
248283
249 const hoveredDay = hoverIndex === null ? undefined : days[hoverIndex];
284 const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];
250285 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
253294 return (
254295 <div className="flex flex-col gap-1">
......@@ -277,7 +318,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
277318 viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
278319 preserveAspectRatio="none"
279320 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`}
281322 >
282323 {ticks.map((tick) => {
283324 const y = toY(tick);
......@@ -325,7 +366,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
325366 )}
326367 </svg>
327368
328 {hoveredDay === undefined ? null : (
369 {hoveredPeriod === undefined ? null : (
329370 <div
330371 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"
331372 style={{
......@@ -333,7 +374,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
333374 transform: hoverLeft > 60 ? "translateX(-100%)" : "translateX(0)",
334375 }}
335376 >
336 <div className="mb-1 text-muted-foreground">{formatDayShort(hoveredDay)}</div>
377 <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div>
337378 {PROVIDER_ORDER.map((provider) => {
338379 const Mark = PROVIDER_MARK[provider];
339380 return (
......@@ -362,14 +403,16 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
362403 </div>
363404
364405 <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>
366407 <span>
367 {days[Math.floor(days.length / 2)] === undefined
408 {periods[Math.floor(periods.length / 2)] === undefined
368409 ? ""
369 : formatDayShort(days[Math.floor(days.length / 2)] ?? "")}
410 : formatPeriod(periods[Math.floor(periods.length / 2)] ?? "")}
370411 </span>
371412 <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] ?? "")}
373416 </span>
374417 </div>
375418 </div>
apps/web/src/state/usage.ts+11-1
......@@ -78,8 +78,18 @@ export function useUsage(input: UsageSummaryInput): UsageView {
7878 sinceDay: input.sinceDay,
7979 untilDay: input.untilDay,
8080 timeZone: input.timeZone,
81 resolution: input.resolution,
82 sinceTime: input.sinceTime,
83 untilTime: input.untilTime,
8184 }),
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 ],
8393 );
8494 const atom = usageByWindowAtom(windowKey);
8595 const environments = useAtomValue(atom);
docs/README.md+1
......@@ -6,6 +6,7 @@
66- [Permission modes](./user/permission-modes.md)
77- [Keyboard shortcuts](./user/keybindings.md)
88- [Organizing threads](./user/thread-sidebar.md)
9- [Review usage](./user/usage.md)
910- [Customize a project icon](./user/project-settings.md)
1011- [Remote access](./user/remote-access.md)
1112- [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 @@
77 * even for turns that were never driven through T3 Code. This mirrors the
88 * approach `ccusage` takes.
99 *
10 * Environments return pre-aggregated `(day, provider, model)` buckets. Raw
11 * transcript records never cross the wire.
10 * Environments return pre-aggregated `(day, hourStart?, provider, model)`
11 * buckets. Raw transcript records never cross the wire.
1212 *
1313 * @module usage
1414 */
......@@ -21,7 +21,7 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
2121 * client renders partial coverage when an environment reports an older version
2222 * rather than failing the whole page.
2323 */
24export const USAGE_CONTRACT_VERSION = 3 as const;
24export const USAGE_CONTRACT_VERSION = 4 as const;
2525
2626export const UsageProviderKind = Schema.Literals(["claude", "codex"]);
2727export type UsageProviderKind = typeof UsageProviderKind.Type;
......@@ -39,6 +39,9 @@ export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_P
3939);
4040export type UsageDay = typeof UsageDay.Type;
4141
42export const UsageResolution = Schema.Literals(["day", "hour"]);
43export type UsageResolution = typeof UsageResolution.Type;
44
4245/**
4346 * Why a bucket's cost is what it is.
4447 *
......@@ -68,7 +71,8 @@ export const UsageTokenTotals = Schema.Struct({
6871export type UsageTokenTotals = typeof UsageTokenTotals.Type;
6972
7073/**
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.
7276 *
7377 * `costUsd` is the raw API-equivalent cost of these tokens. It is not money
7478 * spent: subscription plans bill separately. `unpricedRecords` counts records
......@@ -77,6 +81,7 @@ export type UsageTokenTotals = typeof UsageTokenTotals.Type;
7781 */
7882export const UsageBucket = Schema.Struct({
7983 day: UsageDay,
84 hourStart: Schema.optional(TrimmedNonEmptyString),
8085 provider: UsageProviderKind,
8186 model: TrimmedNonEmptyString,
8287 totals: UsageTokenTotals,
......@@ -165,6 +170,12 @@ export const UsageSummaryInput = Schema.Struct({
165170 * any window that crosses a DST boundary.
166171 */
167172 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),
168179});
169180export 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 @@
44 *
55 * @module usageFormat
66 */
7import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts";
7import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools/contracts";
88
99const CURRENCY = new Intl.NumberFormat("en-US", {
1010 style: "currency",
......@@ -80,11 +80,105 @@ export function enumerateDays(sinceDay: string, untilDay: string): readonly stri
8080 return days;
8181}
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
83173/**
84174 * The window the page requests, expressed in the viewer's own time zone so days
85175 * line up with what they actually experienced.
86176 */
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 {
88182 const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
89183 const format = new Intl.DateTimeFormat("en-CA", {
90184 timeZone,
......@@ -93,9 +187,26 @@ export function makeWindow(days: number, now = new Date()): UsageSummaryInput {
93187 day: "2-digit",
94188 });
95189 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 }
96207 // Subtracting fixed milliseconds from `now` lands on the wrong calendar day
97 // around a DST transition. Only "today" needs the zone; the window start is
98 // pure calendar arithmetic on that day, done in UTC where days are uniform.
208 // around a DST transition. The window start is pure calendar arithmetic on
209 // the local end day, done in UTC where days are uniform.
99210 const [year = 0, month = 1, dayOfMonth = 1] = untilDay
100211 .split("-")
101212 .map((part) => Number.parseInt(part, 10));
......@@ -104,5 +215,6 @@ export function makeWindow(days: number, now = new Date()): UsageSummaryInput {
104215 sinceDay: UsageDay.make(start.toISOString().slice(0, 10)),
105216 untilDay: UsageDay.make(untilDay),
106217 timeZone,
218 resolution,
107219 };
108220}
packages/shared/src/usageMerge.test.ts+26
......@@ -254,5 +254,31 @@ describe("mergeUsage", () => {
254254 const merged = mergeUsage([], USAGE_CONTRACT_VERSION);
255255 expect(merged.costUsd).toBe(0);
256256 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);
257283 });
258284});
packages/shared/src/usageMerge.ts+45
......@@ -45,6 +45,14 @@ export interface DailyTotals {
4545 readonly byProvider: ReadonlyMap<UsageProviderKind, { costUsd: number; totalTokens: number }>;
4646}
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
4856export interface CostQuality {
4957 readonly providerReportedShare: number;
5058 readonly modelPricedShare: number;
......@@ -65,6 +73,7 @@ export interface MergedUsage {
6573 readonly providers: readonly ProviderTotals[];
6674 readonly models: readonly ModelTotals[];
6775 readonly daily: readonly DailyTotals[];
76 readonly hourly: readonly HourlyTotals[];
6877 readonly costQuality: CostQuality;
6978 /** Environments whose data was dropped as a duplicate of another's. */
7079 readonly duplicateSources: readonly string[];
......@@ -168,6 +177,7 @@ const EMPTY_MERGED: MergedUsage = {
168177 providers: [],
169178 models: [],
170179 daily: [],
180 hourly: [],
171181 costQuality: {
172182 providerReportedShare: 0,
173183 modelPricedShare: 0,
......@@ -232,6 +242,16 @@ export function mergeUsage(
232242 byProvider: Map<UsageProviderKind, { costUsd: number; totalTokens: number }>;
233243 }
234244 >();
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 >();
235255 const contributingEnvironments: EnvironmentId[] = [];
236256
237257 for (const environment of current) {
......@@ -290,6 +310,26 @@ export function mergeUsage(
290310 dayProvider.totalTokens += tokens;
291311 day.byProvider.set(bucket.provider, dayProvider);
292312 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 }
293333 }
294334 }
295335
......@@ -326,6 +366,10 @@ export function mergeUsage(
326366 }))
327367 .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
329373 return {
330374 costUsd,
331375 uncachedInputTokens,
......@@ -339,6 +383,7 @@ export function mergeUsage(
339383 providers,
340384 models,
341385 daily,
386 hourly,
342387 costQuality: {
343388 providerReportedShare: records === 0 ? 0 : providerReportedRecords / records,
344389 unpricedShare: records === 0 ? 0 : unpricedRecords / records,