| 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 | |
| 11 | import type { MeterLine, UsageDay, UsageReport, UsageTotals } from "@g1t/contracts"; |
| 12 | |
| 13 | const MICROS_PER_DOLLAR = 1_000_000; |
| 14 | const 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). */ |
| 17 | export 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 | |
| 28 | export 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). */ |
| 33 | export 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; |
| 41 | export type Period = keyof typeof PERIODS; |
| 42 | |
| 43 | export type Grain = "day" | "week" | "month"; |
| 44 | export type GroupBy = "product" | "project" | "day"; |
| 45 | |
| 46 | function day(at: Date): string { |
| 47 | return at.toISOString().slice(0, 10); |
| 48 | } |
| 49 | |
| 50 | function 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. */ |
| 55 | export 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`. */ |
| 81 | export 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. */ |
| 90 | export 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. */ |
| 97 | export type Column = { key: string; label: string; from: string; until: string; parts: Record<string, number>; total: number }; |
| 98 | |
| 99 | function 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 | |
| 109 | function 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 | */ |
| 119 | export 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. */ |
| 150 | export 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 | */ |
| 160 | export 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`). */ |
| 172 | export 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`. */ |
| 180 | export 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 | |
| 187 | function 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`. */ |
| 195 | export 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`. */ |
| 219 | export 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. */ |
| 233 | export 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. */ |
| 243 | export 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. */ |
| 260 | export 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. */ |
| 278 | export 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). */ |
| 283 | export const STAFF_WORKSPACE = "flagon-io"; |
| 284 | export function isStaff(viewer: { workspaces?: { slug: string }[] } | null | undefined): boolean { |
| 285 | return !!viewer?.workspaces?.some((w) => w.slug === STAFF_WORKSPACE); |
| 286 | } |