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", () => {
7777 ]);
7878 });
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", () => {
8192 for (const column of buildDayColumns(days, byDay, "cost")) {
82 let expectedBase = 0;
83 for (const band of column.bands) {
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);
93 const sum = column.bands.reduce((running, band) => running + band.value, 0);
94 expect(column.total).toBeCloseTo(sum, 9);
8995 }
9096 });
9197});
apps/web/src/components/usage/UsageProviderChart.tsx+48-64
......@@ -18,13 +18,11 @@ interface UsageProviderChartProps {
1818 readonly metric: UsageChartMetric;
1919}
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. */
2222export interface DayColumn {
2323 readonly bands: readonly {
2424 readonly provider: UsageProviderKind;
2525 readonly value: number;
26 readonly base: number;
27 readonly top: number;
2826 }[];
2927 readonly total: number;
3028}
......@@ -130,28 +128,6 @@ function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"):
130128 return path;
131129}
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
155131/**
156132 * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the
157133 * peak.
......@@ -175,7 +151,12 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re
175151}
176152
177153/**
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.
179160 *
180161 * The chart paths and the hover readout both consume this, so the number under
181162 * the cursor is by construction the number that was plotted rather than a
......@@ -188,14 +169,11 @@ export function buildDayColumns(
188169): readonly DayColumn[] {
189170 return days.map((day) => {
190171 const entry = byDay.get(day);
191 let stackTop = 0;
192 const bands = PROVIDER_ORDER.map((provider) => {
193 const value = valueFor(entry, provider, metric);
194 const base = stackTop;
195 stackTop += value;
196 return { provider, value, base, top: stackTop };
197 });
198 return { bands, total: stackTop };
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) };
199177 });
200178}
201179
......@@ -215,9 +193,15 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
215193 };
216194 }
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 );
221205 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);
222206 const step = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1);
223207 // Reserve a sliver above the top gridline so the series stroke, which is
......@@ -225,30 +209,28 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
225209 const toY = (value: number) =>
226210 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
227211
228 // One smoothed curve per stack boundary (baseline, then each provider's
229 // cumulative top). Band k is the region between boundary k and k+1, both
230 // drawn from these shared control points.
231 const boundaries = [
232 stacked.map((_, dayIndex) => ({ x: dayIndex * step, y: toY(0) })),
233 ...PROVIDER_ORDER.map((_, providerIndex) =>
234 stacked.map((column, dayIndex) => ({
212 const built = PROVIDER_ORDER.map((provider, providerIndex) => {
213 const curve = smoothCurve(
214 columns.map((column, dayIndex) => ({
235215 x: dayIndex * step,
236 y: toY(column.bands[providerIndex]?.top ?? 0),
216 y: toY(column.bands[providerIndex]?.value ?? 0),
237217 })),
238 ),
239 ].map(smoothCurve);
240
241 const built = PROVIDER_ORDER.map((provider, providerIndex) => {
242 const top = boundaries[providerIndex + 1] ?? [];
243 const base = boundaries[providerIndex] ?? [];
218 );
219 const line = curvePath(curve, "M");
244220 return {
245221 provider,
246 area: `${curvePath(top, "M")} ${reversedCurvePath(base, "L")} Z`,
247 line: curvePath(top, "M"),
222 total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0),
223 area: line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`,
224 line,
248225 };
249226 });
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 };
252234 }, [byDay, days, metric]);
253235
254236 const format = metric === "tokens" ? formatTokens : formatUsd;
......@@ -314,17 +296,19 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
314296 );
315297 })}
316298
317 {paths.map(({ provider, area, line }) => (
318 <g key={provider}>
319 <path d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.5} />
320 <path
321 d={line}
322 fill="none"
323 stroke={PROVIDER_COLOR[provider]}
324 strokeWidth={1.5}
325 vectorEffect="non-scaling-stroke"
326 />
327 </g>
299 {/* Fills first, then every stroke, so no series covers another's line. */}
300 {paths.map(({ provider, area }) => (
301 <path key={provider} d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.12} />
302 ))}
303 {paths.map(({ provider, line }) => (
304 <path
305 key={provider}
306 d={line}
307 fill="none"
308 stroke={PROVIDER_COLOR[provider]}
309 strokeWidth={2}
310 vectorEffect="non-scaling-stroke"
311 />
328312 ))}
329313
330314 {hoverIndex === null ? null : (
apps/web/src/components/usage/usageProviders.ts+3-2
......@@ -3,8 +3,9 @@ import type { UsageProviderKind } from "@t3tools/contracts";
33import { ClaudeAI, type Icon, OpenAI } from "../Icons";
44
55/**
6 * Stacking and table order. Codex sits under Claude Code so the larger band
7 * reads as the top surface, matching the reference layout.
6 * Series and table order. The chart layers both providers from a shared zero
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.
89 */
910export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];
1011