authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-08 08:43:03-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-08 05:43:03-07:00
loga20923ce463335e89e92f5983d98a180536e8e7d
tree7a3d02ac6b75b3ccd52ae72e1aa805d3e521404d
parent8101cd044911c7dc2a2adf7c7a9ba7962abf57b6
signature Signed by PGP key B5690EEEBB952194

fix(web): usage chart no longer makes Claude look like the bigger spender (#5697)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

3 files changed, 65 insertions(+), 74 deletions(-)

apps/web/src/components/usage/UsageProviderChart.test.ts+14-8
...@@ -77,15 +77,21 @@ describe("buildDayColumns", () => {...@@ -77,15 +77,21 @@ describe("buildDayColumns", () => {
77 ]);77 ]);
78 });78 });
7979
80 it("keeps the bands contiguous so the areas stay additive", () => {80 it("keeps band values absolute rather than cumulative", () => {
81 // Regression: the bands were once stack offsets, which drew Claude Code
82 // permanently above Codex regardless of which provider spent more.
83 const [first] = buildDayColumns(days, byDay, "cost");
84
85 expect(first?.bands).toEqual([
86 { provider: "codex", value: 10 },
87 { provider: "claude", value: 20 },
88 ]);
89 });
90
91 it("reports the total as the sum of its bands", () => {
81 for (const column of buildDayColumns(days, byDay, "cost")) {92 for (const column of buildDayColumns(days, byDay, "cost")) {
82 let expectedBase = 0;93 const sum = column.bands.reduce((running, band) => running + band.value, 0);
83 for (const band of column.bands) {94 expect(column.total).toBeCloseTo(sum, 9);
84 expect(band.base).toBeCloseTo(expectedBase, 9);
85 expect(band.top).toBeCloseTo(band.base + band.value, 9);
86 expectedBase = band.top;
87 }
88 expect(column.total).toBeCloseTo(expectedBase, 9);
89 }95 }
90 });96 });
91});97});
apps/web/src/components/usage/UsageProviderChart.tsx+48-64
...@@ -18,13 +18,11 @@ interface UsageProviderChartProps {...@@ -18,13 +18,11 @@ interface UsageProviderChartProps {
18 readonly metric: UsageChartMetric;18 readonly metric: UsageChartMetric;
19}19}
2020
21/** One day's stacked bands, shared by the paths and the hover readout. */21/** One day's per-provider values, shared by the paths and the hover readout. */
22export interface DayColumn {22export interface DayColumn {
23 readonly bands: readonly {23 readonly bands: readonly {
24 readonly provider: UsageProviderKind;24 readonly provider: UsageProviderKind;
25 readonly value: number;25 readonly value: number;
26 readonly base: number;
27 readonly top: number;
28 }[];26 }[];
29 readonly total: number;27 readonly total: number;
30}28}
...@@ -130,28 +128,6 @@ function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"):...@@ -130,28 +128,6 @@ function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"):
130 return path;128 return path;
131}129}
132130
133/**
134 * The same curve walked end to start. A cubic reverses exactly by swapping its
135 * control points, so this traces the identical geometry.
136 *
137 * Bands must use this rather than re-smoothing their base points in reverse:
138 * the tangent clamp in `monotoneTangents` runs left to right, so smoothing is
139 * not perfectly symmetric under reversal, and independently smoothed edges of
140 * adjacent bands could hairline-gap or overlap. Sharing one curve per stack
141 * boundary makes that geometrically impossible.
142 */
143function reversedCurvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string {
144 const last = segments[segments.length - 1];
145 if (last === undefined) return "";
146 let path = `${startCommand}${last.to.x.toFixed(2)},${last.to.y.toFixed(2)}`;
147 for (let index = segments.length - 1; index >= 0; index -= 1) {
148 const segment = segments[index];
149 if (segment === undefined) continue;
150 path += ` C${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.from.x.toFixed(2)},${segment.from.y.toFixed(2)}`;
151 }
152 return path;
153}
154
155/**131/**
156 * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the132 * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the
157 * peak.133 * peak.
...@@ -175,7 +151,12 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re...@@ -175,7 +151,12 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re
175}151}
176152
177/**153/**
178 * Turns the merged daily totals into stacked bands, one column per day.154 * Turns the merged daily totals into one column per day.
155 *
156 * Values are absolute, not cumulative: the series are layered from a shared
157 * zero baseline rather than stacked. A stacked chart puts whichever provider is
158 * drawn last permanently above the other, which reads as "that one is bigger"
159 * even on days where it is not.
179 *160 *
180 * The chart paths and the hover readout both consume this, so the number under161 * The chart paths and the hover readout both consume this, so the number under
181 * the cursor is by construction the number that was plotted rather than a162 * the cursor is by construction the number that was plotted rather than a
...@@ -188,14 +169,11 @@ export function buildDayColumns(...@@ -188,14 +169,11 @@ export function buildDayColumns(
188): readonly DayColumn[] {169): readonly DayColumn[] {
189 return days.map((day) => {170 return days.map((day) => {
190 const entry = byDay.get(day);171 const entry = byDay.get(day);
191 let stackTop = 0;172 const bands = PROVIDER_ORDER.map((provider) => ({
192 const bands = PROVIDER_ORDER.map((provider) => {173 provider,
193 const value = valueFor(entry, provider, metric);174 value: valueFor(entry, provider, metric),
194 const base = stackTop;175 }));
195 stackTop += value;176 return { bands, total: bands.reduce((sum, band) => sum + band.value, 0) };
196 return { provider, value, base, top: stackTop };
197 });
198 return { bands, total: stackTop };
199 });177 });
200}178}
201179
...@@ -215,9 +193,15 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr...@@ -215,9 +193,15 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
215 };193 };
216 }194 }
217195
218 const stacked = buildDayColumns(days, byDay, metric);196 const columns = buildDayColumns(days, byDay, metric);
219197
220 const peak = stacked.reduce((max, column) => Math.max(max, column.total), 0);198 // 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
200 // leave the plot permanently half empty.
201 const peak = columns.reduce(
202 (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),
203 0,
204 );
221 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);205 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);
222 const step = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1);206 const step = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1);
223 // Reserve a sliver above the top gridline so the series stroke, which is207 // Reserve a sliver above the top gridline so the series stroke, which is
...@@ -225,30 +209,28 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr...@@ -225,30 +209,28 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
225 const toY = (value: number) =>209 const toY = (value: number) =>
226 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);210 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
227211
228 // One smoothed curve per stack boundary (baseline, then each provider's212 const built = PROVIDER_ORDER.map((provider, providerIndex) => {
229 // cumulative top). Band k is the region between boundary k and k+1, both213 const curve = smoothCurve(
230 // drawn from these shared control points.214 columns.map((column, dayIndex) => ({
231 const boundaries = [
232 stacked.map((_, dayIndex) => ({ x: dayIndex * step, y: toY(0) })),
233 ...PROVIDER_ORDER.map((_, providerIndex) =>
234 stacked.map((column, dayIndex) => ({
235 x: dayIndex * step,215 x: dayIndex * step,
236 y: toY(column.bands[providerIndex]?.top ?? 0),216 y: toY(column.bands[providerIndex]?.value ?? 0),
237 })),217 })),
238 ),218 );
239 ].map(smoothCurve);219 const line = curvePath(curve, "M");
240
241 const built = PROVIDER_ORDER.map((provider, providerIndex) => {
242 const top = boundaries[providerIndex + 1] ?? [];
243 const base = boundaries[providerIndex] ?? [];
244 return {220 return {
245 provider,221 provider,
246 area: `${curvePath(top, "M")} ${reversedCurvePath(base, "L")} Z`,222 total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0),
247 line: curvePath(top, "M"),223 area: line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`,
224 line,
248 };225 };
249 });226 });
250227
251 return { paths: built, ticks: tickValues, stepX: step, toY, series: stacked };228 // Paint the heavier series first so the lighter one is never buried under
229 // it. The fills are faint enough that the order barely shows, but the
230 // strokes are drawn in a second pass regardless, so neither can be hidden.
231 const ordered = [...built].sort((a, b) => b.total - a.total);
232
233 return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns };
252 }, [byDay, days, metric]);234 }, [byDay, days, metric]);
253235
254 const format = metric === "tokens" ? formatTokens : formatUsd;236 const format = metric === "tokens" ? formatTokens : formatUsd;
...@@ -314,17 +296,19 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr...@@ -314,17 +296,19 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
314 );296 );
315 })}297 })}
316298
317 {paths.map(({ provider, area, line }) => (299 {/* Fills first, then every stroke, so no series covers another's line. */}
318 <g key={provider}>300 {paths.map(({ provider, area }) => (
319 <path d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.5} />301 <path key={provider} d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.12} />
320 <path302 ))}
321 d={line}303 {paths.map(({ provider, line }) => (
322 fill="none"304 <path
323 stroke={PROVIDER_COLOR[provider]}305 key={provider}
324 strokeWidth={1.5}306 d={line}
325 vectorEffect="non-scaling-stroke"307 fill="none"
326 />308 stroke={PROVIDER_COLOR[provider]}
327 </g>309 strokeWidth={2}
310 vectorEffect="non-scaling-stroke"
311 />
328 ))}312 ))}
329313
330 {hoverIndex === null ? null : (314 {hoverIndex === null ? null : (
apps/web/src/components/usage/usageProviders.ts+3-2
...@@ -3,8 +3,9 @@ import type { UsageProviderKind } from "@t3tools/contracts";...@@ -3,8 +3,9 @@ import type { UsageProviderKind } from "@t3tools/contracts";
3import { ClaudeAI, type Icon, OpenAI } from "../Icons";3import { ClaudeAI, type Icon, OpenAI } from "../Icons";
44
5/**5/**
6 * Stacking and table order. Codex sits under Claude Code so the larger band6 * Series and table order. The chart layers both providers from a shared zero
7 * reads as the top surface, matching the reference layout.7 * baseline, so this only fixes the reading order of legends, tables and hover
8 * rows; it does not decide which series sits above the other.
8 */9 */
9export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];10export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];
1011