History

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

maria committed 2 months ago

Commit 8c85b491 parent 3c0665511 files · +434 −346Browse files

Unified · Split

11 files
  1. apps/web/src/components/WorkspaceBreadcrumb.tsx +2 −2
  2. apps/web/src/components/WorkspacePageContainer.tsx +29 −0
  3. apps/web/src/components/settings/settingsLayout.tsx +4 −17
  4. apps/web/src/components/ui/toggle-group.test.tsx +19 −0
  5. apps/web/src/components/ui/toggle-group.tsx +8 −6
  6. apps/web/src/components/ui/toggle.tsx +4 −0
  7. apps/web/src/components/usage/UsagePage.tsx +218 −245
  8. apps/web/src/components/usage/UsageProviderChart.tsx +88 −64
  9. apps/web/src/components/usage/usageProviders.ts +1 −1
  10. packages/shared/src/usageMerge.test.ts +31 −0
  11. packages/shared/src/usageMerge.ts +30 −11
apps/web/src/components/WorkspaceBreadcrumb.tsx+2−2
@@ -45,9 +45,9 @@ export function WorkspaceBreadcrumbItem({ · 44 unmodified lines
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/WorkspacePageContainer.tsx+29−0
@@ -0,0 +1,29 @@
1+import type { ComponentPropsWithoutRef } from "react";
2+
3+import { cn } from "../lib/utils";
4+
5+export type WorkspacePageWidth = "readable" | "wide" | "expanded";
6+
7+const 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. */
14+export 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 { · 11 unmodified lines
1212 } from "react";
1313
1414 import { cn } from "../../lib/utils";
15+import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer";
1516 import { Button } from "../ui/button";
1617 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1718
18−type SettingsPageWidth = "readable" | "wide" | "expanded";
19−
20−const SETTINGS_PAGE_WIDTH_CLASS: Record<SettingsPageWidth, string> = {
21− readable: "max-w-4xl",
22− wide: "max-w-5xl",
23− expanded: "max-w-6xl",
24−};
25−
2619 interface SettingsSearchTargetContextValue {
2720 readonly targetId: string | null;
2821 readonly onTargetHandled: () => void;
@@ -245,7 +238,7 @@ export function SettingsPageContainer({ · 216 unmodified lines
245238 }: {
246239 children: ReactNode;
247240 className?: string;
248− width?: SettingsPageWidth;
241+ width?: WorkspacePageWidth;
249242 }) {
250243 const navigate = useNavigate();
251244 const hash = useLocation({ select: (location) => location.hash });
@@ -260,15 +253,9 @@ export function SettingsPageContainer({ · 8 unmodified lines
260253 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]"
261254 data-settings-page-scroll
262255 >
263− <div
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− >
256+ <WorkspacePageContainer width={width} className={cn("gap-12", className)}>
270257 {children}
271− </div>
258+ </WorkspacePageContainer>
272259 </div>
273260 </SettingsSearchTargetProvider>
274261 );
apps/web/src/components/ui/toggle-group.test.tsx+19−0
@@ -0,0 +1,19 @@
1+import { renderToStaticMarkup } from "react-dom/server";
2+import { describe, expect, it } from "vite-plus/test";
3+
4+import { Toggle, ToggleGroup } from "./toggle-group";
5+
6+describe("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 · 16 unmodified lines
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({ · 6 unmodified lines
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+4−0
@@ -18,6 +18,8 @@ const toggleVariants = cva( · 17 unmodified lines
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( · 3 unmodified lines
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 dark:hover:bg-input/32 dark:data-pressed:bg-input/72",
3034 },
3135 },
3236 },
apps/web/src/components/usage/UsagePage.tsx+218−245
@@ -19,12 +19,19 @@ import { · 18 unmodified lines
1919 formatUsd,
2020 makeWindow,
2121 } from "@t3tools/shared/usageFormat";
22−import { ScrollArea } from "../ui/scroll-area";
2322 import { Button } from "../ui/button";
23+import { ScrollArea } from "../ui/scroll-area";
24+import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
2425 import { SidebarInset } from "../ui/sidebar";
25−import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb";
26−import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar";
27−import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
26+import { Toggle, ToggleGroup } from "../ui/toggle-group";
27+import {
28+ WorkspaceBreadcrumb,
29+ WorkspaceBreadcrumbItem,
30+ WorkspaceBreadcrumbSeparator,
31+} from "../WorkspaceBreadcrumb";
32+import { WorkspacePageContainer } from "../WorkspacePageContainer";
33+import { WorkspacePageHeader } from "../WorkspacePageHeader";
34+import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
2835 import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders";
2936
3037 const WINDOW_OPTIONS = [
@@ -68,21 +75,6 @@ export function UsagePage() { · 37 unmodified lines
6875 [isPast24Hours, merged.daily, merged.hourly],
6976 );
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;
8678 const selectWindow = (days: number) => {
8779 setWindowSelection({
8880 days,
@@ -102,78 +94,113 @@ export function UsagePage() { · 13 unmodified lines
10294 setWindowSelection({ days: windowDays, window: nextWindow });
10395 }
10496 };
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
106193 return (
107194 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
108195 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
109− {!isElectron && (
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− )}
196+ <WorkspacePageHeader electron={isElectron}>{topbarContent}</WorkspacePageHeader>
134197
135198 <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">
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−
199+ <WorkspacePageContainer width="wide">
173200 {settling ? (
174201 <>
175202 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}
176− <UsageSkeleton resolution={isPast24Hours ? "hour" : "day"} />
203+ <UsageSkeleton />
177204 </>
178205 ) : (
179206 <>
@@ -183,88 +210,71 @@ export function UsagePage() { · 3 unmodified lines
183210 staleEnvironments={merged.staleEnvironments}
184211 />
185212
186− {/* Cost first: the financial answer, then the provider split. */}
187− <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
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">
213+ <section className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
214+ <div className="flex min-w-0 flex-col gap-5">
191215 <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>
195216 <span className="text-4xl font-semibold text-foreground tabular-nums">
196217 {metric === "cost"
197− ? `${formatUsd(merged.costUsd)}*`
218+ ? formatUsd(merged.costUsd)
198219 : formatTokens(merged.totalTokens)}
199220 </span>
200221 <span className="text-xs text-muted-foreground">
201222 {metric === "cost"
202− ? "* if billed at full API rate"
203− : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`}
223+ ? `${formatCount(merged.sessions)} sessions · API estimate`
224+ : `${formatCount(merged.sessions)} sessions`}
204225 </span>
205226 </div>
206227
207− {orderedProviders.map((provider) => {
208− const share = metric === "cost" ? provider.costShare : provider.tokenShare;
228+ {PROVIDER_ORDER.map((provider) => {
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+ }`;
209236 return (
210− <div key={provider.provider} className="flex flex-col gap-1.5">
211− <div className="flex items-baseline justify-between">
212− <span className="flex items-center gap-2 text-sm text-foreground">
213− <ProviderMark provider={provider.provider} className="size-4" />
214− {PROVIDER_PRESENTATION[provider.provider].label}
237+ <div key={provider} className="flex flex-col gap-1">
238+ <div className="flex items-baseline justify-between gap-4">
239+ <span className="flex min-w-0 items-center gap-2 text-sm text-foreground">
240+ <span
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>
215256 </span>
216− <span className="text-sm text-foreground tabular-nums">
257+ <span className="shrink-0 text-sm font-medium text-foreground tabular-nums">
217258 {metric === "cost"
218− ? formatUsd(provider.costUsd)
219− : formatTokens(provider.totalTokens)}
259+ ? formatUsd(totals?.costUsd ?? 0)
260+ : formatTokens(totals?.totalTokens ?? 0)}
220261 </span>
221262 </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>
231263 <span className="text-xs text-muted-foreground">
232264 {metric === "cost"
233− ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens`
234− : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`}
265+ ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens`
266+ : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`}
235267 </span>
236268 </div>
237269 );
238270 })}
239271 </div>
240272
241− <div className="flex flex-col gap-3">
242− <div className="flex flex-wrap items-center justify-between gap-3">
243− <h2 className="text-sm font-medium text-foreground">
244− {isPast24Hours ? "Hourly" : "Daily"}{" "}
245− {metric === "tokens" ? "processed tokens" : "cost"}
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>
273+ <div className="flex min-w-0 flex-col gap-3">
274+ <h2 className="text-sm font-medium text-foreground">
275+ {isPast24Hours ? "Hourly" : "Daily"}{" "}
276+ {metric === "tokens" ? "processed tokens" : "cost"}
277+ </h2>
268278 <UsageProviderChart
269279 days={days}
270280 daily={merged.daily}
@@ -278,63 +288,46 @@ export function UsagePage() { · 7 unmodified lines
278288 </div>
279289 </section>
280290
281− <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5">
282− <Metric
283− label="Processed tokens"
284− value={formatTokens(merged.totalTokens)}
285− detail={`${formatTokens(periodAverage)} per active ${isPast24Hours ? "hour" : "day"}`}
286− />
287− <Metric
288− label="Cached input"
289− value={formatTokens(merged.cachedInputTokens)}
290− detail={`${formatPercent(cachedShare)} of observed input`}
291− />
292− <Metric
293− label="Uncached input"
294− value={formatTokens(merged.uncachedInputTokens)}
295− detail={`${formatTokens(merged.cacheCreationTokens)} cache writes`}
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− />
291+ <section className="flex flex-col gap-2">
292+ <h2 className="text-sm font-medium text-foreground">Totals</h2>
293+ <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">
294+ <Metric label="Processed tokens" value={formatTokens(merged.totalTokens)} />
295+ <Metric label="Cached input" value={formatTokens(merged.cachedInputTokens)} />
296+ <Metric
297+ label="Uncached input"
298+ value={formatTokens(merged.uncachedInputTokens)}
299+ />
300+ <Metric label="Output" value={formatTokens(merged.outputTokens)} />
301+ <Metric
302+ label="Cache savings"
303+ value={formatUsd(merged.costQuality.cacheSavingsUsd)}
304+ />
305+ </div>
311306 </section>
312307
313308 <section className="flex flex-col gap-3">
314309 <div className="flex items-center justify-between gap-3">
315310 <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+ >
317320 {(
318321 [
319− { value: "model", label: "model" },
320− { value: "time", label: isPast24Hours ? "hour" : "day" },
322+ { value: "model", label: "Model" },
323+ { value: "time", label: isPast24Hours ? "Hour" : "Day" },
321324 ] as const
322325 ).map((option) => (
323− <button
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− >
326+ <Toggle key={option.value} value={option.value}>
334327 {option.label}
335− </button>
328+ </Toggle>
336329 ))}
337− </div>
330+ </ToggleGroup>
338331 </div>
339332
340333 {breakdown === "model" ? (
@@ -358,7 +351,7 @@ export function UsagePage() { · 17 unmodified lines
358351 merged.models.map((model) => (
359352 <tr
360353 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"
362355 >
363356 <td className="py-2 text-foreground">
364357 <span className="flex items-center gap-2">
@@ -405,7 +398,7 @@ export function UsagePage() { · 40 unmodified lines
405398 breakdownPeriods.map((period) => (
406399 <tr
407400 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"
409402 >
410403 <td className="py-2 text-foreground">
411404 {"hourStart" in period
@@ -435,7 +428,7 @@ export function UsagePage() { · 23 unmodified lines
435428 </section>
436429 </>
437430 )}
438− </div>
431+ </WorkspacePageContainer>
439432 </ScrollArea>
440433 </div>
441434 </SidebarInset>
@@ -454,20 +447,11 @@ function ProviderMark({ · 12 unmodified lines
454447 return <Mark className={cn("shrink-0", className)} aria-hidden />;
455448 }
456449
457−function Metric({
458− label,
459− value,
460− detail,
461−}: {
462− readonly label: string;
463− readonly value: string;
464− readonly detail: string;
465−}) {
450+function Metric({ label, value }: { readonly label: string; readonly value: string }) {
466451 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">
468453 <span className="text-xs text-muted-foreground">{label}</span>
469− <span className="text-lg text-foreground tabular-nums">{value}</span>
470− <span className="text-xs text-muted-foreground">{detail}</span>
454+ <span className="text-base font-medium text-foreground tabular-nums">{value}</span>
471455 </div>
472456 );
473457 }
@@ -571,70 +555,59 @@ function UsageDeviceStrip({ · 97 unmodified lines
571555 );
572556 }
573557
574−/** Deterministic bar heights (each unique: they double as keys). */
575−const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67];
576−
577558 /**
578− * Static stand-in with the loaded page's shape: headline, provider split,
579− * chart and metrics strip. No shimmer; blocks fill in exactly once when the
580− * last device answers.
559+ * Static stand-in with the loaded page's shape. No shimmer; blocks fill in
560+ * exactly once when the last device answers.
581561 */
582−function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) {
562+function UsageSkeleton() {
583563 return (
584564 <>
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)]">
586566 <div className="flex flex-col gap-5">
587567 <div className="flex flex-col gap-1">
588− <span className="text-xs tracking-wide text-muted-foreground uppercase">
589− Raw token cost
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" />
568+ <div className="h-10 w-36 rounded-sm bg-muted" />
569+ <div className="h-4 w-32 rounded-sm bg-muted" />
593570 </div>
594−
595571 {PROVIDER_ORDER.map((provider) => (
596− <div key={provider} className="flex flex-col gap-1.5">
597− <div className="flex items-center justify-between">
598− <span className="flex items-center gap-2 text-sm text-foreground">
572+ <div key={provider} className="flex flex-col gap-1">
573+ <div className="flex min-h-5 items-center justify-between gap-4">
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+ />
599580 <ProviderMark provider={provider} className="size-4" />
600− {PROVIDER_PRESENTATION[provider].label}
581+ <div className="h-3.5 w-20 rounded-sm bg-muted" />
601582 </span>
602583 <div className="h-3.5 w-14 rounded-sm bg-muted" />
603584 </div>
604− <div className="h-1 w-full rounded-full bg-muted" />
605− <div className="h-3 w-36 rounded-sm bg-muted" />
585+ <div className="h-4 w-36 rounded-sm bg-muted" />
606586 </div>
607587 ))}
608588 </div>
609589
610590 <div className="flex flex-col gap-3">
611− <h2 className="py-1 text-sm font-medium text-foreground">
612− {resolution === "hour" ? "Hourly" : "Daily"} cost
613− </h2>
614− {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a
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− ))}
591+ <div className="h-5 w-24 rounded-sm bg-muted" />
592+ <div className="flex flex-col gap-1">
593+ <div className="ml-16 h-56 rounded-sm bg-muted/35" />
594+ <div className="ml-16 h-3 rounded-sm bg-muted/35" />
624595 </div>
625596 </div>
626597 </section>
627598
628− <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5">
629− {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map(
630− (label) => (
631− <div key={label} className="flex flex-col gap-0.5 bg-background px-4 py-3">
632− <span className="text-xs text-muted-foreground">{label}</span>
633− <div className="my-1 h-5 w-16 rounded-sm bg-muted" />
634− <div className="h-3 w-24 rounded-sm bg-muted" />
635− </div>
636− ),
637− )}
599+ <section className="flex flex-col gap-2">
600+ <h2 className="text-sm font-medium text-foreground">Totals</h2>
601+ <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">
602+ {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map(
603+ (label) => (
604+ <div key={label} className="flex flex-col gap-0.5">
605+ <span className="text-xs text-muted-foreground">{label}</span>
606+ <div className="my-0.5 h-4 w-16 rounded-sm bg-muted" />
607+ </div>
608+ ),
609+ )}
610+ </div>
638611 </section>
639612 </>
640613 );
apps/web/src/components/usage/UsageProviderChart.tsx+88−64
@@ -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( · 62 unmodified lines
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,7 +109,6 @@ function monotoneTangents(points: readonly Point[]): readonly number[] { · 34 unmodified lines
115109 return tangents;
116110 }
117111
118−/** One cubic segment of a smoothed boundary. */
119112 interface CurveSegment {
120113 readonly from: Point;
121114 readonly c1: Point;
@@ -123,7 +116,6 @@ interface CurveSegment { · 1 unmodified line
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[] { · 14 unmodified lines
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 · 25 unmodified lines
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({ · 27 unmodified lines
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({ · 2 unmodified lines
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({ · 21 unmodified lines
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({ · 55 unmodified lines
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({ · 40 unmodified lines
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+1−1
@@ -26,5 +26,5 @@ export const PROVIDER_PRESENTATION = { · 25 unmodified lines
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+31−0
@@ -138,6 +138,12 @@ describe("mergeUsage", () => { · 137 unmodified lines
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", () => { · 104 unmodified lines
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", () => {
@@ -257,6 +264,30 @@ describe("mergeUsage", () => { · 3 unmodified lines
257264 expect(merged.hourly).toHaveLength(0);
258265 });
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+
260291 it("derives hourly totals without losing the daily rollup", () => {
261292 const merged = mergeUsage(
262293 [
packages/shared/src/usageMerge.ts+30−11
@@ -25,6 +25,7 @@ export interface ProviderTotals { · 24 unmodified lines
2525 readonly costUsd: number;
2626 readonly totalTokens: number;
2727 readonly records: number;
28+ readonly sessions: number;
2829 readonly costShare: number;
2930 readonly tokenShare: number;
3031 }
@@ -135,22 +136,29 @@ function claimSources(environments: readonly EnvironmentUsage[]): { · 104 unmodified lines
135136 function ownedContribution(
136137 environment: EnvironmentUsage,
137138 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+} {
139143 const ownedProviders = new Set<UsageProviderKind>();
140− let sessions = 0;
144+ const sessionsByProvider = new Map<UsageProviderKind, number>();
141145 for (const source of environment.summary.sources) {
142146 if (source.status === "missing") continue;
143147 const key = fingerprintKey(source.fingerprint);
144148 if (ownerByFingerprint.get(key) === environment.environmentId) {
145− ownedProviders.add(source.fingerprint.provider);
149+ const provider = source.fingerprint.provider;
150+ ownedProviders.add(provider);
146151 // Distinct within a directory. Summing per-bucket session counts instead
147152 // 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+ );
149157 }
150158 }
151159 return {
152160 buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)),
153− sessions,
161+ sessionsByProvider,
154162 };
155163 }
156164
@@ -228,7 +236,7 @@ export function mergeUsage( · 71 unmodified lines
228236
229237 const providerAccumulator = new Map<
230238 UsageProviderKind,
231− { costUsd: number; totalTokens: number; records: number }
239+ { costUsd: number; totalTokens: number; records: number; sessions: number }
232240 >();
233241 const modelAccumulator = new Map<
234242 string,
@@ -255,12 +263,21 @@ export function mergeUsage( · 20 unmodified lines
255263 const contributingEnvironments: EnvironmentId[] = [];
256264
257265 for (const environment of current) {
258− const { buckets, sessions: environmentSessions } = ownedContribution(
259− environment,
260− ownerByFingerprint,
261− );
266+ const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint);
262267 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
265282 for (const bucket of buckets) {
266283 const tokens = bucketTokens(bucket);
@@ -280,6 +297,7 @@ export function mergeUsage( · 13 unmodified lines
280297 costUsd: 0,
281298 totalTokens: 0,
282299 records: 0,
300+ sessions: 0,
283301 };
284302 provider.costUsd += bucket.costUsd;
285303 provider.totalTokens += tokens;
@@ -341,6 +359,7 @@ export function mergeUsage( · 55 unmodified lines
341359 costUsd: totals.costUsd,
342360 totalTokens: totals.totalTokens,
343361 records: totals.records,
362+ sessions: totals.sessions,
344363 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
345364 tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,
346365 }))