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