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