diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 2b647153f20..a36cd1e8833 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -77,15 +77,21 @@ describe("buildDayColumns", () => { ]); }); - it("keeps the bands contiguous so the areas stay additive", () => { + it("keeps band values absolute rather than cumulative", () => { + // Regression: the bands were once stack offsets, which drew Claude Code + // permanently above Codex regardless of which provider spent more. + const [first] = buildDayColumns(days, byDay, "cost"); + + expect(first?.bands).toEqual([ + { provider: "codex", value: 10 }, + { provider: "claude", value: 20 }, + ]); + }); + + it("reports the total as the sum of its bands", () => { for (const column of buildDayColumns(days, byDay, "cost")) { - let expectedBase = 0; - for (const band of column.bands) { - expect(band.base).toBeCloseTo(expectedBase, 9); - expect(band.top).toBeCloseTo(band.base + band.value, 9); - expectedBase = band.top; - } - expect(column.total).toBeCloseTo(expectedBase, 9); + const sum = column.bands.reduce((running, band) => running + band.value, 0); + expect(column.total).toBeCloseTo(sum, 9); } }); }); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index d1ffce25e65..1d5410ebad5 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -18,13 +18,11 @@ interface UsageProviderChartProps { readonly metric: UsageChartMetric; } -/** One day's stacked bands, shared by the paths and the hover readout. */ +/** One day's per-provider values, shared by the paths and the hover readout. */ export interface DayColumn { readonly bands: readonly { readonly provider: UsageProviderKind; readonly value: number; - readonly base: number; - readonly top: number; }[]; readonly total: number; } @@ -130,28 +128,6 @@ function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): return path; } -/** - * The same curve walked end to start. A cubic reverses exactly by swapping its - * control points, so this traces the identical geometry. - * - * Bands must use this rather than re-smoothing their base points in reverse: - * the tangent clamp in `monotoneTangents` runs left to right, so smoothing is - * not perfectly symmetric under reversal, and independently smoothed edges of - * adjacent bands could hairline-gap or overlap. Sharing one curve per stack - * boundary makes that geometrically impossible. - */ -function reversedCurvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { - const last = segments[segments.length - 1]; - if (last === undefined) return ""; - let path = `${startCommand}${last.to.x.toFixed(2)},${last.to.y.toFixed(2)}`; - for (let index = segments.length - 1; index >= 0; index -= 1) { - const segment = segments[index]; - if (segment === undefined) continue; - 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)}`; - } - return path; -} - /** * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the * peak. @@ -175,7 +151,12 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re } /** - * Turns the merged daily totals into stacked bands, one column per day. + * Turns the merged daily totals into one column per day. + * + * Values are absolute, not cumulative: the series are layered from a shared + * zero baseline rather than stacked. A stacked chart puts whichever provider is + * drawn last permanently above the other, which reads as "that one is bigger" + * even on days where it is not. * * The chart paths and the hover readout both consume this, so the number under * the cursor is by construction the number that was plotted rather than a @@ -188,14 +169,11 @@ export function buildDayColumns( ): readonly DayColumn[] { return days.map((day) => { const entry = byDay.get(day); - let stackTop = 0; - const bands = PROVIDER_ORDER.map((provider) => { - const value = valueFor(entry, provider, metric); - const base = stackTop; - stackTop += value; - return { provider, value, base, top: stackTop }; - }); - return { bands, total: stackTop }; + const bands = PROVIDER_ORDER.map((provider) => ({ + provider, + value: valueFor(entry, provider, metric), + })); + return { bands, total: bands.reduce((sum, band) => sum + band.value, 0) }; }); } @@ -215,9 +193,15 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr }; } - const stacked = buildDayColumns(days, byDay, metric); + const columns = buildDayColumns(days, byDay, metric); - const peak = stacked.reduce((max, column) => Math.max(max, column.total), 0); + // The scale tops out at the largest single provider-day, not the largest + // sum: layered series each measure from zero, so a combined peak would + // leave the plot permanently half empty. + const peak = columns.reduce( + (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), + 0, + ); const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); const step = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1); // Reserve a sliver above the top gridline so the series stroke, which is @@ -225,30 +209,28 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr const toY = (value: number) => max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); - // One smoothed curve per stack boundary (baseline, then each provider's - // cumulative top). Band k is the region between boundary k and k+1, both - // drawn from these shared control points. - const boundaries = [ - stacked.map((_, dayIndex) => ({ x: dayIndex * step, y: toY(0) })), - ...PROVIDER_ORDER.map((_, providerIndex) => - stacked.map((column, dayIndex) => ({ + const built = PROVIDER_ORDER.map((provider, providerIndex) => { + const curve = smoothCurve( + columns.map((column, dayIndex) => ({ x: dayIndex * step, - y: toY(column.bands[providerIndex]?.top ?? 0), + y: toY(column.bands[providerIndex]?.value ?? 0), })), - ), - ].map(smoothCurve); - - const built = PROVIDER_ORDER.map((provider, providerIndex) => { - const top = boundaries[providerIndex + 1] ?? []; - const base = boundaries[providerIndex] ?? []; + ); + const line = curvePath(curve, "M"); return { provider, - area: `${curvePath(top, "M")} ${reversedCurvePath(base, "L")} Z`, - line: curvePath(top, "M"), + total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), + area: line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`, + line, }; }); - return { paths: built, ticks: tickValues, stepX: step, toY, series: stacked }; + // Paint the heavier series first so the lighter one is never buried under + // it. The fills are faint enough that the order barely shows, but the + // strokes are drawn in a second pass regardless, so neither can be hidden. + const ordered = [...built].sort((a, b) => b.total - a.total); + + return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns }; }, [byDay, days, metric]); const format = metric === "tokens" ? formatTokens : formatUsd; @@ -314,17 +296,19 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr ); })} - {paths.map(({ provider, area, line }) => ( - - - - + {/* Fills first, then every stroke, so no series covers another's line. */} + {paths.map(({ provider, area }) => ( + + ))} + {paths.map(({ provider, line }) => ( + ))} {hoverIndex === null ? null : ( diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 5356f96edc7..f8b65877dcf 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -3,8 +3,9 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { ClaudeAI, type Icon, OpenAI } from "../Icons"; /** - * Stacking and table order. Codex sits under Claude Code so the larger band - * reads as the top surface, matching the reference layout. + * Series and table order. The chart layers both providers from a shared zero + * baseline, so this only fixes the reading order of legends, tables and hover + * rows; it does not decide which series sits above the other. */ export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];