| author | |
| committer | |
| log | c842c6f5b90122ad88b1d2295df6a99e94954571 |
| tree | 46d66475fbc011f2be7e03c52c791e69534abf33 |
| parent | 6676f9c83b6a6dd1e2076eaa68dff48845ad955b |
| signature | Signed by PGP key B5690EEEBB952194 |
16 files changed, 668 insertions(+), 95 deletions(-)
apps/mobile/src/features/usage/UsageRouteScreen.tsx+76-18| ... | @@ -1,9 +1,11 @@ | ... | @@ -1,9 +1,11 @@ |
| 1 | import { useNavigation } from "@react-navigation/native"; | 1 | import { useNavigation } from "@react-navigation/native"; |
| 2 | import type { MergedUsage } from "@t3tools/shared/usageMerge"; | 2 | import type { DailyTotals, MergedUsage } from "@t3tools/shared/usageMerge"; |
| 3 | import { | 3 | import { |
| 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"; |
| 23 | import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; | 25 | import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; |
| 24 | 26 | ||
| 25 | const WINDOW_OPTIONS = [ | 27 | const 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; |
| 33 | export function UsageRouteScreen() { | 36 | export 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"); |
| 38 | 44 | const { days: windowDays, window } = windowSelection; | |
| 39 | // Recomputed only when the window length changes, so a re-render does not | 45 | 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); |
| 43 | 47 | ||
| 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 | ); | ||
| 48 | 71 | ||
| 49 | // The pull spinner tracks re-scans of environments that have answered | 72 | // The pull spinner tracks re-scans of environments that have answered |
| 50 | // before. The initial scan renders its own placeholder, and an unreachable | 73 | // 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 | }; | ||
| 53 | 95 | ||
| 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 | <SegmentedControl | 112 | <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 | /> |
| 75 | 117 | ||
| 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 | <ChartCard | 130 | <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: { |
| 142 | function ChartCard(props: { | 187 | function 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); |
| 153 | 201 | ||
| 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 | <UsageDailyChart | 222 | <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 | )} |
| 185 | 233 | ||
| 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 | } |
| 294 | 350 | ||
| 295 | function TotalsSection(props: { readonly merged: MergedUsage }) { | 351 | function 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; |
| 301 | 359 | ||
| ... | @@ -305,7 +363,7 @@ function TotalsSection(props: { readonly merged: MergedUsage }) { | ... | @@ -305,7 +363,7 @@ function TotalsSection(props: { readonly merged: MergedUsage }) { |
| 305 | <MetricCell | 363 | <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 | <MetricCell | 368 | <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's | 4 | * 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 | */ |
| 66 | const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; | 66 | const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; |
| 67 | const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; | ||
| 67 | 68 | ||
| 68 | /** Longest window the UI offers, plus slack. Older entries are pruned. */ | 69 | /** Longest window the UI offers, plus slack. Older entries are pruned. */ |
| 69 | const CACHE_RETENTION_DAYS = 90; | 70 | const 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 | } |
| 299 | 300 | ||
| 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; | ||
| 316 | 342 | ||
| 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 | }); |
| 323 | 351 |
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 | } |
| 38 | 38 | ||
| 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 | : {}; | ||
| 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 | } |
| 49 | 62 | ||
| 50 | describe("UsageAggregator", () => { | 63 | 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 | |||
| 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 | }); |
| 78 | 104 | ||
| 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()]); |
| 81 | 153 |
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"; |
| 2 | import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; | 2 | import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; |
| 3 | import { useMemo, useState } from "react"; | 3 | import { useMemo, useState } from "react"; |
| 4 | 4 | ||
| 5 | import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; | ||
| 6 | |||
| 5 | import { isElectron } from "../../env"; | 7 | import { isElectron } from "../../env"; |
| 6 | import { cn } from "../../lib/utils"; | 8 | import { cn } from "../../lib/utils"; |
| 7 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; | 9 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 8 | import { | 10 | import { |
| 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 |
| 22 | import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; | 27 | import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; |
| 23 | 28 | ||
| 24 | const WINDOW_OPTIONS = [ | 29 | const 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; |
| 29 | 35 | ||
| 30 | export function UsagePage() { | 36 | export 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"); |
| 34 | 43 | const { days: windowDays, window } = windowSelection; | |
| 35 | // Recomputed only when the window length changes, so a re-render does not | 44 | 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); |
| 39 | 46 | ||
| 40 | // Hold the content until every environment is terminal. Rendering merged | 47 | // 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 | ); | ||
| 50 | 67 | ||
| 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 | ); |
| 59 | 76 | ||
| 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 | }; | ||
| 64 | 102 | ||
| 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 | <button | 143 | <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 === windowDays | 150 | 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 | <button | 159 | <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> |
| 226 | 277 | ||
| ... | @@ -228,7 +279,7 @@ export function UsagePage() { | ... | @@ -228,7 +279,7 @@ export function UsagePage() { |
| 228 | <Metric | 279 | <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 | <Metric | 284 | <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 | <button | 320 | <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 === breakdown | 326 | 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 | <td | 413 | <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 the | 576 | * chart and metrics strip. No shimmer; blocks fill in exactly once when the |
| 514 | * last device answers. | 577 | * last device answers. |
| 515 | */ | 578 | */ |
| 516 | function UsageSkeleton() { | 579 | function 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> |
| 543 | 606 | ||
| 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 a | 611 | {/* 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 | |||
| 99 | describe("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 @@ |
| 1 | import type { UsageProviderKind } from "@t3tools/contracts"; | 1 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 2 | import { useCallback, useMemo, useRef, useState } from "react"; | 2 | import { useCallback, useMemo, useRef, useState } from "react"; |
| 3 | 3 | ||
| 4 | import type { DailyTotals } from "@t3tools/shared/usageMerge"; | 4 | import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; |
| 5 | import { formatDayShort, formatTokens, formatUsd } from "@t3tools/shared/usageFormat"; | 5 | import { |
| 6 | formatDayShort, | ||
| 7 | formatHourShort, | ||
| 8 | formatRelativeHourShort, | ||
| 9 | formatTokens, | ||
| 10 | formatUsd, | ||
| 11 | } from "@t3tools/shared/usageFormat"; | ||
| 6 | import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; | 12 | import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; |
| 7 | 13 | ||
| 8 | const VIEW_WIDTH = 960; | 14 | const VIEW_WIDTH = 960; |
| ... | @@ -15,7 +21,12 @@ export type UsageChartMetric = "tokens" | "cost"; | ... | @@ -15,7 +21,12 @@ export type UsageChartMetric = "tokens" | "cost"; |
| 15 | interface UsageProviderChartProps { | 21 | interface 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 | } |
| 20 | 31 | ||
| 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 | } |
| 34 | 45 | ||
| 35 | function valueFor( | 46 | function 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 | } |
| 44 | 55 | ||
| 56 | function 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 | } |
| 179 | 198 | ||
| 180 | export function UsageProviderChart({ days, daily, metric }: UsageProviderChartProps) { | 199 | export 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); |
| 184 | 219 | ||
| 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 | } |
| 195 | 230 | ||
| 196 | const columns = buildDayColumns(days, byDay, metric); | 231 | const columns = buildPeriodColumns(periods, byPeriod, metric); |
| 197 | 232 | ||
| 198 | // The scale tops out at the largest single provider-day, not the largest | 233 | // 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 would | 234 | // 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 is | 242 | // 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); |
| 232 | 267 | ||
| 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]); |
| 235 | 270 | ||
| 236 | const format = metric === "tokens" ? formatTokens : formatUsd; | 271 | const format = metric === "tokens" ? formatTokens : formatUsd; |
| 237 | 272 | ||
| 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 | ); |
| 248 | 283 | ||
| 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); | ||
| 252 | 293 | ||
| 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> |
| 327 | 368 | ||
| 328 | {hoveredDay === undefined ? null : ( | 369 | {hoveredPeriod === undefined ? null : ( |
| 329 | <div | 370 | <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> |
| 363 | 404 | ||
| 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)] === undefined | 408 | {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 | |||
| 3 | The Usage page combines Codex and Claude Code activity from your connected environments. It reads | ||
| 4 | the providers' local session history and shows API-equivalent token cost, processed tokens, cache | ||
| 5 | savings, provider shares, and model breakdowns. Subscription billing is separate from the raw token | ||
| 6 | cost shown here. | ||
| 7 | |||
| 8 | Use **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 | ||
| 10 | headline 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 the | 7 | * 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. Raw | 10 | * 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 usage | 13 | * @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 version | 21 | * 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 | */ |
| 24 | export const USAGE_CONTRACT_VERSION = 3 as const; | 24 | export const USAGE_CONTRACT_VERSION = 4 as const; |
| 25 | 25 | ||
| 26 | export const UsageProviderKind = Schema.Literals(["claude", "codex"]); | 26 | export const UsageProviderKind = Schema.Literals(["claude", "codex"]); |
| 27 | export type UsageProviderKind = typeof UsageProviderKind.Type; | 27 | export 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 | ); |
| 40 | export type UsageDay = typeof UsageDay.Type; | 40 | export type UsageDay = typeof UsageDay.Type; |
| 41 | 41 | ||
| 42 | export const UsageResolution = Schema.Literals(["day", "hour"]); | ||
| 43 | export 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({ |
| 68 | export type UsageTokenTotals = typeof UsageTokenTotals.Type; | 71 | export type UsageTokenTotals = typeof UsageTokenTotals.Type; |
| 69 | 72 | ||
| 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 money | 77 | * `costUsd` is the raw API-equivalent cost of these tokens. It is not money |
| 74 | * spent: subscription plans bill separately. `unpricedRecords` counts records | 78 | * 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 | */ |
| 78 | export const UsageBucket = Schema.Struct({ | 82 | export 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 | }); |
| 169 | export type UsageSummaryInput = typeof UsageSummaryInput.Type; | 180 | export type UsageSummaryInput = typeof UsageSummaryInput.Type; |
| 170 | 181 |
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. | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | enumerateHourStarts, | ||
| 6 | formatDateTimeShort, | ||
| 7 | formatHourShort, | ||
| 8 | formatRelativeHourShort, | ||
| 9 | makeWindow, | ||
| 10 | } from "./usageFormat.ts"; | ||
| 11 | |||
| 12 | describe("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 usageFormat | 5 | * @module usageFormat |
| 6 | */ | 6 | */ |
| 7 | import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; | 7 | import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools/contracts"; |
| 8 | 8 | ||
| 9 | const CURRENCY = new Intl.NumberFormat("en-US", { | 9 | const 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 | } |
| 82 | 82 | ||
| 83 | const HOUR_MS = 60 * 60 * 1000; | ||
| 84 | |||
| 85 | /** Every fixed-duration bucket start in an hourly rolling window. */ | ||
| 86 | export 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 | */ | ||
| 104 | export 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. */ | ||
| 134 | export 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. */ | ||
| 146 | export 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 days | 174 | * 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 | */ |
| 87 | export function makeWindow(days: number, now = new Date()): UsageSummaryInput { | 177 | export 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 day | 207 | // 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 | 208 | // 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] = untilDay | 210 | 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 | } |
| 47 | 47 | ||
| 48 | export 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 | |||
| 48 | export interface CostQuality { | 56 | export 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[] = []; |
| 236 | 256 | ||
| 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 | } |
| 295 | 335 | ||
| ... | @@ -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)); |
| 328 | 368 | ||
| 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, |