Skip to content

g1t/apps/web/app/components/usage.tsx

697 lines30,785 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Usage, Billing settings and prepaid AI credit; fixes from the UX audit1/**
2 * The Usage page's pieces, shared by the workspace's page and a project's:
3 * the filters, the included-usage and credit bar with what paid, the
4 * consumption chart (stacked columns by product, money on one axis, a
5 * tooltip per column, a legend, a table view), and the breakdown by
6 * product family with sparklines, allowance rings and each meter's
7 * projects. Every amount is usage at price, as on every other page.
8 */
9import { ChevronDown, Download, Ellipsis, FileCode } from "lucide-react";
10import { type ReactNode, useId, useRef } from "react";
11import { Form, Link } from "react-router";
12
13import type { MeterLine, ProductUsage, UsageReport } from "@g1t/contracts";
14
15import {
16 type Column,
17 type Grain,
18 type GroupBy,
19 PERIODS,
20 type Period,
21 axisMoney,
22 byProject,
23 money,
24 productStyle,
25 quantity,
26 rangeLabel,
27 receipt,
28 shownLines,
29 ticks,
30 usageCsv,
31} from "../lib/usage";
32import { cn } from "../lib/cn";
33import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
Usage: a meter's note is the site's tooltip, caught by the no-native-title test34import { Hint } from "./ui/hint";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit35import { Skeleton } from "./ui/skeleton";
36
37const SELECT =
38 "h-8 rounded-md border border-line bg-bg px-2 text-sm text-fg outline-none transition-colors hover:border-line-strong focus:border-accent-dim";
39
40// --- Filters ------------------------------------------------------------------------
41
42export type UsageFilters = {
43 period: Period;
44 from: string;
45 until: string;
46 products: string[];
47 projects: string[];
48 group: GroupBy;
49 grain: Grain;
50 cumulative: boolean;
51};
52
53/** A multi-select as a disclosure of checkboxes, part of the filter form. */
54function MultiSelect({ name, label, options, chosen }: { name: string; label: string; options: { value: string; label: string }[]; chosen: string[] }) {
55 const summary = chosen.length === 0 ? `All ${label.toLowerCase()}` : chosen.length === 1 ? (options.find((o) => o.value === chosen[0])?.label ?? chosen[0]) : `${chosen.length} ${label.toLowerCase()}`;
56 return (
57 <details className="group relative">
58 <summary className={cn(SELECT, "flex cursor-pointer list-none items-center gap-1.5 [&::-webkit-details-marker]:hidden")}>
59 <span className="text-muted">{label}:</span>
60 <span className="max-w-[10rem] truncate">{summary}</span>
61 <ChevronDown size={13} className="text-muted transition-transform group-open:rotate-180" />
62 </summary>
63 <div className="absolute left-0 z-20 mt-1 w-64 rounded-lg border border-line bg-raised p-2 shadow-xl">
64 {options.length === 0 ? (
65 <p className="px-2 py-1.5 text-sm text-faint">None in this range.</p>
66 ) : (
67 <ul className="max-h-64 overflow-y-auto">
68 {options.map((option) => (
69 <li key={option.value}>
70 <label className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-surface">
71 <input type="checkbox" name={name} value={option.value} defaultChecked={chosen.includes(option.value)} className="accent-[var(--g1t-accent)]" />
72 <span className="truncate">{option.label}</span>
73 </label>
74 </li>
75 ))}
76 </ul>
77 )}
78 <button type="submit" className="mt-2 w-full rounded-md bg-fg px-2 py-1.5 text-sm font-medium text-bg hover:bg-white">
79 Apply
80 </button>
81 </div>
82 </details>
83 );
84}
85
86/**
87 * The filters, in one row above everything: period (with its days),
88 * products, projects, group by, and a menu with the CSV and the API.
89 * A plain GET form, so a filtered page is a link.
90 */
91export function UsageFilterBar({
92 filters,
93 products,
94 projects,
95 report,
96 apiHref,
97 fixedProject,
98}: {
99 filters: UsageFilters;
100 products: { key: string; label: string }[];
101 projects: string[];
102 report: UsageReport | null;
103 apiHref: string;
104 /** A project's own page: no project filter. */
105 fixedProject?: string;
106}) {
107 const form = useRef<HTMLFormElement>(null);
108 const submit = () => form.current?.requestSubmit();
109 const download = () => {
110 if (!report) return;
111 const blob = new Blob([usageCsv(report)], { type: "text/csv" });
112 const link = document.createElement("a");
113 link.href = URL.createObjectURL(blob);
114 link.download = `usage-${report.from}-${report.until}.csv`;
115 link.click();
116 URL.revokeObjectURL(link.href);
117 };
118 return (
119 <Form ref={form} method="get" preventScrollReset className="flex flex-wrap items-center gap-2">
120 <select name="period" defaultValue={filters.period} onChange={submit} className={SELECT} aria-label="Period">
121 {(Object.keys(PERIODS) as Period[]).map((key) => (
122 <option key={key} value={key}>
123 {PERIODS[key]}
124 </option>
125 ))}
126 </select>
127 {filters.period === "custom" ? (
128 <span className="flex items-center gap-1 text-sm">
129 <input type="date" name="from" defaultValue={filters.from} aria-label="From" className={SELECT} />
130 <span className="text-faint">to</span>
131 <input type="date" name="until" defaultValue={filters.until} aria-label="Until" className={SELECT} />
132 </span>
133 ) : (
134 <span className="px-1 text-sm text-muted tabular-nums">{rangeLabel(filters.from, filters.until)}</span>
135 )}
136 <MultiSelect name="product" label="Products" options={products.map((p) => ({ value: p.key, label: p.label }))} chosen={filters.products} />
137 {!fixedProject && <MultiSelect name="project" label="Projects" options={projects.map((p) => ({ value: p, label: p }))} chosen={filters.projects} />}
138 <select name="group" defaultValue={filters.group} onChange={submit} className={SELECT} aria-label="Group by">
139 <option value="product">Group by product</option>
140 {!fixedProject && <option value="project">Group by project</option>}
141 <option value="day">Group by day</option>
142 </select>
143 <input type="hidden" name="grain" value={filters.grain} />
144 {filters.cumulative && <input type="hidden" name="cumulative" value="1" />}
145 <noscript>
146 <button type="submit" className={SELECT}>
147 Apply
148 </button>
149 </noscript>
150 <span className="ml-auto" />
151 <DropdownMenu>
152 <DropdownMenuTrigger className={cn(SELECT, "inline-flex items-center")} aria-label="More">
153 <Ellipsis size={15} />
154 </DropdownMenuTrigger>
155 <DropdownMenuContent align="end">
156 <DropdownMenuItem onSelect={download} disabled={!report}>
157 <Download size={14} /> Export CSV
158 </DropdownMenuItem>
159 <DropdownMenuItem asChild>
160 <a href={apiHref}>
161 <FileCode size={14} /> Usage API
162 </a>
163 </DropdownMenuItem>
164 </DropdownMenuContent>
165 </DropdownMenu>
166 </Form>
167 );
168}
169
170// --- What is included and what paid ------------------------------------------------------
171
172/** A bar of how much of something is used. */
173function Bar({ used, of, tone = "accent" }: { used: number; of: number; tone?: "accent" | "warn" }) {
174 const part = of > 0 ? Math.min(1, used / of) : 0;
175 return (
176 <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
Merge branch 'worktree-agent-ad7c6d88d93adc817'177 <div className={cn("h-full rounded-full", tone === "warn" ? "bg-warn" : "bg-success")} style={{ width: `${part > 0 ? Math.max(2, part * 100) : 0}%` }} />
Usage, Billing settings and prepaid AI credit; fixes from the UX audit178 </div>
179 );
180}
181
182/**
183 * The plan's included usage, the credit balances, and the receipt: usage
184 * at price, what a discount, included usage and credit took off, and what
185 * is charged.
186 */
187export function IncludedAndCredit({ report, billingHref, owner }: { report: UsageReport; billingHref: string; owner: boolean }) {
188 const lines = receipt(report.totals, report.discountPercent);
189 const free = report.plan === "free";
190 const fullDiscount = (report.discountPercent ?? 0) >= 100;
191 return (
192 <section className="grid gap-px overflow-hidden rounded-xl border border-line bg-line sm:grid-cols-3">
193 <div className="bg-surface p-4">
194 <p className="text-sm text-muted">{report.included ? "Included usage this month" : free ? "Trial credit" : "Plan"}</p>
195 {report.included ? (
196 <>
197 <p className="mt-1 text-lg font-semibold tabular-nums">
198 {money(report.included.used)} <span className="text-sm font-normal text-faint">/ {money(report.included.of)}</span>
199 </p>
200 <Bar used={report.included.used} of={report.included.of} />
201 </>
202 ) : free ? (
203 <>
204 <p className="mt-1 text-lg font-semibold tabular-nums">{report.trialMicros != null ? `${money(report.trialMicros)} left` : "None"}</p>
205 <p className="mt-1 text-xs text-faint">
206 The forge is free. Compute needs the plan or the trial.{" "}
207 <Link to={billingHref} className="text-fg-soft underline-offset-2 hover:underline">
208 See plans
209 </Link>
210 </p>
211 </>
212 ) : (
213 <p className="mt-1 text-sm">{fullDiscount ? `${report.discountPercent}% discount from g1t` : report.plan === "enterprise" ? "Paid by an enterprise" : "On the g1t plan"}</p>
214 )}
215 </div>
216 <div className="bg-surface p-4">
217 <p className="text-sm text-muted">Credit</p>
218 {fullDiscount ? (
219 <p className="mt-1 text-sm">AI usage is free under the {report.discountPercent}% discount: nothing to buy.</p>
220 ) : (
221 <dl className="mt-1 space-y-1 text-sm">
222 <div className="flex justify-between gap-3">
223 <dt className="text-muted">AI credit</dt>
224 <dd className="font-medium tabular-nums">{money(report.aiCreditMicros)}</dd>
225 </div>
226 {report.creditMicros > 0 && (
227 <div className="flex justify-between gap-3">
228 <dt className="text-muted">Credit from g1t</dt>
229 <dd className="tabular-nums">{money(report.creditMicros)}</dd>
230 </div>
231 )}
232 {owner && report.plan === "paid" && (
233 <dd>
234 <Link to={`${billingHref}#ai-credit`} className="text-xs text-fg-soft underline-offset-2 hover:underline">
235 Buy AI credit or turn on auto-reload
236 </Link>
237 </dd>
238 )}
239 </dl>
240 )}
241 </div>
242 <div className="bg-surface p-4">
243 <p className="text-sm text-muted">This range</p>
244 <dl className="mt-1 space-y-1 text-sm">
245 {lines.map((line, i) => (
246 <div key={line.label} className={cn("flex justify-between gap-3", i === lines.length - 1 && "border-t border-line pt-1 font-medium")}>
247 <dt className={i === lines.length - 1 ? "" : "text-muted"}>{line.label}</dt>
248 <dd className="tabular-nums">
249 {line.minus ? "−" : ""}
250 {money(line.micros)}
251 </dd>
252 </div>
253 ))}
254 </dl>
255 {report.totals.pendingMicros > 0 && (
256 <p className="mt-1 text-xs text-faint">{money(report.totals.pendingMicros)} of it is metered this month and charged when it closes.</p>
257 )}
258 </div>
259 </section>
260 );
261}
262
263// --- The chart -----------------------------------------------------------------------------
264
265const CHART_HEIGHT = 200;
266
267/**
268 * Consumption: one column per day (or week, or month), stacked by product,
269 * money on the one axis. Hovering or focusing a column shows its products;
270 * the legend names them; the table view has every number.
271 */
272export function UsageChart({
273 columns,
274 products,
275 grain,
276 cumulative,
277 grainHref,
278 cumulativeHref,
279}: {
280 columns: Column[];
281 products: ProductUsage[];
282 grain: Grain;
283 cumulative: boolean;
284 grainHref: (grain: Grain) => string;
285 cumulativeHref: string;
286}) {
287 const used = products.filter((p) => p.micros !== 0 && columns.some((c) => (c.parts[p.key] ?? 0) !== 0));
288 const max = Math.max(0, ...columns.map((c) => c.total));
289 const scale = ticks(max);
290 const top = scale[scale.length - 1] || 1;
291 const id = useId();
292 const labelEvery = Math.max(1, Math.ceil(columns.length / 6));
293 return (
294 <section className="rounded-xl border border-line bg-surface p-4 sm:p-5" aria-labelledby={`${id}-title`}>
295 <div className="flex flex-wrap items-center justify-between gap-3">
296 <h2 id={`${id}-title`} className="font-medium">
297 Consumption
298 </h2>
299 <div className="flex flex-wrap items-center gap-2 text-sm">
300 <nav className="flex rounded-md border border-line p-0.5" aria-label="Columns">
301 {(["day", "week", "month"] as Grain[]).map((g) => (
302 <Link
303 key={g}
304 to={grainHref(g)}
305 preventScrollReset
306 aria-current={g === grain ? "true" : undefined}
307 className={cn("rounded px-2 py-0.5 capitalize", g === grain ? "bg-raised text-fg" : "text-muted hover:text-fg")}
308 >
309 {g === "day" ? "Daily" : g === "week" ? "Weekly" : "Monthly"}
310 </Link>
311 ))}
312 </nav>
313 <Link to={cumulativeHref} preventScrollReset className="flex items-center gap-1.5 text-muted hover:text-fg" role="checkbox" aria-checked={cumulative}>
314 <span className={cn("grid size-3.5 place-items-center rounded-sm border", cumulative ? "border-accent bg-accent text-bg" : "border-line-strong")}>
315 {cumulative && <span className="text-[0.6rem] leading-none">✓</span>}
316 </span>
317 Cumulative
318 </Link>
319 </div>
320 </div>
321 {used.length > 0 && (
322 <ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted" aria-label="Legend">
323 {used.map((p) => (
324 <li key={p.key} className="flex items-center gap-1.5">
325 <span className="size-2.5 rounded-sm" style={{ background: productStyle(p.key).color }} />
326 {p.label}
327 </li>
328 ))}
329 </ul>
330 )}
331 <div className="mt-4 flex gap-2">
332 {/* The one money axis: clean ticks, every label different. */}
333 <div className="relative w-12 shrink-0 text-right text-[0.6875rem] text-faint tabular-nums" style={{ height: CHART_HEIGHT }} aria-hidden="true">
334 {scale.map((t) => (
335 <span key={t} className="absolute right-0 -translate-y-1/2" style={{ top: `${100 - (t / top) * 100}%` }}>
336 {axisMoney(t)}
337 </span>
338 ))}
339 </div>
340 <div className="relative min-w-0 flex-1" style={{ height: CHART_HEIGHT }}>
341 {scale.map((t) => (
342 <div key={t} className="absolute inset-x-0 border-t border-line" style={{ top: `${100 - (t / top) * 100}%` }} aria-hidden="true" />
343 ))}
344 <div className="absolute inset-0 flex items-end" role="list" aria-label="Usage per column">
345 {columns.map((column, i) => (
346 <div
347 key={column.key}
348 role="listitem"
349 tabIndex={0}
350 aria-label={`${column.label}: ${money(column.total)}`}
351 className="group relative flex h-full min-w-0 flex-1 flex-col items-center justify-end outline-none"
352 >
353 <div className="flex w-full max-w-6 flex-col-reverse gap-[2px] px-[1px]" style={{ height: `${(column.total / top) * 100}%` }}>
354 {used
355 .filter((p) => (column.parts[p.key] ?? 0) > 0)
356 .map((p, at, all) => (
357 <div
358 key={p.key}
359 className={cn("w-full min-h-[2px]", at === all.length - 1 && "rounded-t")}
360 style={{ flexGrow: column.parts[p.key] ?? 0, flexBasis: 0, background: productStyle(p.key).color }}
361 />
362 ))}
363 </div>
364 <div className="absolute inset-y-0 left-1/2 -z-0 w-full -translate-x-1/2 rounded group-hover:bg-white/[0.03] group-focus-visible:bg-white/[0.05]" aria-hidden="true" />
365 <div
366 className={cn(
367 "pointer-events-none absolute bottom-full z-10 mb-2 hidden w-52 rounded-lg border border-line-strong bg-raised p-2.5 text-xs shadow-xl group-hover:block group-focus-visible:block",
368 i < columns.length / 2 ? "left-0" : "right-0",
369 )}
370 role="tooltip"
371 >
372 <p className="flex justify-between font-medium">
373 <span>{column.label}</span>
374 <span className="tabular-nums">{money(column.total)}</span>
375 </p>
376 <ul className="mt-1.5 space-y-1">
377 {used.map((p) => (
378 <li key={p.key} className="flex items-center justify-between gap-2 text-muted">
379 <span className="flex items-center gap-1.5">
380 <span className="size-2 rounded-sm" style={{ background: productStyle(p.key).color }} />
381 {p.label}
382 </span>
383 <span className="tabular-nums text-fg-soft">{money(column.parts[p.key] ?? 0)}</span>
384 </li>
385 ))}
386 </ul>
387 </div>
388 </div>
389 ))}
390 </div>
391 </div>
392 </div>
393 <div className="ml-14 mt-1.5 flex text-[0.6875rem] text-faint" aria-hidden="true">
394 {columns.map((column, i) => (
395 <span key={column.key} className="min-w-0 flex-1 truncate text-center">
396 {i % labelEvery === 0 ? column.label : ""}
397 </span>
398 ))}
399 </div>
400 {max === 0 && <p className="mt-3 text-center text-sm text-faint">Nothing used in this range.</p>}
401 <details className="mt-3 text-sm">
402 <summary className="cursor-pointer text-xs text-muted hover:text-fg">Show as a table</summary>
403 <div className="mt-2 max-h-72 overflow-auto">
404 <table className="w-full text-left text-xs tabular-nums">
405 <thead className="sticky top-0 bg-surface text-muted">
406 <tr>
407 <th className="py-1 pr-3 font-medium">{grain === "day" ? "Day" : grain === "week" ? "Week of" : "Month"}</th>
408 {used.map((p) => (
409 <th key={p.key} className="py-1 pr-3 text-right font-medium">
410 {p.label}
411 </th>
412 ))}
413 <th className="py-1 text-right font-medium">{cumulative ? "Running total" : "Total"}</th>
414 </tr>
415 </thead>
416 <tbody className="divide-y divide-line">
417 {columns.map((column) => (
418 <tr key={column.key}>
419 <td className="py-1 pr-3">{column.label}</td>
420 {used.map((p) => (
421 <td key={p.key} className="py-1 pr-3 text-right">
422 {money(column.parts[p.key] ?? 0)}
423 </td>
424 ))}
425 <td className="py-1 text-right">{money(column.total)}</td>
426 </tr>
427 ))}
428 </tbody>
429 </table>
430 </div>
431 </details>
432 </section>
433 );
434}
435
436// --- The breakdown ---------------------------------------------------------------------------
437
438/** A meter's days as a small line, in the product's color. */
439export function Sparkline({ values, color }: { values: number[]; color: string }) {
440 const max = Math.max(...values, 0);
441 if (values.length < 2 || max <= 0) return <span className="block h-5 w-20" aria-hidden="true" />;
442 const points = values.map((v, i) => `${(i / (values.length - 1)) * 78 + 1},${19 - (v / max) * 17}`).join(" ");
443 return (
444 <svg viewBox="0 0 80 20" className="h-5 w-20" aria-hidden="true">
445 <polyline points={points} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
446 </svg>
447 );
448}
449
450/** How much of an allowance is used, as a ring. */
451export function AllowanceRing({ used, of }: { used: number; of: number }) {
452 const part = of > 0 ? Math.min(1, used / of) : 0;
453 const r = 7;
454 const c = 2 * Math.PI * r;
455 return (
456 <svg viewBox="0 0 18 18" className="size-4 shrink-0 -rotate-90" aria-hidden="true">
457 <circle cx="9" cy="9" r={r} fill="none" stroke="var(--g1t-line-strong)" strokeWidth="2.5" />
Merge branch 'worktree-agent-ad7c6d88d93adc817'458 <circle cx="9" cy="9" r={r} fill="none" stroke={part >= 1 ? "var(--g1t-warn)" : "var(--g1t-success)"} strokeWidth="2.5" strokeDasharray={`${part * c} ${c}`} strokeLinecap="round" />
Usage, Billing settings and prepaid AI credit; fixes from the UX audit459 </svg>
460 );
461}
462
463function MeterUsed({ meter }: { meter: MeterLine }) {
464 if (meter.allowance) {
465 const unit = meter.allowance.unit;
466 return (
467 <span className="flex items-center gap-1.5 sm:justify-end">
468 <AllowanceRing used={meter.allowance.used} of={meter.allowance.of} />
469 <span>
470 {quantity(meter.allowance.used, unit)} <span className="text-faint">/ {quantity(meter.allowance.of, unit).replace(/ (operations|entries)$/, "")} free</span>
471 </span>
472 </span>
473 );
474 }
475 return <span>{meter.quantity ? quantity(meter.quantity, meter.unit) : <span className="text-faint">—</span>}</span>;
476}
477
478const ROW = "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1 px-4 py-2.5 sm:grid-cols-[minmax(0,1fr)_5.5rem_14rem_6rem]";
479
480/** One meter: its days, how much was used and its charge; open, its projects. */
481function MeterRow({ meter, color, projectHref }: { meter: MeterLine; color: string; projectHref?: (project: string) => string }) {
482 const parts = meter.byProject.filter((p) => p.micros !== 0 || p.quantity !== 0);
483 const body = (
484 <>
485 <span className="flex min-w-0 items-center gap-2">
486 <span className="size-2 shrink-0 rounded-sm" style={{ background: color }} />
Merge branch 'model-routing'487 <span className="min-w-0">
488 <span className="block truncate">{meter.label}</span>
Usage: a meter's note is the site's tooltip, caught by the no-native-title test489 {meter.note && (
490 <Hint label={meter.note}>
491 <span className="block truncate text-[0.6875rem] text-faint">{meter.note}</span>
492 </Hint>
493 )}
Merge branch 'model-routing'494 </span>
Usage, Billing settings and prepaid AI credit; fixes from the UX audit495 {parts.length > 0 && <ChevronDown size={13} className="shrink-0 text-faint transition-transform group-open:rotate-180" />}
496 </span>
497 <span className="hidden sm:block">
498 <Sparkline values={meter.daily} color={color} />
499 </span>
500 <span className="order-3 col-span-2 text-sm text-muted tabular-nums sm:order-none sm:col-span-1 sm:text-right">
501 <MeterUsed meter={meter} />
502 </span>
503 <span className="text-right tabular-nums">
504 {money(meter.micros)}
505 {(meter.pendingMicros ?? 0) > 0 && <span className="block text-[0.6875rem] text-faint">{money(meter.pendingMicros ?? 0)} pending</span>}
506 </span>
507 </>
508 );
509 if (parts.length === 0) return <li className={cn(ROW, "text-sm")}>{body}</li>;
510 return (
511 <li>
512 <details className="group">
513 <summary className={cn(ROW, "cursor-pointer list-none text-sm hover:bg-raised/50 [&::-webkit-details-marker]:hidden")}>{body}</summary>
514 <ul className="border-t border-line bg-bg/40 py-1">
515 {parts.map((part) => (
516 <li key={part.project || "none"} className="flex justify-between gap-3 py-1.5 pl-10 pr-4 text-sm text-muted">
517 <span className="truncate">
518 {part.project ? (
519 projectHref ? (
520 <Link to={projectHref(part.project)} className="hover:text-fg hover:underline">
521 {part.project}
522 </Link>
523 ) : (
524 part.project
525 )
526 ) : (
527 "Not tied to a project"
528 )}
529 </span>
530 <span className="flex gap-4 tabular-nums">
531 {part.quantity > 0 && meter.unit !== "entries" && <span className="hidden sm:inline">{quantity(part.quantity, meter.unit)}</span>}
532 <span className="text-fg-soft">{money(part.micros)}</span>
533 </span>
534 </li>
535 ))}
536 </ul>
537 </details>
538 </li>
539 );
540}
541
542/** The breakdown, by product family (or by project, or by day). */
543export function Breakdown({
544 report,
545 group,
546 columns,
547 projectHref,
548}: {
549 report: UsageReport;
550 group: GroupBy;
551 columns: Column[];
552 projectHref?: (project: string) => string;
553}) {
554 const header = (first: string) => (
555 <div className={cn(ROW, "border-b border-line py-2 text-xs text-muted")}>
556 <span>{first}</span>
557 <span className="hidden sm:block">Trend</span>
558 <span className="hidden text-right sm:block">Usage</span>
559 <span className="text-right">Charge at price</span>
560 </div>
561 );
562 if (group === "project") {
563 const rows = byProject(report);
564 return (
565 <section className="overflow-hidden rounded-xl border border-line bg-surface">
566 <div className="grid grid-cols-[minmax(0,1fr)_auto] border-b border-line px-4 py-2 text-xs text-muted">
567 <span>Project</span>
568 <span>Charge at price</span>
569 </div>
570 {rows.length === 0 ? (
571 <p className="px-4 py-6 text-center text-sm text-faint">Nothing used in this range.</p>
572 ) : (
573 <ul className="divide-y divide-line">
574 {rows.map((row) => (
575 <li key={row.project || "none"} className="px-4 py-2.5 text-sm">
576 <div className="flex justify-between gap-3">
577 <span className="truncate font-medium">
578 {row.project ? projectHref ? <Link to={projectHref(row.project)} className="hover:underline">{row.project}</Link> : row.project : "Not tied to a project"}
579 </span>
580 <span className="tabular-nums">{money(row.micros)}</span>
581 </div>
582 <p className="mt-0.5 text-xs text-faint">{row.meters.map((m) => `${m.label} ${money(m.micros)}`).join(" · ")}</p>
583 </li>
584 ))}
585 </ul>
586 )}
587 </section>
588 );
589 }
590 if (group === "day") {
591 const days = columns.filter((c) => c.total !== 0).reverse();
592 return (
593 <section className="overflow-hidden rounded-xl border border-line bg-surface">
594 <div className="grid grid-cols-[minmax(0,1fr)_auto] border-b border-line px-4 py-2 text-xs text-muted">
595 <span>Day</span>
596 <span>Charge at price</span>
597 </div>
598 {days.length === 0 ? (
599 <p className="px-4 py-6 text-center text-sm text-faint">Nothing used in this range.</p>
600 ) : (
601 <ul className="divide-y divide-line">
602 {days.map((column) => (
603 <li key={column.key} className="px-4 py-2.5 text-sm">
604 <div className="flex justify-between gap-3">
605 <span className="font-medium">{column.label}</span>
606 <span className="tabular-nums">{money(column.total)}</span>
607 </div>
608 <p className="mt-0.5 text-xs text-faint">
609 {Object.entries(column.parts)
610 .filter(([, micros]) => micros !== 0)
611 .map(([product, micros]) => `${productStyle(product).label} ${money(micros)}`)
612 .join(" · ")}
613 </p>
614 </li>
615 ))}
616 </ul>
617 )}
618 </section>
619 );
620 }
621 const products = report.products.filter((p) => shownLines(p.meters).length > 0);
622 return (
623 <section className="overflow-hidden rounded-xl border border-line bg-surface">
624 {header("Product")}
625 {products.length === 0 ? (
626 <p className="px-4 py-6 text-center text-sm text-faint">Nothing used in this range.</p>
627 ) : (
628 products.map((product) => {
629 const color = productStyle(product.key).color;
630 return (
631 <div key={product.key} className="border-b border-line last:border-b-0">
632 <div className="flex items-baseline justify-between gap-3 bg-raised/40 px-4 py-2">
633 <h3 className="text-sm font-medium">{product.label}</h3>
634 <span className="text-sm font-medium tabular-nums">{money(product.micros)}</span>
635 </div>
636 {product.features && product.features.length > 0 && (
637 <p className="px-4 pt-2 text-xs text-faint">
638 {product.features.map((f) => `${f.label}${f.count ? ` (${f.count})` : ""} ${money(f.micros)}`).join(" · ")}
639 </p>
640 )}
Merge branch 'model-routing'641 {product.key === "agent" && report.models && report.models.length > 0 && (
642 <p className="px-4 pt-1 text-xs text-faint">
643 Tokens by model:{" "}
644 {report.models.map((m) => `${m.model} ${quantity(m.input + m.output + m.cacheRead + m.cacheWrite, "tokens")}`).join(" · ")}
645 </p>
646 )}
Usage, Billing settings and prepaid AI credit; fixes from the UX audit647 <ul className="divide-y divide-line/60">
648 {shownLines(product.meters).map((meter) => (
649 <MeterRow key={meter.key} meter={meter} color={color} projectHref={projectHref} />
650 ))}
651 </ul>
652 </div>
653 );
654 })
655 )}
656 </section>
657 );
658}
659
660// --- Loading --------------------------------------------------------------------------------
661
662/** Each region's shape while the report is on its way: the same heights, so nothing moves. */
663export function UsageSkeleton({ children }: { children?: ReactNode }) {
664 return (
665 <div className="space-y-6" aria-busy="true">
666 <span role="status" className="sr-only">
667 Loading usage…
668 </span>
669 {children}
670 <div className="grid gap-px overflow-hidden rounded-xl border border-line bg-line sm:grid-cols-3" aria-hidden="true">
671 {[0, 1, 2].map((i) => (
672 <div key={i} className="h-[7.5rem] bg-surface p-4">
673 <Skeleton className="h-3 w-28" />
674 <Skeleton className="mt-3 h-5 w-20" />
675 <Skeleton className="mt-3 h-1.5 w-full" />
676 </div>
677 ))}
678 </div>
679 <div className="rounded-xl border border-line bg-surface p-5" aria-hidden="true">
680 <Skeleton className="h-4 w-28" />
681 <div className="mt-6 flex h-[200px] items-end gap-1">
682 {Array.from({ length: 24 }, (_, i) => (
683 <Skeleton key={i} className="flex-1 rounded-t" style={{ height: `${20 + ((i * 37) % 60)}%` }} />
684 ))}
685 </div>
686 </div>
687 <div className="rounded-xl border border-line bg-surface" aria-hidden="true">
688 {[0, 1, 2, 3, 4].map((i) => (
689 <div key={i} className="flex items-center justify-between border-b border-line px-4 py-3 last:border-b-0">
690 <Skeleton className="h-3 w-40" />
691 <Skeleton className="h-3 w-16" />
692 </div>
693 ))}
694 </div>
695 </div>
696 );
697}

This file's history is long; its oldest lines are credited to the oldest commit read.