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