Skip to content

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

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