Skip to content

Commit d586e6d

Browse files
committed
feat(web): redesign usage insights
1 parent 904f059 commit d586e6d

8 files changed

Lines changed: 348 additions & 326 deletions

File tree

apps/web/src/components/WorkspacePageContainer.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,11 @@ export function WorkspacePageHeader({
5252
/>
5353
);
5454
}
55+
56+
/** Keeps an icon glyph on the content edge while its larger hit target extends outward. */
57+
export function WorkspacePageHeaderEdgeControl({
58+
className,
59+
...props
60+
}: ComponentPropsWithoutRef<"div">) {
61+
return <div className={cn("-me-[7px] flex shrink-0", className)} {...props} />;
62+
}

apps/web/src/components/ui/toggle-group.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,13 @@ function ToggleGroup({
3030
orientation === "horizontal"
3131
? "*:pointer-coarse:after:min-w-auto"
3232
: "*:pointer-coarse:after:min-h-auto",
33-
variant === "default"
34-
? "gap-0.5"
35-
: orientation === "horizontal"
36-
? "*:not-first:not-data-[slot=separator]:before:-start-[0.5px] *:not-last:not-data-[slot=separator]:before:-end-[0.5px] *:not-first:rounded-s-none *:not-last:rounded-e-none *:not-first:border-s-0 *:not-last:border-e-0 *:not-first:before:rounded-s-none *:not-last:before:rounded-e-none"
37-
: "*:not-first:not-data-[slot=separator]:before:-top-[0.5px] *:not-last:not-data-[slot=separator]:before:-bottom-[0.5px] flex-col *:not-first:rounded-t-none *:not-last:rounded-b-none *:not-first:border-t-0 *:not-last:border-b-0 *:not-first:before:rounded-t-none *:not-last:before:rounded-b-none *:data-[slot=toggle]:not-last:before:hidden dark:*:last:before:hidden dark:*:first:before:block",
33+
variant === "segmented"
34+
? "gap-0.5 rounded-lg bg-input/40 p-0.5"
35+
: variant === "default"
36+
? "gap-0.5"
37+
: orientation === "horizontal"
38+
? "*:not-first:not-data-[slot=separator]:before:-start-[0.5px] *:not-last:not-data-[slot=separator]:before:-end-[0.5px] *:not-first:rounded-s-none *:not-last:rounded-e-none *:not-first:border-s-0 *:not-last:border-e-0 *:not-first:before:rounded-s-none *:not-last:before:rounded-e-none"
39+
: "*:not-first:not-data-[slot=separator]:before:-top-[0.5px] *:not-last:not-data-[slot=separator]:before:-bottom-[0.5px] flex-col *:not-first:rounded-t-none *:not-last:rounded-b-none *:not-first:border-t-0 *:not-last:border-b-0 *:not-first:before:rounded-t-none *:not-last:before:rounded-b-none *:data-[slot=toggle]:not-last:before:hidden dark:*:last:before:hidden dark:*:first:before:block",
3840
className,
3941
)}
4042
data-size={size}

apps/web/src/components/ui/toggle.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const toggleVariants = cva(
1818
"h-7 min-w-7 rounded-md px-[calc(--spacing(1)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5",
1919
default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8",
2020
lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9",
21+
segmented:
22+
"h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]",
2123
sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7",
2224
xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md",
2325
},
@@ -27,6 +29,8 @@ const toggleVariants = cva(
2729
"border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100",
2830
outline:
2931
"border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none",
32+
segmented:
33+
"border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-background/55 hover:text-foreground data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-xs/10",
3034
},
3135
},
3236
},

apps/web/src/components/usage/UsagePage.tsx

Lines changed: 210 additions & 244 deletions
Large diffs are not rendered by default.

apps/web/src/components/usage/UsageProviderChart.tsx

Lines changed: 82 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { UsageProviderKind } from "@t3tools/contracts";
2-
import { useCallback, useMemo, useRef, useState } from "react";
2+
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
33

44
import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";
55
import {
@@ -68,13 +68,7 @@ function buildPeriodColumns(
6868
});
6969
}
7070

71-
/**
72-
* Monotone cubic tangents (Fritsch-Carlson).
73-
*
74-
* Plain cubic smoothing overshoots on spiky daily data and would dip the area
75-
* below zero between points, which reads as negative spend. This variant is
76-
* shape-preserving, so a smoothed series never leaves the range of its samples.
77-
*/
71+
/** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */
7872
function monotoneTangents(points: readonly Point[]): readonly number[] {
7973
const count = points.length;
8074
if (count < 2) return [0];
@@ -115,15 +109,13 @@ function monotoneTangents(points: readonly Point[]): readonly number[] {
115109
return tangents;
116110
}
117111

118-
/** One cubic segment of a smoothed boundary. */
119112
interface CurveSegment {
120113
readonly from: Point;
121114
readonly c1: Point;
122115
readonly c2: Point;
123116
readonly to: Point;
124117
}
125118

126-
/** Smoothed polyline through `points`, as explicit cubic control points. */
127119
function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
128120
if (points.length < 2) return [];
129121
const tangents = monotoneTangents(points);
@@ -144,10 +136,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
144136
return segments;
145137
}
146138

147-
function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string {
139+
function curvePath(segments: readonly CurveSegment[]): string {
148140
const first = segments[0];
149141
if (first === undefined) return "";
150-
let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`;
142+
let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`;
151143
for (const segment of segments) {
152144
path += ` C${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.to.x.toFixed(2)},${segment.to.y.toFixed(2)}`;
153145
}
@@ -179,10 +171,8 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re
179171
/**
180172
* Turns the merged daily totals into one column per day.
181173
*
182-
* Values are absolute, not cumulative: the series are layered from a shared
183-
* zero baseline rather than stacked. A stacked chart puts whichever provider is
184-
* drawn last permanently above the other, which reads as "that one is bigger"
185-
* even on days where it is not.
174+
* Values are absolute, not cumulative: each provider is drawn from the same
175+
* zero baseline so the chart never implies that one provider is always larger.
186176
*
187177
* The chart paths and the hover readout both consume this, so the number under
188178
* the cursor is by construction the number that was plotted rather than a
@@ -216,42 +206,39 @@ export function UsageProviderChart({
216206
);
217207
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
218208
const plotRef = useRef<HTMLDivElement | null>(null);
209+
const tooltipRef = useRef<HTMLDivElement | null>(null);
210+
const hoverPositionRef = useRef<{ x: number; y: number } | null>(null);
219211

220-
const { paths, ticks, stepX, toY, series } = useMemo(() => {
212+
const { paths, series, stepX, ticks, toY } = useMemo(() => {
221213
if (periods.length === 0) {
222214
return {
223215
paths: [],
224-
ticks: [0] as readonly number[],
216+
series: [] as readonly DayColumn[],
225217
stepX: 0,
218+
ticks: [0] as readonly number[],
226219
toY: () => VIEW_HEIGHT,
227-
series: [] as readonly DayColumn[],
228220
};
229221
}
230222

231223
const columns = buildPeriodColumns(periods, byPeriod, metric);
232-
233-
// The scale tops out at the largest single provider-day, not the largest
234-
// sum: layered series each measure from zero, so a combined peak would
235-
// leave the plot permanently half empty.
236224
const peak = columns.reduce(
237225
(max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),
238226
0,
239227
);
240228
const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);
241229
const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1);
242-
// Reserve a sliver above the top gridline so the series stroke, which is
243-
// drawn at constant screen width, is not shaved off at a peak.
244230
const toY = (value: number) =>
245231
max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
246232

247233
const built = PROVIDER_ORDER.map((provider, providerIndex) => {
248-
const curve = smoothCurve(
249-
columns.map((column, dayIndex) => ({
250-
x: dayIndex * step,
251-
y: toY(column.bands[providerIndex]?.value ?? 0),
252-
})),
234+
const line = curvePath(
235+
smoothCurve(
236+
columns.map((column, periodIndex) => ({
237+
x: periodIndex * step,
238+
y: toY(column.bands[providerIndex]?.value ?? 0),
239+
})),
240+
),
253241
);
254-
const line = curvePath(curve, "M");
255242
return {
256243
provider,
257244
total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0),
@@ -260,30 +247,75 @@ export function UsageProviderChart({
260247
};
261248
});
262249

263-
// Paint the heavier series first so the lighter one is never buried under
264-
// it. The fills are faint enough that the order barely shows, but the
265-
// strokes are drawn in a second pass regardless, so neither can be hidden.
266-
const ordered = [...built].sort((a, b) => b.total - a.total);
267-
268-
return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns };
250+
return {
251+
paths: built.toSorted((a, b) => b.total - a.total),
252+
series: columns,
253+
stepX: step,
254+
ticks: tickValues,
255+
toY,
256+
};
269257
}, [byPeriod, metric, periods]);
270258

271259
const format = metric === "tokens" ? formatTokens : formatUsd;
272260

261+
const positionTooltip = useCallback(() => {
262+
const plot = plotRef.current;
263+
const tooltip = tooltipRef.current;
264+
const hoverPosition = hoverPositionRef.current;
265+
if (plot === null || tooltip === null || hoverPosition === null) return;
266+
267+
const gap = 12;
268+
const tooltipWidth = tooltip.offsetWidth;
269+
const tooltipHeight = tooltip.offsetHeight;
270+
const plotWidth = plot.clientWidth;
271+
const plotHeight = plot.clientHeight;
272+
const preferredLeft =
273+
hoverPosition.x + gap + tooltipWidth <= plotWidth
274+
? hoverPosition.x + gap
275+
: hoverPosition.x - gap - tooltipWidth;
276+
const preferredTop =
277+
hoverPosition.y + gap + tooltipHeight <= plotHeight
278+
? hoverPosition.y + gap
279+
: hoverPosition.y - gap - tooltipHeight;
280+
const left = Math.min(Math.max(0, preferredLeft), Math.max(0, plotWidth - tooltipWidth));
281+
const top = Math.min(Math.max(0, preferredTop), Math.max(0, plotHeight - tooltipHeight));
282+
plot.style.setProperty("--usage-tooltip-left", `${left}px`);
283+
plot.style.setProperty("--usage-tooltip-top", `${top}px`);
284+
}, []);
285+
286+
useLayoutEffect(() => {
287+
if (hoverIndex === null) return;
288+
positionTooltip();
289+
290+
const plot = plotRef.current;
291+
const tooltip = tooltipRef.current;
292+
if (plot === null || tooltip === null || typeof ResizeObserver === "undefined") return;
293+
294+
const observer = new ResizeObserver(positionTooltip);
295+
observer.observe(plot);
296+
observer.observe(tooltip);
297+
return () => observer.disconnect();
298+
}, [hoverIndex, positionTooltip]);
299+
273300
const handleMove = useCallback(
274301
(event: React.MouseEvent<HTMLDivElement>) => {
275-
const bounds = plotRef.current?.getBoundingClientRect();
276-
if (bounds === undefined || bounds.width === 0 || periods.length === 0) return;
277-
const fraction = (event.clientX - bounds.left) / bounds.width;
302+
const plot = plotRef.current;
303+
if (plot === null || periods.length === 0) return;
304+
const bounds = plot.getBoundingClientRect();
305+
if (bounds.width === 0) return;
306+
const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left));
307+
const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top));
308+
const fraction = localX / bounds.width;
278309
const index = Math.round(fraction * (periods.length - 1));
310+
hoverPositionRef.current = { x: localX, y: localY };
311+
positionTooltip();
279312
setHoverIndex(Math.min(periods.length - 1, Math.max(0, index)));
280313
},
281-
[periods.length],
314+
[periods.length, positionTooltip],
282315
);
283316

284317
const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];
285318
const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];
286-
const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100;
287319
const formatPeriod = (period: string) =>
288320
resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);
289321
const formatTooltipPeriod = (period: string) =>
@@ -311,7 +343,10 @@ export function UsageProviderChart({
311343
ref={plotRef}
312344
className="relative h-56 flex-1"
313345
onMouseMove={handleMove}
314-
onMouseLeave={() => setHoverIndex(null)}
346+
onMouseLeave={() => {
347+
hoverPositionRef.current = null;
348+
setHoverIndex(null);
349+
}}
315350
>
316351
<svg
317352
className="h-full w-full"
@@ -337,7 +372,6 @@ export function UsageProviderChart({
337372
);
338373
})}
339374

340-
{/* Fills first, then every stroke, so no series covers another's line. */}
341375
{paths.map(({ provider, area }) => (
342376
<path
343377
key={provider}
@@ -373,10 +407,11 @@ export function UsageProviderChart({
373407

374408
{hoveredPeriod === undefined ? null : (
375409
<div
376-
className="pointer-events-none absolute top-0 z-10 min-w-36 border border-border bg-background/95 px-2 py-1.5 text-xs"
410+
ref={tooltipRef}
411+
className="surface-glass pointer-events-none absolute z-10 min-w-36 max-w-full rounded-xl border border-border/50 px-2.5 py-2 text-xs shadow-lg"
377412
style={{
378-
left: `${hoverLeft}%`,
379-
transform: hoverLeft > 60 ? "translateX(-100%)" : "translateX(0)",
413+
left: "var(--usage-tooltip-left, 0px)",
414+
top: "var(--usage-tooltip-top, 0px)",
380415
}}
381416
>
382417
<div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div>
@@ -423,21 +458,3 @@ export function UsageProviderChart({
423458
</div>
424459
);
425460
}
426-
427-
export function UsageChartLegend() {
428-
return (
429-
<div className="flex items-center gap-4">
430-
{PROVIDER_ORDER.map((provider) => {
431-
// Brand marks keep monochrome providers identifiable even when their
432-
// chart series use distinct colors.
433-
const { label, mark: Mark } = PROVIDER_PRESENTATION[provider];
434-
return (
435-
<span key={provider} className="flex items-center gap-1.5 text-xs text-muted-foreground">
436-
<Mark className="size-3.5 shrink-0" aria-hidden />
437-
{label}
438-
</span>
439-
);
440-
})}
441-
</div>
442-
);
443-
}

apps/web/src/components/usage/usageProviders.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,5 @@ export const PROVIDER_PRESENTATION = {
2626
},
2727
} satisfies Record<UsageProviderKind, UsageProviderPresentation>;
2828

29-
/** The chart layers every series from zero, so order only controls how it is read. */
29+
/** Stable provider reading order across charts, summaries, tables, and hover rows. */
3030
export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[];

packages/shared/src/usageMerge.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ describe("mergeUsage", () => {
138138
"claude",
139139
"codex",
140140
]);
141+
expect(merged.sessions).toBe(2);
142+
expect(
143+
Object.fromEntries(
144+
merged.providers.map((provider) => [provider.provider, provider.sessions]),
145+
),
146+
).toEqual({ claude: 1, codex: 1 });
141147
});
142148

143149
it("excludes an environment reporting an older contract version", () => {
@@ -248,6 +254,7 @@ describe("mergeUsage", () => {
248254
);
249255

250256
expect(merged.sessions).toBe(1);
257+
expect(merged.providers[0]?.sessions).toBe(1);
251258
});
252259

253260
it("returns empty totals with no environments", () => {

0 commit comments

Comments
 (0)