authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-19 16:10:47-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-19 16:10:47-04:00
log8c85b4933fb7202c2d48e4c8f8ea0e1cfdbcdc29
treed59ec08ee4022bf3c2a09731548caf70494277ab
parent3c0665543f0e31db1e3425d4fcf9f4aea295a8ce
signature Signed by PGP key B5690EEEBB952194

feat(web): redesign usage insights (#7147)


11 files changed, 434 insertions(+), 346 deletions(-)

apps/web/src/components/WorkspaceBreadcrumb.tsx+2-2
...@@ -45,9 +45,9 @@ export function WorkspaceBreadcrumbItem({...@@ -45,9 +45,9 @@ export function WorkspaceBreadcrumbItem({
45 );45 );
46}46}
4747
48export function WorkspaceBreadcrumbSeparator() {48export function WorkspaceBreadcrumbSeparator({ className }: { readonly className?: string }) {
49 return (49 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)}>
51 /51 /
52 </li>52 </li>
53 );53 );
apps/web/src/components/WorkspacePageContainer.tsx created+29
...@@ -0,0 +1,29 @@
1import type { ComponentPropsWithoutRef } from "react";
2
3import { cn } from "../lib/utils";
4
5export type WorkspacePageWidth = "readable" | "wide" | "expanded";
6
7const WIDTH_CLASS: Record<WorkspacePageWidth, string> = {
8 readable: "max-w-4xl",
9 wide: "max-w-5xl",
10 expanded: "max-w-6xl",
11};
12
13/** Shared content frame for workspace pages. */
14export function WorkspacePageContainer({
15 width = "readable",
16 className,
17 ...props
18}: ComponentPropsWithoutRef<"div"> & { readonly width?: WorkspacePageWidth }) {
19 return (
20 <div
21 className={cn(
22 "mx-auto flex w-full flex-col gap-6 px-5 pt-6 pb-12 sm:px-6",
23 WIDTH_CLASS[width],
24 className,
25 )}
26 {...props}
27 />
28 );
29}
apps/web/src/components/settings/settingsLayout.tsx+4-17
...@@ -12,17 +12,10 @@ import {...@@ -12,17 +12,10 @@ import {
12} from "react";12} from "react";
1313
14import { cn } from "../../lib/utils";14import { cn } from "../../lib/utils";
15import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer";
15import { Button } from "../ui/button";16import { Button } from "../ui/button";
16import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";17import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1718
18type SettingsPageWidth = "readable" | "wide" | "expanded";
19
20const SETTINGS_PAGE_WIDTH_CLASS: Record<SettingsPageWidth, string> = {
21 readable: "max-w-4xl",
22 wide: "max-w-5xl",
23 expanded: "max-w-6xl",
24};
25
26interface SettingsSearchTargetContextValue {19interface SettingsSearchTargetContextValue {
27 readonly targetId: string | null;20 readonly targetId: string | null;
28 readonly onTargetHandled: () => void;21 readonly onTargetHandled: () => void;
...@@ -245,7 +238,7 @@ export function SettingsPageContainer({...@@ -245,7 +238,7 @@ export function SettingsPageContainer({
245}: {238}: {
246 children: ReactNode;239 children: ReactNode;
247 className?: string;240 className?: string;
248 width?: SettingsPageWidth;241 width?: WorkspacePageWidth;
249}) {242}) {
250 const navigate = useNavigate();243 const navigate = useNavigate();
251 const hash = useLocation({ select: (location) => location.hash });244 const hash = useLocation({ select: (location) => location.hash });
...@@ -260,15 +253,9 @@ export function SettingsPageContainer({...@@ -260,15 +253,9 @@ export function SettingsPageContainer({
260 className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto [--topbar-scroll-fade-height:1.5rem] sm:[--topbar-scroll-fade-height:1.5rem]"253 className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto [--topbar-scroll-fade-height:1.5rem] sm:[--topbar-scroll-fade-height:1.5rem]"
261 data-settings-page-scroll254 data-settings-page-scroll
262 >255 >
263 <div256 <WorkspacePageContainer width={width} className={cn("gap-12", className)}>
264 className={cn(
265 "mx-auto flex w-full flex-col gap-12 px-5 pt-6 pb-12 sm:px-6",
266 SETTINGS_PAGE_WIDTH_CLASS[width],
267 className,
268 )}
269 >
270 {children}257 {children}
271 </div>258 </WorkspacePageContainer>
272 </div>259 </div>
273 </SettingsSearchTargetProvider>260 </SettingsSearchTargetProvider>
274 );261 );
apps/web/src/components/ui/toggle-group.test.tsx created+19
...@@ -0,0 +1,19 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { Toggle, ToggleGroup } from "./toggle-group";
5
6describe("toggle group segmented defaults", () => {
7 it("derives the segmented item size from the variant", () => {
8 const html = renderToStaticMarkup(
9 <ToggleGroup variant="segmented" value={["a"]}>
10 <Toggle value="a">A</Toggle>
11 </ToggleGroup>,
12 );
13
14 expect(html.match(/data-size="segmented"/g)).toHaveLength(2);
15 expect(html).toContain("h-6");
16 expect(html).toContain("dark:hover:bg-input/32");
17 expect(html).toContain("dark:data-pressed:bg-input/72");
18 });
19});
apps/web/src/components/ui/toggle-group.tsx+8-6
...@@ -17,7 +17,7 @@ const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariant...@@ -17,7 +17,7 @@ const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariant
17function ToggleGroup({17function ToggleGroup({
18 className,18 className,
19 variant = "default",19 variant = "default",
20 size = "default",20 size = variant === "segmented" ? "segmented" : "default",
21 orientation = "horizontal",21 orientation = "horizontal",
22 children,22 children,
23 ...props23 ...props
...@@ -30,11 +30,13 @@ function ToggleGroup({...@@ -30,11 +30,13 @@ function ToggleGroup({
30 orientation === "horizontal"30 orientation === "horizontal"
31 ? "*:pointer-coarse:after:min-w-auto"31 ? "*:pointer-coarse:after:min-w-auto"
32 : "*:pointer-coarse:after:min-h-auto",32 : "*:pointer-coarse:after:min-h-auto",
33 variant === "default"33 variant === "segmented"
34 ? "gap-0.5"34 ? "gap-0.5 rounded-lg bg-input/40 p-0.5"
35 : orientation === "horizontal"35 : variant === "default"
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"36 ? "gap-0.5"
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",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",
38 className,40 className,
39 )}41 )}
40 data-size={size}42 data-size={size}
apps/web/src/components/ui/toggle.tsx+4
...@@ -18,6 +18,8 @@ const toggleVariants = cva(...@@ -18,6 +18,8 @@ const toggleVariants = cva(
18 "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",18 "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",
19 default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8",19 default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8",
20 lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9",20 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)]",
21 sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7",23 sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7",
22 xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md",24 xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md",
23 },25 },
...@@ -27,6 +29,8 @@ const toggleVariants = cva(...@@ -27,6 +29,8 @@ const toggleVariants = cva(
27 "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",29 "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",
28 outline:30 outline:
29 "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",31 "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 dark:hover:bg-input/32 dark:data-pressed:bg-input/72",
30 },34 },
31 },35 },
32 },36 },
apps/web/src/components/usage/UsagePage.tsx+218-245
...@@ -19,12 +19,19 @@ import {...@@ -19,12 +19,19 @@ import {
19 formatUsd,19 formatUsd,
20 makeWindow,20 makeWindow,
21} from "@t3tools/shared/usageFormat";21} from "@t3tools/shared/usageFormat";
22import { ScrollArea } from "../ui/scroll-area";
23import { Button } from "../ui/button";22import { Button } from "../ui/button";
23import { ScrollArea } from "../ui/scroll-area";
24import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
24import { SidebarInset } from "../ui/sidebar";25import { SidebarInset } from "../ui/sidebar";
25import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb";26import { Toggle, ToggleGroup } from "../ui/toggle-group";
26import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar";27import {
27import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";28 WorkspaceBreadcrumb,
29 WorkspaceBreadcrumbItem,
30 WorkspaceBreadcrumbSeparator,
31} from "../WorkspaceBreadcrumb";
32import { WorkspacePageContainer } from "../WorkspacePageContainer";
33import { WorkspacePageHeader } from "../WorkspacePageHeader";
34import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
28import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders";35import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders";
2936
30const WINDOW_OPTIONS = [37const WINDOW_OPTIONS = [
...@@ -68,21 +75,6 @@ export function UsagePage() {...@@ -68,21 +75,6 @@ export function UsagePage() {
68 [isPast24Hours, merged.daily, merged.hourly],75 [isPast24Hours, merged.daily, merged.hourly],
69 );76 );
7077
71 // Ranked by whatever the toggle is showing, so the bars always descend.
72 const orderedProviders = useMemo(
73 () =>
74 merged.providers.toSorted((a, b) =>
75 metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens,
76 ),
77 [merged.providers, metric],
78 );
79
80 const activePeriods = (isPast24Hours ? merged.hourly : merged.daily).filter(
81 (period) => period.totalTokens > 0,
82 ).length;
83 const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods;
84 const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens;
85 const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput;
86 const selectWindow = (days: number) => {78 const selectWindow = (days: number) => {
87 setWindowSelection({79 setWindowSelection({
88 days,80 days,
...@@ -102,78 +94,113 @@ export function UsagePage() {...@@ -102,78 +94,113 @@ export function UsagePage() {
102 setWindowSelection({ days: windowDays, window: nextWindow });94 setWindowSelection({ days: windowDays, window: nextWindow });
103 }95 }
104 };96 };
97 const windowLabel =
98 isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined
99 ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
100 : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`;
101 const topbarContent = (
102 <div className="flex w-full min-w-0 items-center gap-3">
103 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0">
104 <WorkspaceBreadcrumbItem current>
105 <h1>Usage</h1>
106 </WorkspaceBreadcrumbItem>
107 <WorkspaceBreadcrumbSeparator className="hidden md:flex" />
108 <WorkspaceBreadcrumbItem className="hidden min-w-0 shrink md:flex">
109 <span className="truncate">{windowLabel}</span>
110 </WorkspaceBreadcrumbItem>
111 </WorkspaceBreadcrumb>
112 <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 lg:flex">
113 <ToggleGroup
114 aria-label="Usage metric"
115 variant="segmented"
116 value={[metric]}
117 onValueChange={(next) => {
118 const value = next[0];
119 if (value === "cost" || value === "tokens") setMetric(value);
120 }}
121 >
122 {(["cost", "tokens"] as const).map((option) => (
123 <Toggle key={option} value={option}>
124 {option === "cost" ? "Cost" : "Tokens"}
125 </Toggle>
126 ))}
127 </ToggleGroup>
128 <ToggleGroup
129 aria-label="Usage period"
130 variant="segmented"
131 value={[String(windowDays)]}
132 onValueChange={(next) => {
133 const value = next[0];
134 if (value) selectWindow(Number(value));
135 }}
136 >
137 {WINDOW_OPTIONS.map((option) => (
138 <Toggle key={option.days} value={String(option.days)}>
139 {option.label}
140 </Toggle>
141 ))}
142 </ToggleGroup>
143 <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost">
144 <RefreshCwIcon className="size-3.5" />
145 </Button>
146 </div>
147 <div className="ms-auto flex min-w-0 items-center justify-end gap-1 lg:hidden">
148 <Select
149 value={metric}
150 onValueChange={(value) => {
151 if (value === "cost" || value === "tokens") setMetric(value);
152 }}
153 >
154 <SelectTrigger
155 aria-label="Usage metric"
156 size="compact"
157 variant="ghost"
158 className="w-auto min-w-0"
159 >
160 <SelectValue>{metric === "cost" ? "Cost" : "Tokens"}</SelectValue>
161 </SelectTrigger>
162 <SelectPopup align="end" alignItemWithTrigger={false}>
163 <SelectItem value="cost">Cost</SelectItem>
164 <SelectItem value="tokens">Tokens</SelectItem>
165 </SelectPopup>
166 </Select>
167 <Select value={String(windowDays)} onValueChange={(value) => selectWindow(Number(value))}>
168 <SelectTrigger
169 aria-label="Usage period"
170 size="compact"
171 variant="ghost"
172 className="w-auto min-w-0"
173 >
174 <SelectValue>
175 {WINDOW_OPTIONS.find((option) => option.days === windowDays)?.label}
176 </SelectValue>
177 </SelectTrigger>
178 <SelectPopup align="end" alignItemWithTrigger={false}>
179 {WINDOW_OPTIONS.map((option) => (
180 <SelectItem key={option.days} value={String(option.days)}>
181 {option.label}
182 </SelectItem>
183 ))}
184 </SelectPopup>
185 </Select>
186 <Button onClick={refreshWindow} aria-label="Refresh usage" size="icon-sm" variant="ghost">
187 <RefreshCwIcon className="size-3.5" />
188 </Button>
189 </div>
190 </div>
191 );
105192
106 return (193 return (
107 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">194 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
108 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">195 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
109 {!isElectron && (196 <WorkspacePageHeader electron={isElectron}>{topbarContent}</WorkspacePageHeader>
110 <header
111 className={cn(
112 "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
113 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
114 )}
115 >
116 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb">
117 <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem>
118 </WorkspaceBreadcrumb>
119 </header>
120 )}
121
122 {isElectron && (
123 <div
124 className={cn(
125 "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",
126 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
127 )}
128 >
129 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb">
130 <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem>
131 </WorkspaceBreadcrumb>
132 </div>
133 )}
134197
135 <ScrollArea className="min-h-0 flex-1">198 <ScrollArea className="min-h-0 flex-1">
136 <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-6">199 <WorkspacePageContainer width="wide">
137 <div className="flex flex-wrap items-center justify-between gap-4">
138 <p className="text-sm text-muted-foreground">
139 {isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined
140 ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
141 : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`}
142 </p>
143 <div className="flex items-center gap-2">
144 <div className="flex rounded-md border border-border">
145 {WINDOW_OPTIONS.map((option) => (
146 <button
147 key={option.days}
148 type="button"
149 aria-pressed={option.days === windowDays}
150 onClick={() => selectWindow(option.days)}
151 className={cn(
152 "relative cursor-pointer px-3 py-1.5 text-xs outline-none first:rounded-s-[calc(var(--radius-md)-1px)] last:rounded-e-[calc(var(--radius-md)-1px)] focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
153 option.days === windowDays
154 ? "bg-muted text-foreground"
155 : "text-muted-foreground hover:text-foreground",
156 )}
157 >
158 {option.label}
159 </button>
160 ))}
161 </div>
162 <Button
163 size="icon"
164 variant="outline"
165 onClick={refreshWindow}
166 aria-label="Refresh usage"
167 >
168 <RefreshCwIcon className="size-3.5" />
169 </Button>
170 </div>
171 </div>
172
173 {settling ? (200 {settling ? (
174 <>201 <>
175 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}202 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}
176 <UsageSkeleton resolution={isPast24Hours ? "hour" : "day"} />203 <UsageSkeleton />
177 </>204 </>
178 ) : (205 ) : (
179 <>206 <>
...@@ -183,88 +210,71 @@ export function UsagePage() {...@@ -183,88 +210,71 @@ export function UsagePage() {
183 staleEnvironments={merged.staleEnvironments}210 staleEnvironments={merged.staleEnvironments}
184 />211 />
185212
186 {/* Cost first: the financial answer, then the provider split. */}213 <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
187 <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">214 <div className="flex min-w-0 flex-col gap-5">
188 {/* The summary follows the chart toggle, so the headline and the
189 series are always reading the same units. */}
190 <div className="flex flex-col gap-5">
191 <div className="flex flex-col gap-1">215 <div className="flex flex-col gap-1">
192 <span className="text-xs tracking-wide text-muted-foreground uppercase">
193 {metric === "cost" ? "Raw token cost" : "Processed tokens"}
194 </span>
195 <span className="text-4xl font-semibold text-foreground tabular-nums">216 <span className="text-4xl font-semibold text-foreground tabular-nums">
196 {metric === "cost"217 {metric === "cost"
197 ? `${formatUsd(merged.costUsd)}*`218 ? formatUsd(merged.costUsd)
198 : formatTokens(merged.totalTokens)}219 : formatTokens(merged.totalTokens)}
199 </span>220 </span>
200 <span className="text-xs text-muted-foreground">221 <span className="text-xs text-muted-foreground">
201 {metric === "cost"222 {metric === "cost"
202 ? "* if billed at full API rate"223 ? `${formatCount(merged.sessions)} sessions · API estimate`
203 : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`}224 : `${formatCount(merged.sessions)} sessions`}
204 </span>225 </span>
205 </div>226 </div>
206227
207 {orderedProviders.map((provider) => {228 {PROVIDER_ORDER.map((provider) => {
208 const share = metric === "cost" ? provider.costShare : provider.tokenShare;229 const totals = merged.providers.find((entry) => entry.provider === provider);
230 const share =
231 metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0);
232 const providerSessions = totals?.sessions ?? 0;
233 const sessionLabel = `${formatCount(providerSessions)} ${
234 providerSessions === 1 ? "session" : "sessions"
235 }`;
209 return (236 return (
210 <div key={provider.provider} className="flex flex-col gap-1.5">237 <div key={provider} className="flex flex-col gap-1">
211 <div className="flex items-baseline justify-between">238 <div className="flex items-baseline justify-between gap-4">
212 <span className="flex items-center gap-2 text-sm text-foreground">239 <span className="flex min-w-0 items-center gap-2 text-sm text-foreground">
213 <ProviderMark provider={provider.provider} className="size-4" />240 <span
214 {PROVIDER_PRESENTATION[provider.provider].label}241 aria-hidden
242 className="size-2 shrink-0 rounded-full"
243 style={{
244 backgroundColor: PROVIDER_PRESENTATION[provider].color,
245 }}
246 />
247 <ProviderMark provider={provider} className="size-4" />
248 <span className="flex min-w-0 items-baseline gap-1.5">
249 <span className="truncate">
250 {PROVIDER_PRESENTATION[provider].label}
251 </span>
252 <span className="shrink-0 whitespace-nowrap text-[11px] text-muted-foreground tabular-nums">
253 {sessionLabel}
254 </span>
255 </span>
215 </span>256 </span>
216 <span className="text-sm text-foreground tabular-nums">257 <span className="shrink-0 text-sm font-medium text-foreground tabular-nums">
217 {metric === "cost"258 {metric === "cost"
218 ? formatUsd(provider.costUsd)259 ? formatUsd(totals?.costUsd ?? 0)
219 : formatTokens(provider.totalTokens)}260 : formatTokens(totals?.totalTokens ?? 0)}
220 </span>261 </span>
221 </div>262 </div>
222 <div className="h-1 w-full overflow-hidden rounded-full bg-muted">
223 <div
224 className="h-full"
225 style={{
226 width: `${(share * 100).toFixed(1)}%`,
227 backgroundColor: PROVIDER_PRESENTATION[provider.provider].color,
228 }}
229 />
230 </div>
231 <span className="text-xs text-muted-foreground">263 <span className="text-xs text-muted-foreground">
232 {metric === "cost"264 {metric === "cost"
233 ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens`265 ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens`
234 : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`}266 : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`}
235 </span>267 </span>
236 </div>268 </div>
237 );269 );
238 })}270 })}
239 </div>271 </div>
240272
241 <div className="flex flex-col gap-3">273 <div className="flex min-w-0 flex-col gap-3">
242 <div className="flex flex-wrap items-center justify-between gap-3">274 <h2 className="text-sm font-medium text-foreground">
243 <h2 className="text-sm font-medium text-foreground">275 {isPast24Hours ? "Hourly" : "Daily"}{" "}
244 {isPast24Hours ? "Hourly" : "Daily"}{" "}276 {metric === "tokens" ? "processed tokens" : "cost"}
245 {metric === "tokens" ? "processed tokens" : "cost"}277 </h2>
246 </h2>
247 <div className="flex items-center gap-4">
248 <div className="flex overflow-hidden rounded-md border border-border">
249 {(["cost", "tokens"] as const).map((option) => (
250 <button
251 key={option}
252 type="button"
253 onClick={() => setMetric(option)}
254 className={cn(
255 "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase",
256 option === metric
257 ? "bg-muted text-foreground"
258 : "text-muted-foreground hover:text-foreground",
259 )}
260 >
261 {option}
262 </button>
263 ))}
264 </div>
265 <UsageChartLegend />
266 </div>
267 </div>
268 <UsageProviderChart278 <UsageProviderChart
269 days={days}279 days={days}
270 daily={merged.daily}280 daily={merged.daily}
...@@ -278,63 +288,46 @@ export function UsagePage() {...@@ -278,63 +288,46 @@ export function UsagePage() {
278 </div>288 </div>
279 </section>289 </section>
280290
281 <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5">291 <section className="flex flex-col gap-2">
282 <Metric292 <h2 className="text-sm font-medium text-foreground">Totals</h2>
283 label="Processed tokens"293 <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">
284 value={formatTokens(merged.totalTokens)}294 <Metric label="Processed tokens" value={formatTokens(merged.totalTokens)} />
285 detail={`${formatTokens(periodAverage)} per active ${isPast24Hours ? "hour" : "day"}`}295 <Metric label="Cached input" value={formatTokens(merged.cachedInputTokens)} />
286 />296 <Metric
287 <Metric297 label="Uncached input"
288 label="Cached input"298 value={formatTokens(merged.uncachedInputTokens)}
289 value={formatTokens(merged.cachedInputTokens)}299 />
290 detail={`${formatPercent(cachedShare)} of observed input`}300 <Metric label="Output" value={formatTokens(merged.outputTokens)} />
291 />301 <Metric
292 <Metric302 label="Cache savings"
293 label="Uncached input"303 value={formatUsd(merged.costQuality.cacheSavingsUsd)}
294 value={formatTokens(merged.uncachedInputTokens)}304 />
295 detail={`${formatTokens(merged.cacheCreationTokens)} cache writes`}305 </div>
296 />
297 <Metric
298 label="Output"
299 value={formatTokens(merged.outputTokens)}
300 detail={`includes ${formatTokens(merged.reasoningTokens)} reasoning`}
301 />
302 <Metric
303 label="Cache savings"
304 value={formatUsd(merged.costQuality.cacheSavingsUsd)}
305 detail={
306 merged.costUsd > 0
307 ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost`
308 : "vs full input rates"
309 }
310 />
311 </section>306 </section>
312307
313 <section className="flex flex-col gap-3">308 <section className="flex flex-col gap-3">
314 <div className="flex items-center justify-between gap-3">309 <div className="flex items-center justify-between gap-3">
315 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>310 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>
316 <div className="flex overflow-hidden rounded-md border border-border">311 <ToggleGroup
312 aria-label="Usage breakdown"
313 variant="segmented"
314 value={[breakdown]}
315 onValueChange={(next) => {
316 const value = next[0];
317 if (value === "model" || value === "time") setBreakdown(value);
318 }}
319 >
317 {(320 {(
318 [321 [
319 { value: "model", label: "model" },322 { value: "model", label: "Model" },
320 { value: "time", label: isPast24Hours ? "hour" : "day" },323 { value: "time", label: isPast24Hours ? "Hour" : "Day" },
321 ] as const324 ] as const
322 ).map((option) => (325 ).map((option) => (
323 <button326 <Toggle key={option.value} value={option.value}>
324 key={option.value}
325 type="button"
326 onClick={() => setBreakdown(option.value)}
327 className={cn(
328 "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase",
329 option.value === breakdown
330 ? "bg-muted text-foreground"
331 : "text-muted-foreground hover:text-foreground",
332 )}
333 >
334 {option.label}327 {option.label}
335 </button>328 </Toggle>
336 ))}329 ))}
337 </div>330 </ToggleGroup>
338 </div>331 </div>
339332
340 {breakdown === "model" ? (333 {breakdown === "model" ? (
...@@ -358,7 +351,7 @@ export function UsagePage() {...@@ -358,7 +351,7 @@ export function UsagePage() {
358 merged.models.map((model) => (351 merged.models.map((model) => (
359 <tr352 <tr
360 key={`${model.provider}:${model.model}`}353 key={`${model.provider}:${model.model}`}
361 className="border-b border-border/50"354 className="border-b border-border/50 transition-colors hover:bg-muted/50"
362 >355 >
363 <td className="py-2 text-foreground">356 <td className="py-2 text-foreground">
364 <span className="flex items-center gap-2">357 <span className="flex items-center gap-2">
...@@ -405,7 +398,7 @@ export function UsagePage() {...@@ -405,7 +398,7 @@ export function UsagePage() {
405 breakdownPeriods.map((period) => (398 breakdownPeriods.map((period) => (
406 <tr399 <tr
407 key={"hourStart" in period ? period.hourStart : period.day}400 key={"hourStart" in period ? period.hourStart : period.day}
408 className="border-b border-border/50"401 className="border-b border-border/50 transition-colors hover:bg-muted/50"
409 >402 >
410 <td className="py-2 text-foreground">403 <td className="py-2 text-foreground">
411 {"hourStart" in period404 {"hourStart" in period
...@@ -435,7 +428,7 @@ export function UsagePage() {...@@ -435,7 +428,7 @@ export function UsagePage() {
435 </section>428 </section>
436 </>429 </>
437 )}430 )}
438 </div>431 </WorkspacePageContainer>
439 </ScrollArea>432 </ScrollArea>
440 </div>433 </div>
441 </SidebarInset>434 </SidebarInset>
...@@ -454,20 +447,11 @@ function ProviderMark({...@@ -454,20 +447,11 @@ function ProviderMark({
454 return <Mark className={cn("shrink-0", className)} aria-hidden />;447 return <Mark className={cn("shrink-0", className)} aria-hidden />;
455}448}
456449
457function Metric({450function Metric({ label, value }: { readonly label: string; readonly value: string }) {
458 label,
459 value,
460 detail,
461}: {
462 readonly label: string;
463 readonly value: string;
464 readonly detail: string;
465}) {
466 return (451 return (
467 <div className="flex flex-col gap-0.5 bg-background px-4 py-3">452 <div className="flex min-w-0 flex-col gap-0.5">
468 <span className="text-xs text-muted-foreground">{label}</span>453 <span className="text-xs text-muted-foreground">{label}</span>
469 <span className="text-lg text-foreground tabular-nums">{value}</span>454 <span className="text-base font-medium text-foreground tabular-nums">{value}</span>
470 <span className="text-xs text-muted-foreground">{detail}</span>
471 </div>455 </div>
472 );456 );
473}457}
...@@ -571,70 +555,59 @@ function UsageDeviceStrip({...@@ -571,70 +555,59 @@ function UsageDeviceStrip({
571 );555 );
572}556}
573557
574/** Deterministic bar heights (each unique: they double as keys). */
575const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67];
576
577/**558/**
578 * Static stand-in with the loaded page's shape: headline, provider split,559 * Static stand-in with the loaded page's shape. No shimmer; blocks fill in
579 * chart and metrics strip. No shimmer; blocks fill in exactly once when the560 * exactly once when the last device answers.
580 * last device answers.
581 */561 */
582function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) {562function UsageSkeleton() {
583 return (563 return (
584 <>564 <>
585 <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">565 <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
586 <div className="flex flex-col gap-5">566 <div className="flex flex-col gap-5">
587 <div className="flex flex-col gap-1">567 <div className="flex flex-col gap-1">
588 <span className="text-xs tracking-wide text-muted-foreground uppercase">568 <div className="h-10 w-36 rounded-sm bg-muted" />
589 Raw token cost569 <div className="h-4 w-32 rounded-sm bg-muted" />
590 </span>
591 <div className="my-1.5 h-8 w-36 rounded-sm bg-muted" />
592 <div className="h-3 w-28 rounded-sm bg-muted" />
593 </div>570 </div>
594
595 {PROVIDER_ORDER.map((provider) => (571 {PROVIDER_ORDER.map((provider) => (
596 <div key={provider} className="flex flex-col gap-1.5">572 <div key={provider} className="flex flex-col gap-1">
597 <div className="flex items-center justify-between">573 <div className="flex min-h-5 items-center justify-between gap-4">
598 <span className="flex items-center gap-2 text-sm text-foreground">574 <span className="flex items-center gap-2">
575 <span
576 aria-hidden
577 className="size-2 shrink-0 rounded-full"
578 style={{ backgroundColor: PROVIDER_PRESENTATION[provider].color }}
579 />
599 <ProviderMark provider={provider} className="size-4" />580 <ProviderMark provider={provider} className="size-4" />
600 {PROVIDER_PRESENTATION[provider].label}581 <div className="h-3.5 w-20 rounded-sm bg-muted" />
601 </span>582 </span>
602 <div className="h-3.5 w-14 rounded-sm bg-muted" />583 <div className="h-3.5 w-14 rounded-sm bg-muted" />
603 </div>584 </div>
604 <div className="h-1 w-full rounded-full bg-muted" />585 <div className="h-4 w-36 rounded-sm bg-muted" />
605 <div className="h-3 w-36 rounded-sm bg-muted" />
606 </div>586 </div>
607 ))}587 ))}
608 </div>588 </div>
609589
610 <div className="flex flex-col gap-3">590 <div className="flex flex-col gap-3">
611 <h2 className="py-1 text-sm font-medium text-foreground">591 <div className="h-5 w-24 rounded-sm bg-muted" />
612 {resolution === "hour" ? "Hourly" : "Daily"} cost592 <div className="flex flex-col gap-1">
613 </h2>593 <div className="ml-16 h-56 rounded-sm bg-muted/35" />
614 {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a594 <div className="ml-16 h-3 rounded-sm bg-muted/35" />
615 relayout when the real chart swaps in. */}
616 <div className="flex h-56 items-end gap-1 pl-16">
617 {SKELETON_BAR_HEIGHTS.map((height) => (
618 <div
619 key={height}
620 className="flex-1 rounded-sm bg-muted"
621 style={{ height: `${height}%` }}
622 />
623 ))}
624 </div>595 </div>
625 </div>596 </div>
626 </section>597 </section>
627598
628 <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5">599 <section className="flex flex-col gap-2">
629 {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map(600 <h2 className="text-sm font-medium text-foreground">Totals</h2>
630 (label) => (601 <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">
631 <div key={label} className="flex flex-col gap-0.5 bg-background px-4 py-3">602 {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map(
632 <span className="text-xs text-muted-foreground">{label}</span>603 (label) => (
633 <div className="my-1 h-5 w-16 rounded-sm bg-muted" />604 <div key={label} className="flex flex-col gap-0.5">
634 <div className="h-3 w-24 rounded-sm bg-muted" />605 <span className="text-xs text-muted-foreground">{label}</span>
635 </div>606 <div className="my-0.5 h-4 w-16 rounded-sm bg-muted" />
636 ),607 </div>
637 )}608 ),
609 )}
610 </div>
638 </section>611 </section>
639 </>612 </>
640 );613 );
apps/web/src/components/usage/UsageProviderChart.tsx+88-64
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { UsageProviderKind } from "@t3tools/contracts";1import type { UsageProviderKind } from "@t3tools/contracts";
2import { useCallback, useMemo, useRef, useState } from "react";2import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
33
4import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";4import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";
5import {5import {
...@@ -68,13 +68,7 @@ function buildPeriodColumns(...@@ -68,13 +68,7 @@ function buildPeriodColumns(
68 });68 });
69}69}
7070
71/**71/** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */
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 */
78function monotoneTangents(points: readonly Point[]): readonly number[] {72function monotoneTangents(points: readonly Point[]): readonly number[] {
79 const count = points.length;73 const count = points.length;
80 if (count < 2) return [0];74 if (count < 2) return [0];
...@@ -115,7 +109,6 @@ function monotoneTangents(points: readonly Point[]): readonly number[] {...@@ -115,7 +109,6 @@ function monotoneTangents(points: readonly Point[]): readonly number[] {
115 return tangents;109 return tangents;
116}110}
117111
118/** One cubic segment of a smoothed boundary. */
119interface CurveSegment {112interface CurveSegment {
120 readonly from: Point;113 readonly from: Point;
121 readonly c1: Point;114 readonly c1: Point;
...@@ -123,7 +116,6 @@ interface CurveSegment {...@@ -123,7 +116,6 @@ interface CurveSegment {
123 readonly to: Point;116 readonly to: Point;
124}117}
125118
126/** Smoothed polyline through `points`, as explicit cubic control points. */
127function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {119function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
128 if (points.length < 2) return [];120 if (points.length < 2) return [];
129 const tangents = monotoneTangents(points);121 const tangents = monotoneTangents(points);
...@@ -144,10 +136,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {...@@ -144,10 +136,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
144 return segments;136 return segments;
145}137}
146138
147function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string {139function curvePath(segments: readonly CurveSegment[]): string {
148 const first = segments[0];140 const first = segments[0];
149 if (first === undefined) return "";141 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)}`;
151 for (const segment of segments) {143 for (const segment of segments) {
152 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)}`;144 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)}`;
153 }145 }
...@@ -179,10 +171,8 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re...@@ -179,10 +171,8 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re
179/**171/**
180 * Turns the merged daily totals into one column per day.172 * Turns the merged daily totals into one column per day.
181 *173 *
182 * Values are absolute, not cumulative: the series are layered from a shared174 * Values are absolute, not cumulative: each provider is drawn from the same
183 * zero baseline rather than stacked. A stacked chart puts whichever provider is175 * zero baseline so the chart never implies that one provider is always larger.
184 * drawn last permanently above the other, which reads as "that one is bigger"
185 * even on days where it is not.
186 *176 *
187 * The chart paths and the hover readout both consume this, so the number under177 * The chart paths and the hover readout both consume this, so the number under
188 * the cursor is by construction the number that was plotted rather than a178 * the cursor is by construction the number that was plotted rather than a
...@@ -216,42 +206,44 @@ export function UsageProviderChart({...@@ -216,42 +206,44 @@ export function UsageProviderChart({
216 );206 );
217 const [hoverIndex, setHoverIndex] = useState<number | null>(null);207 const [hoverIndex, setHoverIndex] = useState<number | null>(null);
218 const plotRef = useRef<HTMLDivElement | null>(null);208 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(() => {
221 if (periods.length === 0) {213 if (periods.length === 0) {
222 return {214 return {
223 paths: [],215 paths: [],
224 ticks: [0] as readonly number[],216 series: [] as readonly DayColumn[],
225 stepX: 0,217 stepX: 0,
218 ticks: [0] as readonly number[],
226 toY: () => VIEW_HEIGHT,219 toY: () => VIEW_HEIGHT,
227 series: [] as readonly DayColumn[],
228 };220 };
229 }221 }
230222
231 const columns = buildPeriodColumns(periods, byPeriod, metric);223 const columns = buildPeriodColumns(periods, byPeriod, metric);
232224 // The scale tops out at the largest single provider-period, not the sum:
233 // The scale tops out at the largest single provider-day, not the largest225 // layered series each measure from zero, so a combined peak would leave
234 // sum: layered series each measure from zero, so a combined peak would226 // the plot permanently half empty.
235 // leave the plot permanently half empty.
236 const peak = columns.reduce(227 const peak = columns.reduce(
237 (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),228 (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),
238 0,229 0,
239 );230 );
240 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);231 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);
241 const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1);232 const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1);
242 // Reserve a sliver above the top gridline so the series stroke, which is233 // Leave room above the top gridline so the constant-width stroke is not
243 // drawn at constant screen width, is not shaved off at a peak.234 // clipped when a series reaches the peak.
244 const toY = (value: number) =>235 const toY = (value: number) =>
245 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);236 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
246237
247 const built = PROVIDER_ORDER.map((provider, providerIndex) => {238 const built = PROVIDER_ORDER.map((provider, providerIndex) => {
248 const curve = smoothCurve(239 const line = curvePath(
249 columns.map((column, dayIndex) => ({240 smoothCurve(
250 x: dayIndex * step,241 columns.map((column, periodIndex) => ({
251 y: toY(column.bands[providerIndex]?.value ?? 0),242 x: periodIndex * step,
252 })),243 y: toY(column.bands[providerIndex]?.value ?? 0),
244 })),
245 ),
253 );246 );
254 const line = curvePath(curve, "M");
255 return {247 return {
256 provider,248 provider,
257 total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0),249 total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0),
...@@ -260,30 +252,76 @@ export function UsageProviderChart({...@@ -260,30 +252,76 @@ export function UsageProviderChart({
260 };252 };
261 });253 });
262254
263 // Paint the heavier series first so the lighter one is never buried under255 // Paint the heavier series first so the lighter one is not buried.
264 // it. The fills are faint enough that the order barely shows, but the256 return {
265 // strokes are drawn in a second pass regardless, so neither can be hidden.257 paths: built.toSorted((a, b) => b.total - a.total),
266 const ordered = [...built].sort((a, b) => b.total - a.total);258 series: columns,
267259 stepX: step,
268 return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns };260 ticks: tickValues,
261 toY,
262 };
269 }, [byPeriod, metric, periods]);263 }, [byPeriod, metric, periods]);
270264
271 const format = metric === "tokens" ? formatTokens : formatUsd;265 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
273 const handleMove = useCallback(306 const handleMove = useCallback(
274 (event: React.MouseEvent<HTMLDivElement>) => {307 (event: React.MouseEvent<HTMLDivElement>) => {
275 const bounds = plotRef.current?.getBoundingClientRect();308 const plot = plotRef.current;
276 if (bounds === undefined || bounds.width === 0 || periods.length === 0) return;309 if (plot === null || periods.length === 0) return;
277 const fraction = (event.clientX - bounds.left) / bounds.width;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;
278 const index = Math.round(fraction * (periods.length - 1));315 const index = Math.round(fraction * (periods.length - 1));
316 hoverPositionRef.current = { x: localX, y: localY };
317 positionTooltip();
279 setHoverIndex(Math.min(periods.length - 1, Math.max(0, index)));318 setHoverIndex(Math.min(periods.length - 1, Math.max(0, index)));
280 },319 },
281 [periods.length],320 [periods.length, positionTooltip],
282 );321 );
283322
284 const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];323 const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];
285 const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];324 const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];
286 const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100;
287 const formatPeriod = (period: string) =>325 const formatPeriod = (period: string) =>
288 resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);326 resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);
289 const formatTooltipPeriod = (period: string) =>327 const formatTooltipPeriod = (period: string) =>
...@@ -311,7 +349,10 @@ export function UsageProviderChart({...@@ -311,7 +349,10 @@ export function UsageProviderChart({
311 ref={plotRef}349 ref={plotRef}
312 className="relative h-56 flex-1"350 className="relative h-56 flex-1"
313 onMouseMove={handleMove}351 onMouseMove={handleMove}
314 onMouseLeave={() => setHoverIndex(null)}352 onMouseLeave={() => {
353 hoverPositionRef.current = null;
354 setHoverIndex(null);
355 }}
315 >356 >
316 <svg357 <svg
317 className="h-full w-full"358 className="h-full w-full"
...@@ -373,10 +414,11 @@ export function UsageProviderChart({...@@ -373,10 +414,11 @@ export function UsageProviderChart({
373414
374 {hoveredPeriod === undefined ? null : (415 {hoveredPeriod === undefined ? null : (
375 <div416 <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"
377 style={{419 style={{
378 left: `${hoverLeft}%`,420 left: "var(--usage-tooltip-left, 0px)",
379 transform: hoverLeft > 60 ? "translateX(-100%)" : "translateX(0)",421 top: "var(--usage-tooltip-top, 0px)",
380 }}422 }}
381 >423 >
382 <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div>424 <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div>
...@@ -423,21 +465,3 @@ export function UsageProviderChart({...@@ -423,21 +465,3 @@ export function UsageProviderChart({
423 </div>465 </div>
424 );466 );
425}467}
426
427export 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+1-1
...@@ -26,5 +26,5 @@ export const PROVIDER_PRESENTATION = {...@@ -26,5 +26,5 @@ export const PROVIDER_PRESENTATION = {
26 },26 },
27} satisfies Record<UsageProviderKind, UsageProviderPresentation>;27} 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. */
30export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[];30export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[];
packages/shared/src/usageMerge.test.ts+31
...@@ -138,6 +138,12 @@ describe("mergeUsage", () => {...@@ -138,6 +138,12 @@ describe("mergeUsage", () => {
138 "claude",138 "claude",
139 "codex",139 "codex",
140 ]);140 ]);
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 });
141 });147 });
142148
143 it("excludes an environment reporting an older contract version", () => {149 it("excludes an environment reporting an older contract version", () => {
...@@ -248,6 +254,7 @@ describe("mergeUsage", () => {...@@ -248,6 +254,7 @@ describe("mergeUsage", () => {
248 );254 );
249255
250 expect(merged.sessions).toBe(1);256 expect(merged.sessions).toBe(1);
257 expect(merged.providers[0]?.sessions).toBe(1);
251 });258 });
252259
253 it("returns empty totals with no environments", () => {260 it("returns empty totals with no environments", () => {
...@@ -257,6 +264,30 @@ describe("mergeUsage", () => {...@@ -257,6 +264,30 @@ describe("mergeUsage", () => {
257 expect(merged.hourly).toHaveLength(0);264 expect(merged.hourly).toHaveLength(0);
258 });265 });
259266
267 it("omits providers with no sessions or usage", () => {
268 const merged = mergeUsage(
269 [
270 environment(
271 "env-a",
272 summary(
273 [],
274 [
275 {
276 provider: "claude",
277 hostId: "mac",
278 homePath: "/a/.claude",
279 distinctSessions: 0,
280 },
281 ],
282 ),
283 ),
284 ],
285 USAGE_CONTRACT_VERSION,
286 );
287
288 expect(merged.providers).toEqual([]);
289 });
290
260 it("derives hourly totals without losing the daily rollup", () => {291 it("derives hourly totals without losing the daily rollup", () => {
261 const merged = mergeUsage(292 const merged = mergeUsage(
262 [293 [
packages/shared/src/usageMerge.ts+30-11
...@@ -25,6 +25,7 @@ export interface ProviderTotals {...@@ -25,6 +25,7 @@ export interface ProviderTotals {
25 readonly costUsd: number;25 readonly costUsd: number;
26 readonly totalTokens: number;26 readonly totalTokens: number;
27 readonly records: number;27 readonly records: number;
28 readonly sessions: number;
28 readonly costShare: number;29 readonly costShare: number;
29 readonly tokenShare: number;30 readonly tokenShare: number;
30}31}
...@@ -135,22 +136,29 @@ function claimSources(environments: readonly EnvironmentUsage[]): {...@@ -135,22 +136,29 @@ function claimSources(environments: readonly EnvironmentUsage[]): {
135function ownedContribution(136function ownedContribution(
136 environment: EnvironmentUsage,137 environment: EnvironmentUsage,
137 ownerByFingerprint: ReadonlyMap<string, EnvironmentId>,138 ownerByFingerprint: ReadonlyMap<string, EnvironmentId>,
138): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } {139): {
140 readonly buckets: readonly UsageBucket[];
141 readonly sessionsByProvider: ReadonlyMap<UsageProviderKind, number>;
142} {
139 const ownedProviders = new Set<UsageProviderKind>();143 const ownedProviders = new Set<UsageProviderKind>();
140 let sessions = 0;144 const sessionsByProvider = new Map<UsageProviderKind, number>();
141 for (const source of environment.summary.sources) {145 for (const source of environment.summary.sources) {
142 if (source.status === "missing") continue;146 if (source.status === "missing") continue;
143 const key = fingerprintKey(source.fingerprint);147 const key = fingerprintKey(source.fingerprint);
144 if (ownerByFingerprint.get(key) === environment.environmentId) {148 if (ownerByFingerprint.get(key) === environment.environmentId) {
145 ownedProviders.add(source.fingerprint.provider);149 const provider = source.fingerprint.provider;
150 ownedProviders.add(provider);
146 // Distinct within a directory. Summing per-bucket session counts instead151 // Distinct within a directory. Summing per-bucket session counts instead
147 // would count a session once per day and model it spans.152 // would count a session once per day and model it spans.
148 sessions += source.distinctSessions;153 sessionsByProvider.set(
154 provider,
155 (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions,
156 );
149 }157 }
150 }158 }
151 return {159 return {
152 buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)),160 buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)),
153 sessions,161 sessionsByProvider,
154 };162 };
155}163}
156164
...@@ -228,7 +236,7 @@ export function mergeUsage(...@@ -228,7 +236,7 @@ export function mergeUsage(
228236
229 const providerAccumulator = new Map<237 const providerAccumulator = new Map<
230 UsageProviderKind,238 UsageProviderKind,
231 { costUsd: number; totalTokens: number; records: number }239 { costUsd: number; totalTokens: number; records: number; sessions: number }
232 >();240 >();
233 const modelAccumulator = new Map<241 const modelAccumulator = new Map<
234 string,242 string,
...@@ -255,12 +263,21 @@ export function mergeUsage(...@@ -255,12 +263,21 @@ export function mergeUsage(
255 const contributingEnvironments: EnvironmentId[] = [];263 const contributingEnvironments: EnvironmentId[] = [];
256264
257 for (const environment of current) {265 for (const environment of current) {
258 const { buckets, sessions: environmentSessions } = ownedContribution(266 const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint);
259 environment,
260 ownerByFingerprint,
261 );
262 if (buckets.length > 0) contributingEnvironments.push(environment.environmentId);267 if (buckets.length > 0) contributingEnvironments.push(environment.environmentId);
263 sessions += environmentSessions;268
269 for (const [providerKind, providerSessions] of sessionsByProvider) {
270 sessions += providerSessions;
271 if (providerSessions === 0) continue;
272 const provider = providerAccumulator.get(providerKind) ?? {
273 costUsd: 0,
274 totalTokens: 0,
275 records: 0,
276 sessions: 0,
277 };
278 provider.sessions += providerSessions;
279 providerAccumulator.set(providerKind, provider);
280 }
264281
265 for (const bucket of buckets) {282 for (const bucket of buckets) {
266 const tokens = bucketTokens(bucket);283 const tokens = bucketTokens(bucket);
...@@ -280,6 +297,7 @@ export function mergeUsage(...@@ -280,6 +297,7 @@ export function mergeUsage(
280 costUsd: 0,297 costUsd: 0,
281 totalTokens: 0,298 totalTokens: 0,
282 records: 0,299 records: 0,
300 sessions: 0,
283 };301 };
284 provider.costUsd += bucket.costUsd;302 provider.costUsd += bucket.costUsd;
285 provider.totalTokens += tokens;303 provider.totalTokens += tokens;
...@@ -341,6 +359,7 @@ export function mergeUsage(...@@ -341,6 +359,7 @@ export function mergeUsage(
341 costUsd: totals.costUsd,359 costUsd: totals.costUsd,
342 totalTokens: totals.totalTokens,360 totalTokens: totals.totalTokens,
343 records: totals.records,361 records: totals.records,
362 sessions: totals.sessions,
344 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,363 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
345 tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,364 tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,
346 }))365 }))