From 3c4eaf18d215627912bc281b9ab42364e7ee3e62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gu=C5=A1tinGal?= Date: Thu, 22 Oct 2026 22:33:12 +0200 Subject: [PATCH] fix(web): respect usage metric in time breakdown --- apps/web/src/components/usage/UsagePage.tsx | 10 +++-- .../components/usage/usageBreakdown.test.ts | 41 ++++++++++++++++++- .../src/components/usage/usageBreakdown.ts | 21 ++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index b5c12b148fe80..d270c685a9c04 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -91,6 +91,7 @@ import { modelShare, sortModelsByTokens, speedCostSegments, + timeBreakdownMetric, tokenTypeSegments, } from "./usageBreakdown"; import { @@ -669,6 +670,7 @@ function UsageOverview({ const breakdownPeriods: readonly (DailyTotals | HourlyTotals)[] = ( shownHourly ? merged.hourly : merged.daily ).toReversed(); + const timeMetric = timeBreakdownMetric(metric); const breakdownModels = breakdown === "model" && metric === "tokens" ? sortModelsByTokens(merged.models) @@ -1017,7 +1019,7 @@ function UsageOverview({ ))} Total - Tokens + {timeMetric.secondaryLabel} @@ -1049,7 +1051,7 @@ function UsageOverview({ figureClass(loadingProviders.has(provider)), )} > - {formatUsd(period.byProvider.get(provider)?.costUsd ?? 0)} + {timeMetric.formatValue(period.byProvider.get(provider))} ))} - {formatUsd(period.costUsd)} + {timeMetric.formatValue(period)} - {formatTokens(period.totalTokens)} + {timeMetric.formatSecondaryValue(period)} )) diff --git a/apps/web/src/components/usage/usageBreakdown.test.ts b/apps/web/src/components/usage/usageBreakdown.test.ts index 14fe70017fcbc..0f843a7fbc3a2 100644 --- a/apps/web/src/components/usage/usageBreakdown.test.ts +++ b/apps/web/src/components/usage/usageBreakdown.test.ts @@ -1,7 +1,44 @@ -import type { ModelTotals } from "@t3tools/shared/usageMerge"; +import type { DailyTotals, HourlyTotals, ModelTotals } from "@t3tools/shared/usageMerge"; import { describe, expect, it } from "vite-plus/test"; -import { cacheHitRate, costPerMillionTokens } from "./usageBreakdown"; +import { cacheHitRate, costPerMillionTokens, timeBreakdownMetric } from "./usageBreakdown"; + +describe("time breakdown metric", () => { + const daily: DailyTotals = { + day: "2026-10-22", + costUsd: 3.5, + totalTokens: 2_500_000, + byProvider: new Map([ + ["codex", { costUsd: 3.5, totalTokens: 2_000_000 }], + ["opencode", { costUsd: 0, totalTokens: 500_000 }], + ]), + }; + const hourly: HourlyTotals = { ...daily, hourStart: "2026-10-22T10:00:00.000Z" }; + + it.each([ + { resolution: "day", period: daily }, + { resolution: "hour", period: hourly }, + ])("switches $resolution provider and total figures with the selected metric", ({ period }) => { + for (const metric of ["cost", "tokens", "cost"] as const) { + const figures = timeBreakdownMetric(metric); + expect(figures.formatValue(period.byProvider.get("codex"))).toBe( + metric === "tokens" ? "2M" : "$3.50", + ); + expect(figures.formatValue(period.byProvider.get("opencode"))).toBe( + metric === "tokens" ? "500K" : "$0.00", + ); + expect(figures.formatValue(period)).toBe(metric === "tokens" ? "2.50M" : "$3.50"); + expect(figures.secondaryLabel).toBe(metric === "tokens" ? "Cost" : "Tokens"); + expect(figures.formatSecondaryValue(period)).toBe(metric === "tokens" ? "$3.50" : "2.50M"); + } + }); + + it("shows zero for a provider with no activity in the period", () => { + const missing = hourly.byProvider.get("claude"); + expect(timeBreakdownMetric("cost").formatValue(missing)).toBe("$0.00"); + expect(timeBreakdownMetric("tokens").formatValue(missing)).toBe("0"); + }); +}); const model = ( name: string, diff --git a/apps/web/src/components/usage/usageBreakdown.ts b/apps/web/src/components/usage/usageBreakdown.ts index 7fecab508c223..0d5de08d508bb 100644 --- a/apps/web/src/components/usage/usageBreakdown.ts +++ b/apps/web/src/components/usage/usageBreakdown.ts @@ -1,13 +1,34 @@ import type { UsageTokenTotals } from "@t3tools/contracts"; +import { formatTokens, formatUsd } from "@t3tools/shared/usageFormat"; import { isModelCostUnknown, type CategoryCost, + type DailyTotals, type ModelTotals, type SpeedCost, } from "@t3tools/shared/usageMerge"; import type { ShareSegment } from "./UsageShareBar"; +export function timeBreakdownMetric(metric: "cost" | "tokens") { + const formatCost = (totals: Pick | undefined) => + formatUsd(totals?.costUsd ?? 0); + const formatTokenCount = (totals: Pick | undefined) => + formatTokens(totals?.totalTokens ?? 0); + + return metric === "tokens" + ? { + formatValue: formatTokenCount, + secondaryLabel: "Cost", + formatSecondaryValue: formatCost, + } + : { + formatValue: formatCost, + secondaryLabel: "Tokens", + formatSecondaryValue: formatTokenCount, + }; +} + export function sortModelsByTokens(models: readonly ModelTotals[]) { return models.toSorted( (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd,