Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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