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