Skip to content

Commit 4b69cf3

Browse files
committed
feat(web): redesign usage insights
1 parent a323f96 commit 4b69cf3

8 files changed

Lines changed: 350 additions & 328 deletions

File tree

apps/web/src/components/WorkspaceBreadcrumb.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ export function WorkspaceBreadcrumbItem({
4545
);
4646
}
4747

48-
export function WorkspaceBreadcrumbSeparator() {
48+
export function WorkspaceBreadcrumbSeparator({ className }: { readonly className?: string }) {
4949
return (
50-
<li aria-hidden="true" className="flex shrink-0 items-center text-icon-muted">
50+
<li aria-hidden="true" className={cn("flex shrink-0 items-center text-icon-muted", className)}>
5151
/
5252
</li>
5353
);

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariant
1717
function ToggleGroup({
1818
className,
1919
variant = "default",
20-
size = "default",
20+
size = variant === "segmented" ? "segmented" : "default",
2121
orientation = "horizontal",
2222
children,
2323
...props
@@ -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: 211 additions & 244 deletions
Large diffs are not rendered by default.

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

Lines changed: 88 additions & 64 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,44 @@ 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.
224+
// The scale tops out at the largest single provider-period, not the sum:
225+
// layered series each measure from zero, so a combined peak would leave
226+
// the plot permanently half empty.
236227
const peak = columns.reduce(
237228
(max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),
238229
0,
239230
);
240231
const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);
241232
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.
233+
// Leave room above the top gridline so the constant-width stroke is not
234+
// clipped when a series reaches the peak.
244235
const toY = (value: number) =>
245236
max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
246237

247238
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-
})),
239+
const line = curvePath(
240+
smoothCurve(
241+
columns.map((column, periodIndex) => ({
242+
x: periodIndex * step,
243+
y: toY(column.bands[providerIndex]?.value ?? 0),
244+
})),
245+
),
253246
);
254-
const line = curvePath(curve, "M");
255247
return {
256248
provider,
257249
total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0),
@@ -260,30 +252,76 @@ export function UsageProviderChart({
260252
};
261253
});
262254

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 };
255+
// Paint the heavier series first so the lighter one is not buried.
256+
return {
257+
paths: built.toSorted((a, b) => b.total - a.total),
258+
series: columns,
259+
stepX: step,
260+
ticks: tickValues,
261+
toY,
262+
};
269263
}, [byPeriod, metric, periods]);
270264

271265
const format = metric === "tokens" ? formatTokens : formatUsd;
272266

267+
const positionTooltip = useCallback(() => {
268+
const plot = plotRef.current;
269+
const tooltip = tooltipRef.current;
270+
const hoverPosition = hoverPositionRef.current;
271+
if (plot === null || tooltip === null || hoverPosition === null) return;
272+
273+
const gap = 12;
274+
const tooltipWidth = tooltip.offsetWidth;
275+
const tooltipHeight = tooltip.offsetHeight;
276+
const plotWidth = plot.clientWidth;
277+
const plotHeight = plot.clientHeight;
278+
const preferredLeft =
279+
hoverPosition.x + gap + tooltipWidth <= plotWidth
280+
? hoverPosition.x + gap
281+
: hoverPosition.x - gap - tooltipWidth;
282+
const preferredTop =
283+
hoverPosition.y + gap + tooltipHeight <= plotHeight
284+
? hoverPosition.y + gap
285+
: hoverPosition.y - gap - tooltipHeight;
286+
const left = Math.min(Math.max(0, preferredLeft), Math.max(0, plotWidth - tooltipWidth));
287+
const top = Math.min(Math.max(0, preferredTop), Math.max(0, plotHeight - tooltipHeight));
288+
plot.style.setProperty("--usage-tooltip-left", `${left}px`);
289+
plot.style.setProperty("--usage-tooltip-top", `${top}px`);
290+
}, []);
291+
292+
useLayoutEffect(() => {
293+
if (hoverIndex === null) return;
294+
positionTooltip();
295+
296+
const plot = plotRef.current;
297+
const tooltip = tooltipRef.current;
298+
if (plot === null || tooltip === null || typeof ResizeObserver === "undefined") return;
299+
300+
const observer = new ResizeObserver(positionTooltip);
301+
observer.observe(plot);
302+
observer.observe(tooltip);
303+
return () => observer.disconnect();
304+
}, [hoverIndex, positionTooltip]);
305+
273306
const handleMove = useCallback(
274307
(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;
308+
const plot = plotRef.current;
309+
if (plot === null || periods.length === 0) return;
310+
const bounds = plot.getBoundingClientRect();
311+
if (bounds.width === 0) return;
312+
const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left));
313+
const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top));
314+
const fraction = localX / bounds.width;
278315
const index = Math.round(fraction * (periods.length - 1));
316+
hoverPositionRef.current = { x: localX, y: localY };
317+
positionTooltip();
279318
setHoverIndex(Math.min(periods.length - 1, Math.max(0, index)));
280319
},
281-
[periods.length],
320+
[periods.length, positionTooltip],
282321
);
283322

284323
const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];
285324
const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];
286-
const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100;
287325
const formatPeriod = (period: string) =>
288326
resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);
289327
const formatTooltipPeriod = (period: string) =>
@@ -311,7 +349,10 @@ export function UsageProviderChart({
311349
ref={plotRef}
312350
className="relative h-56 flex-1"
313351
onMouseMove={handleMove}
314-
onMouseLeave={() => setHoverIndex(null)}
352+
onMouseLeave={() => {
353+
hoverPositionRef.current = null;
354+
setHoverIndex(null);
355+
}}
315356
>
316357
<svg
317358
className="h-full w-full"
@@ -373,10 +414,11 @@ export function UsageProviderChart({
373414

374415
{hoveredPeriod === undefined ? null : (
375416
<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"
417+
ref={tooltipRef}
418+
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"
377419
style={{
378-
left: `${hoverLeft}%`,
379-
transform: hoverLeft > 60 ? "translateX(-100%)" : "translateX(0)",
420+
left: "var(--usage-tooltip-left, 0px)",
421+
top: "var(--usage-tooltip-top, 0px)",
380422
}}
381423
>
382424
<div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div>
@@ -423,21 +465,3 @@ export function UsageProviderChart({
423465
</div>
424466
);
425467
}
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)