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({
4545 );
4646}
4747
48export function WorkspaceBreadcrumbSeparator() {
48export 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 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 {
1212} from "react";
1313
1414import { cn } from "../../lib/utils";
15import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer";
1516import { Button } from "../ui/button";
1617import { 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
2619interface SettingsSearchTargetContextValue {
2720 readonly targetId: string | null;
2821 readonly onTargetHandled: () => void;
......@@ -245,7 +238,7 @@ export function SettingsPageContainer({
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({
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 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
1717function ToggleGroup({
1818 className,
1919 variant = "default",
20 size = "default",
20 size = variant === "segmented" ? "segmented" : "default",
2121 orientation = "horizontal",
2222 children,
2323 ...props
......@@ -30,11 +30,13 @@ function ToggleGroup({
3030 orientation === "horizontal"
3131 ? "*:pointer-coarse:after:min-w-auto"
3232 : "*:pointer-coarse:after:min-h-auto",
33 variant === "default"
34 ? "gap-0.5"
35 : orientation === "horizontal"
36 ? "*:not-first:not-data-[slot=separator]:before:-start-[0.5px] *:not-last:not-data-[slot=separator]:before:-end-[0.5px] *:not-first:rounded-s-none *:not-last:rounded-e-none *:not-first:border-s-0 *:not-last:border-e-0 *:not-first:before:rounded-s-none *:not-last:before:rounded-e-none"
37 : "*:not-first:not-data-[slot=separator]:before:-top-[0.5px] *:not-last:not-data-[slot=separator]:before:-bottom-[0.5px] flex-col *:not-first:rounded-t-none *:not-last:rounded-b-none *:not-first:border-t-0 *:not-last:border-b-0 *:not-first:before:rounded-t-none *:not-last:before:rounded-b-none *:data-[slot=toggle]:not-last:before:hidden dark:*:last:before:hidden dark:*:first:before:block",
33 variant === "segmented"
34 ? "gap-0.5 rounded-lg bg-input/40 p-0.5"
35 : variant === "default"
36 ? "gap-0.5"
37 : orientation === "horizontal"
38 ? "*:not-first:not-data-[slot=separator]:before:-start-[0.5px] *:not-last:not-data-[slot=separator]:before:-end-[0.5px] *:not-first:rounded-s-none *:not-last:rounded-e-none *:not-first:border-s-0 *:not-last:border-e-0 *:not-first:before:rounded-s-none *:not-last:before:rounded-e-none"
39 : "*:not-first:not-data-[slot=separator]:before:-top-[0.5px] *:not-last:not-data-[slot=separator]:before:-bottom-[0.5px] flex-col *:not-first:rounded-t-none *:not-last:rounded-b-none *:not-first:border-t-0 *:not-last:border-b-0 *:not-first:before:rounded-t-none *:not-last:before:rounded-b-none *:data-[slot=toggle]:not-last:before:hidden dark:*:last:before:hidden dark:*:first:before:block",
3840 className,
3941 )}
4042 data-size={size}
apps/web/src/components/ui/toggle.tsx+4
......@@ -18,6 +18,8 @@ const toggleVariants = cva(
1818 "h-7 min-w-7 rounded-md px-[calc(--spacing(1)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5",
1919 default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8",
2020 lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9",
21 segmented:
22 "h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]",
2123 sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7",
2224 xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md",
2325 },
......@@ -27,6 +29,8 @@ const toggleVariants = cva(
2729 "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100",
2830 outline:
2931 "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none",
32 segmented:
33 "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-background/55 hover:text-foreground data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-xs/10 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 {
1919 formatUsd,
2020 makeWindow,
2121} from "@t3tools/shared/usageFormat";
22import { ScrollArea } from "../ui/scroll-area";
2322import { Button } from "../ui/button";
23import { ScrollArea } from "../ui/scroll-area";
24import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
2425import { SidebarInset } from "../ui/sidebar";
25import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb";
26import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar";
27import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
26import { Toggle, ToggleGroup } from "../ui/toggle-group";
27import {
28 WorkspaceBreadcrumb,
29 WorkspaceBreadcrumbItem,
30 WorkspaceBreadcrumbSeparator,
31} from "../WorkspaceBreadcrumb";
32import { WorkspacePageContainer } from "../WorkspacePageContainer";
33import { WorkspacePageHeader } from "../WorkspacePageHeader";
34import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
2835import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders";
2936
3037const WINDOW_OPTIONS = [
......@@ -68,21 +75,6 @@ export function UsagePage() {
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() {
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() {
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() {
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() {
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() {
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() {
435428 </section>
436429 </>
437430 )}
438 </div>
431 </WorkspacePageContainer>
439432 </ScrollArea>
440433 </div>
441434 </SidebarInset>
......@@ -454,20 +447,11 @@ function ProviderMark({
454447 return <Mark className={cn("shrink-0", className)} aria-hidden />;
455448}
456449
457function Metric({
458 label,
459 value,
460 detail,
461}: {
462 readonly label: string;
463 readonly value: string;
464 readonly detail: string;
465}) {
450function 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({
571555 );
572556}
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
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 */
582function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) {
562function 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 @@
11import type { UsageProviderKind } from "@t3tools/contracts";
2import { useCallback, useMemo, useRef, useState } from "react";
2import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
33
44import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";
55import {
......@@ -68,13 +68,7 @@ function buildPeriodColumns(
6868 });
6969}
7070
71/**
72 * Monotone cubic tangents (Fritsch-Carlson).
73 *
74 * Plain cubic smoothing overshoots on spiky daily data and would dip the area
75 * below zero between points, which reads as negative spend. This variant is
76 * shape-preserving, so a smoothed series never leaves the range of its samples.
77 */
71/** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */
7872function 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[] {
115109 return tangents;
116110}
117111
118/** One cubic segment of a smoothed boundary. */
119112interface CurveSegment {
120113 readonly from: Point;
121114 readonly c1: Point;
......@@ -123,7 +116,6 @@ interface CurveSegment {
123116 readonly to: Point;
124117}
125118
126/** Smoothed polyline through `points`, as explicit cubic control points. */
127119function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
128120 if (points.length < 2) return [];
129121 const tangents = monotoneTangents(points);
......@@ -144,10 +136,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
144136 return segments;
145137}
146138
147function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string {
139function curvePath(segments: readonly CurveSegment[]): string {
148140 const first = segments[0];
149141 if (first === undefined) return "";
150 let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`;
142 let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`;
151143 for (const segment of segments) {
152144 path += ` C${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.to.x.toFixed(2)},${segment.to.y.toFixed(2)}`;
153145 }
......@@ -179,10 +171,8 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re
179171/**
180172 * Turns the merged daily totals into one column per day.
181173 *
182 * Values are absolute, not cumulative: the series are layered from a shared
183 * zero baseline rather than stacked. A stacked chart puts whichever provider is
184 * drawn last permanently above the other, which reads as "that one is bigger"
185 * even on days where it is not.
174 * Values are absolute, not cumulative: each provider is drawn from the same
175 * zero baseline so the chart never implies that one provider is always larger.
186176 *
187177 * The chart paths and the hover readout both consume this, so the number under
188178 * the cursor is by construction the number that was plotted rather than a
......@@ -216,42 +206,44 @@ export function UsageProviderChart({
216206 );
217207 const [hoverIndex, setHoverIndex] = useState<number | null>(null);
218208 const plotRef = useRef<HTMLDivElement | null>(null);
209 const tooltipRef = useRef<HTMLDivElement | null>(null);
210 const hoverPositionRef = useRef<{ x: number; y: number } | null>(null);
219211
220 const { paths, ticks, stepX, toY, series } = useMemo(() => {
212 const { paths, series, stepX, ticks, toY } = useMemo(() => {
221213 if (periods.length === 0) {
222214 return {
223215 paths: [],
224 ticks: [0] as readonly number[],
216 series: [] as readonly DayColumn[],
225217 stepX: 0,
218 ticks: [0] as readonly number[],
226219 toY: () => VIEW_HEIGHT,
227 series: [] as readonly DayColumn[],
228220 };
229221 }
230222
231223 const columns = buildPeriodColumns(periods, byPeriod, metric);
232
233 // The scale tops out at the largest single provider-day, not the largest
234 // sum: layered series each measure from zero, so a combined peak would
235 // leave the plot permanently half empty.
224 // The scale tops out at the largest single provider-period, not the sum:
225 // layered series each measure from zero, so a combined peak would leave
226 // the plot permanently half empty.
236227 const peak = columns.reduce(
237228 (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),
238229 0,
239230 );
240231 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);
241232 const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1);
242 // Reserve a sliver above the top gridline so the series stroke, which is
243 // drawn at constant screen width, is not shaved off at a peak.
233 // Leave room above the top gridline so the constant-width stroke is not
234 // clipped when a series reaches the peak.
244235 const toY = (value: number) =>
245236 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
246237
247238 const built = PROVIDER_ORDER.map((provider, providerIndex) => {
248 const curve = smoothCurve(
249 columns.map((column, dayIndex) => ({
250 x: dayIndex * step,
251 y: toY(column.bands[providerIndex]?.value ?? 0),
252 })),
239 const line = curvePath(
240 smoothCurve(
241 columns.map((column, periodIndex) => ({
242 x: periodIndex * step,
243 y: toY(column.bands[providerIndex]?.value ?? 0),
244 })),
245 ),
253246 );
254 const line = curvePath(curve, "M");
255247 return {
256248 provider,
257249 total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0),
......@@ -260,30 +252,76 @@ export function UsageProviderChart({
260252 };
261253 });
262254
263 // Paint the heavier series first so the lighter one is never buried under
264 // it. The fills are faint enough that the order barely shows, but the
265 // strokes are drawn in a second pass regardless, so neither can be hidden.
266 const ordered = [...built].sort((a, b) => b.total - a.total);
267
268 return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns };
255 // Paint the heavier series first so the lighter one is not buried.
256 return {
257 paths: built.toSorted((a, b) => b.total - a.total),
258 series: columns,
259 stepX: step,
260 ticks: tickValues,
261 toY,
262 };
269263 }, [byPeriod, metric, periods]);
270264
271265 const format = metric === "tokens" ? formatTokens : formatUsd;
272266
267 const positionTooltip = useCallback(() => {
268 const plot = plotRef.current;
269 const tooltip = tooltipRef.current;
270 const hoverPosition = hoverPositionRef.current;
271 if (plot === null || tooltip === null || hoverPosition === null) return;
272
273 const gap = 12;
274 const tooltipWidth = tooltip.offsetWidth;
275 const tooltipHeight = tooltip.offsetHeight;
276 const plotWidth = plot.clientWidth;
277 const plotHeight = plot.clientHeight;
278 const preferredLeft =
279 hoverPosition.x + gap + tooltipWidth <= plotWidth
280 ? hoverPosition.x + gap
281 : hoverPosition.x - gap - tooltipWidth;
282 const preferredTop =
283 hoverPosition.y + gap + tooltipHeight <= plotHeight
284 ? hoverPosition.y + gap
285 : hoverPosition.y - gap - tooltipHeight;
286 const left = Math.min(Math.max(0, preferredLeft), Math.max(0, plotWidth - tooltipWidth));
287 const top = Math.min(Math.max(0, preferredTop), Math.max(0, plotHeight - tooltipHeight));
288 plot.style.setProperty("--usage-tooltip-left", `${left}px`);
289 plot.style.setProperty("--usage-tooltip-top", `${top}px`);
290 }, []);
291
292 useLayoutEffect(() => {
293 if (hoverIndex === null) return;
294 positionTooltip();
295
296 const plot = plotRef.current;
297 const tooltip = tooltipRef.current;
298 if (plot === null || tooltip === null || typeof ResizeObserver === "undefined") return;
299
300 const observer = new ResizeObserver(positionTooltip);
301 observer.observe(plot);
302 observer.observe(tooltip);
303 return () => observer.disconnect();
304 }, [hoverIndex, positionTooltip]);
305
273306 const handleMove = useCallback(
274307 (event: React.MouseEvent<HTMLDivElement>) => {
275 const bounds = plotRef.current?.getBoundingClientRect();
276 if (bounds === undefined || bounds.width === 0 || periods.length === 0) return;
277 const fraction = (event.clientX - bounds.left) / bounds.width;
308 const plot = plotRef.current;
309 if (plot === null || periods.length === 0) return;
310 const bounds = plot.getBoundingClientRect();
311 if (bounds.width === 0) return;
312 const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left));
313 const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top));
314 const fraction = localX / bounds.width;
278315 const index = Math.round(fraction * (periods.length - 1));
316 hoverPositionRef.current = { x: localX, y: localY };
317 positionTooltip();
279318 setHoverIndex(Math.min(periods.length - 1, Math.max(0, index)));
280319 },
281 [periods.length],
320 [periods.length, positionTooltip],
282321 );
283322
284323 const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];
285324 const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];
286 const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100;
287325 const formatPeriod = (period: string) =>
288326 resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);
289327 const formatTooltipPeriod = (period: string) =>
......@@ -311,7 +349,10 @@ export function UsageProviderChart({
311349 ref={plotRef}
312350 className="relative h-56 flex-1"
313351 onMouseMove={handleMove}
314 onMouseLeave={() => setHoverIndex(null)}
352 onMouseLeave={() => {
353 hoverPositionRef.current = null;
354 setHoverIndex(null);
355 }}
315356 >
316357 <svg
317358 className="h-full w-full"
......@@ -373,10 +414,11 @@ export function UsageProviderChart({
373414
374415 {hoveredPeriod === undefined ? null : (
375416 <div
376 className="pointer-events-none absolute top-0 z-10 min-w-36 border border-border bg-background/95 px-2 py-1.5 text-xs"
417 ref={tooltipRef}
418 className="surface-glass pointer-events-none absolute z-10 min-w-36 max-w-full rounded-xl border border-border/50 px-2.5 py-2 text-xs shadow-lg"
377419 style={{
378 left: `${hoverLeft}%`,
379 transform: hoverLeft > 60 ? "translateX(-100%)" : "translateX(0)",
420 left: "var(--usage-tooltip-left, 0px)",
421 top: "var(--usage-tooltip-top, 0px)",
380422 }}
381423 >
382424 <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div>
......@@ -423,21 +465,3 @@ export function UsageProviderChart({
423465 </div>
424466 );
425467}
426
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 = {
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. */
3030export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[];
packages/shared/src/usageMerge.test.ts+31
......@@ -138,6 +138,12 @@ describe("mergeUsage", () => {
138138 "claude",
139139 "codex",
140140 ]);
141 expect(merged.sessions).toBe(2);
142 expect(
143 Object.fromEntries(
144 merged.providers.map((provider) => [provider.provider, provider.sessions]),
145 ),
146 ).toEqual({ claude: 1, codex: 1 });
141147 });
142148
143149 it("excludes an environment reporting an older contract version", () => {
......@@ -248,6 +254,7 @@ describe("mergeUsage", () => {
248254 );
249255
250256 expect(merged.sessions).toBe(1);
257 expect(merged.providers[0]?.sessions).toBe(1);
251258 });
252259
253260 it("returns empty totals with no environments", () => {
......@@ -257,6 +264,30 @@ describe("mergeUsage", () => {
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 {
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[]): {
135136function 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(
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(
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(
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(
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 }))