Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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