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