Skip to content

Commit c842c6f

Browse files
Add hourly past-24-hour usage view (#6170)
1 parent 6676f9c commit c842c6f

16 files changed

Lines changed: 668 additions & 95 deletions

File tree

‎apps/mobile/src/features/usage/UsageRouteScreen.tsx‎

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { useNavigation } from "@react-navigation/native";
2-
import type { MergedUsage } from "@t3tools/shared/usageMerge";
2+
import type { DailyTotals, MergedUsage } from "@t3tools/shared/usageMerge";
33
import {
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";
2325
import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";
2426

2527
const 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;
3336
export 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: {
142187
function 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

295-
function TotalsSection(props: { readonly merged: MergedUsage }) {
351+
function 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‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
6666
const MTIME_SLACK_MS = 36 * 60 * 60 * 1000;
67+
const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000;
6768

6869
/** Longest window the UI offers, plus slack. Older entries are pruned. */
6970
const 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‎

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,44 @@ function record(overrides: Partial<UsageRecord> = {}): UsageRecord {
3636
};
3737
}
3838

39-
function aggregate(records: readonly UsageRecord[], timeZone = "UTC") {
39+
function 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);
4760
return aggregator.finish();
4861
}
4962

5063
describe("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

1.34 KB
Binary file not shown.

0 commit comments

Comments
 (0)