authorgravatar for admin@snappeh.comTristan Knight <admin@snappeh.com> 2026-08-24 04:03:02+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-23 23:03:02-04:00
log17dbe8dda3ba87eb83804bc93fc0cb945186d204
treecf00434695197e6550f1ad7c74f856ab0299ea16
parentb4be33f0747445f1c9df126e932c7b9792f322d5
signature Signed by PGP key B5690EEEBB952194

fix(web): show only providers with usage in usage views (#7563)


5 files changed, 74 insertions(+), 20 deletions(-)

apps/web/src/components/usage/UsagePage.test.tsx+10-7
......@@ -53,13 +53,16 @@ vi.mock("../WorkspaceBreadcrumb", () => ({
5353vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" }));
5454vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" }));
5555vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" }));
56vi.mock("./usageProviders", () => ({
57 PROVIDER_ORDER: ["codex", "claude"],
58 PROVIDER_PRESENTATION: {
59 codex: { color: "white", label: "Codex", mark: "span" },
60 claude: { color: "orange", label: "Claude Code", mark: "span" },
61 },
62}));
56vi.mock("./usageProviders", async (importOriginal) => {
57 const actual = await importOriginal<typeof import("./usageProviders")>();
58 return {
59 ...actual,
60 PROVIDER_PRESENTATION: {
61 codex: { color: "white", label: "Codex", mark: "span" },
62 claude: { color: "orange", label: "Claude Code", mark: "span" },
63 },
64 };
65});
6366
6467import { UsagePage } from "./UsagePage";
6568
apps/web/src/components/usage/UsagePage.tsx+27-7
......@@ -32,7 +32,7 @@ import {
3232import { WorkspacePageContainer } from "../WorkspacePageContainer";
3333import { WorkspacePageHeader } from "../WorkspacePageHeader";
3434import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
35import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders";
35import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders";
3636
3737const WINDOW_OPTIONS = [
3838 { days: 1, label: "Past 24h" },
......@@ -74,6 +74,8 @@ export function UsagePage() {
7474 () => (isPast24Hours ? merged.hourly : merged.daily).toReversed(),
7575 [isPast24Hours, merged.daily, merged.hourly],
7676 );
77 const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]);
78 const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`;
7779
7880 const selectWindow = (days: number) => {
7981 setWindowSelection({
......@@ -225,7 +227,7 @@ export function UsagePage() {
225227 </span>
226228 </div>
227229
228 {PROVIDER_ORDER.map((provider) => {
230 {activeProviders.map((provider) => {
229231 const totals = merged.providers.find((entry) => entry.provider === provider);
230232 const share =
231233 metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0);
......@@ -276,6 +278,7 @@ export function UsagePage() {
276278 {metric === "tokens" ? "processed tokens" : "cost"}
277279 </h2>
278280 <UsageProviderChart
281 providers={activeProviders}
279282 days={days}
280283 daily={merged.daily}
281284 hours={hours}
......@@ -331,7 +334,13 @@ export function UsagePage() {
331334 </div>
332335
333336 {breakdown === "model" ? (
334 <table className="w-full text-sm">
337 <table className="w-full table-fixed text-sm">
338 <colgroup>
339 <col className="w-2/5" />
340 <col className="w-1/5" />
341 <col className="w-1/5" />
342 <col className="w-1/5" />
343 </colgroup>
335344 <thead>
336345 <tr className="border-b border-border text-left text-xs text-muted-foreground">
337346 <th className="py-2 font-normal">Model</th>
......@@ -374,11 +383,19 @@ export function UsagePage() {
374383 </tbody>
375384 </table>
376385 ) : (
377 <table className="w-full text-sm">
386 <table className="w-full table-fixed text-sm">
387 <colgroup>
388 <col className="w-2/5" />
389 {activeProviders.map((provider) => (
390 <col key={provider} style={{ width: timeValueColumnWidth }} />
391 ))}
392 <col style={{ width: timeValueColumnWidth }} />
393 <col style={{ width: timeValueColumnWidth }} />
394 </colgroup>
378395 <thead>
379396 <tr className="border-b border-border text-left text-xs text-muted-foreground">
380397 <th className="py-2 font-normal">{isPast24Hours ? "Hour" : "Day"}</th>
381 {PROVIDER_ORDER.map((provider) => (
398 {activeProviders.map((provider) => (
382399 <th key={provider} className="py-2 text-right font-normal">
383400 {PROVIDER_PRESENTATION[provider].label}
384401 </th>
......@@ -390,7 +407,10 @@ export function UsagePage() {
390407 <tbody>
391408 {breakdownPeriods.length === 0 ? (
392409 <tr>
393 <td colSpan={5} className="py-6 text-center text-muted-foreground">
410 <td
411 colSpan={activeProviders.length + 3}
412 className="py-6 text-center text-muted-foreground"
413 >
394414 No activity in this window.
395415 </td>
396416 </tr>
......@@ -405,7 +425,7 @@ export function UsagePage() {
405425 ? formatHourShort(period.hourStart, window.timeZone)
406426 : formatDayShort(period.day)}
407427 </td>
408 {PROVIDER_ORDER.map((provider) => (
428 {activeProviders.map((provider) => (
409429 <td
410430 key={provider}
411431 className="py-2 text-right text-muted-foreground tabular-nums"
apps/web/src/components/usage/UsageProviderChart.test.ts+12
......@@ -1,6 +1,7 @@
11import { describe, expect, it } from "vite-plus/test";
22
33import { buildDayColumns, niceScale } from "./UsageProviderChart";
4import { providersWithUsage } from "./usageProviders";
45
56describe("niceScale", () => {
67 it("never puts the peak above the top of the scale", () => {
......@@ -96,6 +97,17 @@ describe("buildDayColumns", () => {
9697 });
9798});
9899
100describe("providersWithUsage", () => {
101 it("omits providers with no cost or tokens", () => {
102 expect(
103 providersWithUsage([
104 { provider: "codex", costUsd: 0, totalTokens: 0 },
105 { provider: "claude", costUsd: 0, totalTokens: 200 },
106 ]),
107 ).toEqual(["claude"]);
108 });
109});
110
99111describe("hourly chart columns", () => {
100112 it("zero-fills inactive hours and preserves hourly provider values", () => {
101113 const byHour = new Map([
apps/web/src/components/usage/UsageProviderChart.tsx+7-4
......@@ -19,6 +19,7 @@ const PLOT_TOP = 8;
1919export type UsageChartMetric = "tokens" | "cost";
2020
2121interface UsageProviderChartProps {
22 readonly providers: readonly UsageProviderKind[];
2223 readonly days: readonly string[];
2324 readonly daily: readonly DailyTotals[];
2425 readonly hours: readonly string[];
......@@ -187,6 +188,7 @@ export function buildDayColumns(
187188}
188189
189190export function UsageProviderChart({
191 providers,
190192 days,
191193 daily,
192194 hours,
......@@ -209,7 +211,7 @@ export function UsageProviderChart({
209211 const tooltipRef = useRef<HTMLDivElement | null>(null);
210212 const hoverPositionRef = useRef<{ x: number; y: number } | null>(null);
211213
212 const { paths, series, stepX, ticks, toY } = useMemo(() => {
214 const { paths, ticks, stepX, toY, series } = useMemo(() => {
213215 if (periods.length === 0) {
214216 return {
215217 paths: [],
......@@ -235,7 +237,8 @@ export function UsageProviderChart({
235237 const toY = (value: number) =>
236238 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
237239
238 const built = PROVIDER_ORDER.map((provider, providerIndex) => {
240 const built = providers.map((provider) => {
241 const providerIndex = PROVIDER_ORDER.indexOf(provider);
239242 const line = curvePath(
240243 smoothCurve(
241244 columns.map((column, periodIndex) => ({
......@@ -260,7 +263,7 @@ export function UsageProviderChart({
260263 ticks: tickValues,
261264 toY,
262265 };
263 }, [byPeriod, metric, periods]);
266 }, [byPeriod, metric, periods, providers]);
264267
265268 const format = metric === "tokens" ? formatTokens : formatUsd;
266269
......@@ -422,7 +425,7 @@ export function UsageProviderChart({
422425 }}
423426 >
424427 <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div>
425 {PROVIDER_ORDER.map((provider) => {
428 {providers.map((provider) => {
426429 const { label, mark: Mark } = PROVIDER_PRESENTATION[provider];
427430 return (
428431 <div key={provider} className="flex items-center justify-between gap-3">
apps/web/src/components/usage/usageProviders.ts+18-2
......@@ -10,8 +10,8 @@ type UsageProviderPresentation = {
1010
1111/**
1212 * Exhaustive presentation for providers supported by the usage contract.
13 * Declaration order is reused by every chart, table, legend, and skeleton, so
14 * adding a provider only requires its contract support and one entry here.
13 * Declaration order is reused by every chart and table, so adding a provider
14 * only requires its contract support and one entry here.
1515 */
1616export const PROVIDER_PRESENTATION = {
1717 codex: {
......@@ -28,3 +28,19 @@ export const PROVIDER_PRESENTATION = {
2828
2929/** Stable provider reading order across charts, summaries, tables, and hover rows. */
3030export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[];
31
32/** Providers with real activity, independent of the metric currently displayed. */
33export function providersWithUsage(
34 totals: readonly {
35 readonly provider: UsageProviderKind;
36 readonly costUsd: number;
37 readonly totalTokens: number;
38 }[],
39): readonly UsageProviderKind[] {
40 const active = new Set(
41 totals
42 .filter((entry) => entry.totalTokens > 0 || entry.costUsd > 0)
43 .map((entry) => entry.provider),
44 );
45 return PROVIDER_ORDER.filter((provider) => active.has(provider));
46}