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