Skip to content

g1t/apps/web/app/lib/usage.ts

286 lines13,163 bytesCodeBlame

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 audit1/**
2 * The Usage page's arithmetic: which days a period covers, how a report's
3 * days become the chart's columns, clean axis ticks, quantities in their
4 * units, and the CSV export. Pure, so it can be tested.
5 *
6 * Every figure is usage at price (`UsageReport.totals.priceMicros`), the
7 * one number mission control, the agent fleet, Usage and Billing all show:
8 * what was charged, plus what included usage, credit or a discount paid.
9 */
10
11import type { MeterLine, UsageDay, UsageReport, UsageTotals } from "@g1t/contracts";
12
13const MICROS_PER_DOLLAR = 1_000_000;
14const DAY_MS = 86_400_000;
15
16/** The product families in order, with their names and colors (the chart's categorical slots, validated against the dark surface). */
17export const PRODUCT_STYLE: { key: string; label: string; color: string }[] = [
18 { key: "agent", label: "Agent", color: "#3987e5" },
19 { key: "sandboxes", label: "Sandboxes", color: "#d95926" },
20 { key: "gateway", label: "AI Gateway", color: "#199e70" },
21 { key: "deployments", label: "Deployments", color: "#c98500" },
22 { key: "git_storage", label: "Git & storage", color: "#d55181" },
23 { key: "packages", label: "Packages", color: "#008300" },
24 { key: "security", label: "Security & quality", color: "#9085e9" },
25 { key: "search", label: "Search", color: "#e66767" },
26];
27
28export function productStyle(key: string): { key: string; label: string; color: string } {
29 return PRODUCT_STYLE.find((p) => p.key === key) ?? { key, label: key, color: "#86868e" };
30}
31
32/** The periods the page offers. Billing cycles are calendar months (UTC). */
33export const PERIODS = {
34 cycle: "Current billing cycle",
35 last_cycle: "Last billing cycle",
36 "7d": "Last 7 days",
37 "30d": "Last 30 days",
38 "90d": "Last 90 days",
39 custom: "Custom range",
40} as const;
41export type Period = keyof typeof PERIODS;
42
43export type Grain = "day" | "week" | "month";
44export type GroupBy = "product" | "project" | "day";
45
46function day(at: Date): string {
47 return at.toISOString().slice(0, 10);
48}
49
50function isDay(text: string | null | undefined): text is string {
51 return !!text && /^\d{4}-\d{2}-\d{2}$/.test(text) && !Number.isNaN(Date.parse(`${text}T00:00:00Z`)) && day(new Date(`${text}T00:00:00Z`)) === text;
52}
53
54/** The days a period covers, both included, as `YYYY-MM-DD` (UTC), and the period actually used. */
55export function resolveRange(
56 asked: string | null | undefined,
57 custom: { from?: string | null; until?: string | null },
58 now = new Date(),
59): { period: Period; from: string; until: string } {
60 const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
61 const period: Period = asked && asked in PERIODS ? (asked as Period) : "cycle";
62 if (period === "custom" && isDay(custom.from) && isDay(custom.until)) {
63 const [from, until] = custom.from <= custom.until ? [custom.from, custom.until] : [custom.until, custom.from];
64 // At most 400 days, as billing reads them.
65 const earliest = day(new Date(Date.parse(`${until}T00:00:00Z`) - 399 * DAY_MS));
66 return { period, from: from < earliest ? earliest : from, until };
67 }
68 if (period === "last_cycle") {
69 const start = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 1, 1));
70 const end = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 0));
71 return { period, from: day(start), until: day(end) };
72 }
73 if (period === "7d" || period === "30d" || period === "90d") {
74 const days = Number(period.slice(0, -1));
75 return { period, from: day(new Date(today.getTime() - (days - 1) * DAY_MS)), until: day(today) };
76 }
77 return { period: "cycle", from: day(new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1))), until: day(today) };
78}
79
80/** `Oct 1 – Oct 8, 2026`. */
81export function rangeLabel(from: string, until: string): string {
82 const f = new Date(`${from}T00:00:00Z`);
83 const u = new Date(`${until}T00:00:00Z`);
84 const short = (d: Date, year: boolean) =>
85 d.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC", ...(year ? { year: "numeric" } : {}) });
86 return from === until ? short(u, true) : `${short(f, f.getUTCFullYear() !== u.getUTCFullYear())} – ${short(u, true)}`;
87}
88
89/** Every day from `from` to `until`, both included. */
90export function daysIn(from: string, until: string): string[] {
91 const out: string[] = [];
92 for (let t = Date.parse(`${from}T00:00:00Z`); t <= Date.parse(`${until}T00:00:00Z`); t += DAY_MS) out.push(day(new Date(t)));
93 return out;
94}
95
96/** One column of the chart: a day, a week (from its Monday) or a month, with each product's part. */
97export type Column = { key: string; label: string; from: string; until: string; parts: Record<string, number>; total: number };
98
99function columnKey(d: string, grain: Grain): string {
100 if (grain === "month") return d.slice(0, 7);
101 if (grain === "week") {
102 const t = new Date(`${d}T00:00:00Z`);
103 const monday = new Date(t.getTime() - ((t.getUTCDay() + 6) % 7) * DAY_MS);
104 return day(monday);
105 }
106 return d;
107}
108
109function columnLabel(key: string, grain: Grain): string {
110 if (grain === "month") return new Date(`${key}-01T00:00:00Z`).toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
111 return new Date(`${key}T00:00:00Z`).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
112}
113
114/**
115 * The chart's columns: every day (or week, or month) of the range, zeros
116 * included, each product's usage at price. Cumulative adds each column to
117 * the ones before, product by product.
118 */
119export function columns(days: UsageDay[], from: string, until: string, grain: Grain = "day", cumulative = false): Column[] {
120 const out: Column[] = [];
121 const at = new Map<string, Column>();
122 for (const d of daysIn(from, until)) {
123 const key = columnKey(d, grain);
124 let column = at.get(key);
125 if (!column) {
126 column = { key, label: columnLabel(key, grain), from: d, until: d, parts: {}, total: 0 };
127 at.set(key, column);
128 out.push(column);
129 }
130 column.until = d;
131 }
132 for (const d of days) {
133 const column = at.get(columnKey(d.day, grain));
134 if (!column) continue;
135 column.parts[d.product] = (column.parts[d.product] ?? 0) + d.micros;
136 column.total += d.micros;
137 }
138 if (cumulative) {
139 const running: Record<string, number> = {};
140 for (const column of out) {
141 for (const [product, micros] of Object.entries(column.parts)) running[product] = (running[product] ?? 0) + micros;
142 column.parts = { ...running };
143 column.total = Object.values(running).reduce((a, b) => a + b, 0);
144 }
145 }
146 return out;
147}
148
149/** The finest grain that keeps the chart readable: days up to 45 of them, weeks up to 200, months past that. */
150export function defaultGrain(from: string, until: string): Grain {
151 const days = daysIn(from, until).length;
152 return days <= 45 ? "day" : days <= 200 ? "week" : "month";
153}
154
155/**
156 * Clean ticks for a money axis from 0 to at least `max` micros: 1, 2 or 5
157 * times a power of ten, every label different. With nothing used, one tick
158 * at $0.
159 */
160export function ticks(max: number, count = 4): number[] {
161 if (!(max > 0)) return [0];
162 const raw = max / count;
163 const power = 10 ** Math.floor(Math.log10(raw));
164 const step = [1, 2, 2.5, 5, 10].map((m) => m * power).find((s) => s >= raw) ?? 10 * power;
165 const out: number[] = [];
166 for (let v = 0; v < max + step / 2 && out.length <= count + 1; v += step) out.push(Math.round(v));
167 if (out[out.length - 1]! < max) out.push(Math.round(out[out.length - 1]! + step));
168 return out;
169}
170
171/** Money, to the cent; under a cent, to as many places as it takes to say something (`$0.004`). */
172export function money(micros: number): string {
173 const sign = micros < 0 ? "−" : "";
174 const d = Math.abs(micros) / MICROS_PER_DOLLAR;
175 const digits = d === 0 || d >= 0.01 ? 2 : d >= 0.001 ? 3 : 4;
176 return `${sign}$${d.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits })}`;
177}
178
179/** An axis label: `$0`, `$0.50`, `$2`, `$1.2K`. */
180export function axisMoney(micros: number): string {
181 const d = micros / MICROS_PER_DOLLAR;
182 if (d >= 1000) return `$${(d / 1000).toLocaleString("en-US", { maximumFractionDigits: 1 })}K`;
183 if (Number.isInteger(d)) return `$${d}`;
184 return `$${d.toLocaleString("en-US", { minimumFractionDigits: d < 0.1 ? 3 : 2, maximumFractionDigits: d < 0.1 ? 3 : 2 })}`;
185}
186
187function compact(n: number): string {
188 if (n >= 1e9) return `${(n / 1e9).toLocaleString("en-US", { maximumFractionDigits: 1 })}B`;
189 if (n >= 1e6) return `${(n / 1e6).toLocaleString("en-US", { maximumFractionDigits: 1 })}M`;
190 if (n >= 1e4) return `${(n / 1e3).toLocaleString("en-US", { maximumFractionDigits: 1 })}K`;
191 return Math.round(n).toLocaleString("en-US");
192}
193
194/** How much of a meter, in its unit: `1.2M tokens`, `3h 12m`, `504 MB`, `12 entries`. */
195export function quantity(amount: number, unit: string): string {
196 switch (unit) {
197 case "tokens":
198 return `${compact(amount)} tokens`;
199 case "seconds": {
200 const s = Math.round(amount);
201 const h = Math.floor(s / 3600);
202 const m = Math.floor((s % 3600) / 60);
203 return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s % 60}s` : `${s}s`;
204 }
205 case "bytes":
206 return bytes(amount);
207 case "operations":
208 return `${compact(amount)} ${amount === 1 ? "operation" : "operations"}`;
209 case "requests":
210 return `${compact(amount)} ${amount === 1 ? "request" : "requests"}`;
211 case "micros":
212 return money(amount);
213 default:
214 return `${compact(amount)} ${amount === 1 ? "entry" : "entries"}`;
215 }
216}
217
218/** Storage in powers of ten, as it is priced: `504 MB`, `1 TB`. */
219export function bytes(n: number): string {
220 const units: [number, string][] = [
221 [1e12, "TB"],
222 [1e9, "GB"],
223 [1e6, "MB"],
224 [1e3, "KB"],
225 ];
226 for (const [size, name] of units) {
227 if (n >= size) return `${(n / size).toLocaleString("en-US", { maximumFractionDigits: n / size >= 100 ? 0 : 1 })} ${name}`;
228 }
229 return `${Math.round(n)} B`;
230}
231
232/** What paid for usage, in order, each line with anything to show: the receipt under the totals. */
233export function receipt(totals: UsageTotals, discountPercent: number | null | undefined): { label: string; micros: number; minus: boolean }[] {
234 const lines: { label: string; micros: number; minus: boolean }[] = [{ label: "Usage at price", micros: totals.priceMicros, minus: false }];
235 if (totals.discountMicros > 0) lines.push({ label: `Discount${discountPercent ? ` (${discountPercent}%)` : ""}`, micros: totals.discountMicros, minus: true });
236 if (totals.includedMicros > 0) lines.push({ label: "Included usage and pools", micros: totals.includedMicros, minus: true });
237 if (totals.creditsMicros > 0) lines.push({ label: "Credits applied", micros: totals.creditsMicros, minus: true });
238 lines.push({ label: "Charged", micros: totals.chargedMicros, minus: false });
239 return lines;
240}
241
242/** Rows of the breakdown when grouped by project: each project's usage across every meter. */
243export function byProject(report: Pick<UsageReport, "products">): { project: string; micros: number; meters: { label: string; micros: number }[] }[] {
244 const rows = new Map<string, { project: string; micros: number; meters: { label: string; micros: number }[] }>();
245 for (const product of report.products) {
246 for (const meter of product.meters) {
247 for (const part of meter.byProject) {
248 if (part.micros === 0) continue;
249 const row = rows.get(part.project) ?? { project: part.project, micros: 0, meters: [] };
250 row.micros += part.micros;
251 row.meters.push({ label: meter.label, micros: part.micros });
252 rows.set(part.project, row);
253 }
254 }
255 }
256 return [...rows.values()].sort((a, b) => b.micros - a.micros);
257}
258
259/** The report as CSV: one row per meter, day and amount at price. */
260export function usageCsv(report: Pick<UsageReport, "from" | "until" | "products">): string {
261 const days = daysIn(report.from, report.until);
262 const quote = (s: string) => (/[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
263 const lines = ["day,product,meter,usd"];
264 for (const product of report.products) {
265 for (const meter of product.meters) {
266 meter.daily.forEach((micros, i) => {
267 if (micros !== 0) lines.push([days[i] ?? "", quote(product.label), quote(meter.label), (micros / MICROS_PER_DOLLAR).toFixed(6)].join(","));
268 });
269 if ((meter.pendingMicros ?? 0) !== 0) {
270 lines.push(["pending", quote(product.label), quote(meter.label), ((meter.pendingMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6)].join(","));
271 }
272 }
273 }
274 return `${lines.join("\n")}\n`;
275}
276
277/** The meters worth a row: anything used, and those with an allowance. */
278export function shownLines(meters: MeterLine[]): MeterLine[] {
279 return meters.filter((m) => m.micros !== 0 || m.quantity !== 0 || m.allowance);
280}
281
282/** Who sees test-mode hints: g1t's own people (members of Flagon's workspace). */
283export const STAFF_WORKSPACE = "flagon-io";
284export function isStaff(viewer: { workspaces?: { slug: string }[] } | null | undefined): boolean {
285 return !!viewer?.workspaces?.some((w) => w.slug === STAFF_WORKSPACE);
286}