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.

163 linesCodeBlameRaw
1/**
2 * The arithmetic behind sudo's charts, kept apart from the SVG so it can
3 * be tested under Node. Money is in micros throughout.
4 */
5import type { MonthFigures } from "@g1t/contracts";
6
7const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
8const MONTHS_LONG = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
9
10/** `2026-10` → `Oct`; anything else as it came. */
11export function monthShort(month: string): string {
12 const match = /^(\d{4})-(\d{2})$/.exec(month);
13 const index = match ? Number(match[2]) - 1 : -1;
14 return MONTHS[index] ?? month;
15}
16
17/** `2026-10` → `October 2026`. */
18export function monthLong(month: string): string {
19 const match = /^(\d{4})-(\d{2})$/.exec(month);
20 const index = match ? Number(match[2]) - 1 : -1;
21 return match && MONTHS_LONG[index] ? `${MONTHS_LONG[index]} ${match[1]}` : month;
22}
23
24/** `2026-10-14` → `Oct 14`; anything else as it came. */
25export function dayShort(day: string): string {
26 const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
27 const name = match ? MONTHS[Number(match[2]) - 1] : undefined;
28 return match && name ? `${name} ${Number(match[3])}` : day;
29}
30
31/**
32 * The top of an axis for values up to `max`: the next 1, 2, 2.5 or 5
33 * times a power of ten, so its ticks are round numbers. A dollar when
34 * there is nothing to show, so an empty chart still has an axis.
35 */
36export function niceCeiling(max: number, floor = 1_000_000): number {
37 const value = Math.max(max, floor);
38 const power = 10 ** Math.floor(Math.log10(value));
39 for (const step of [1, 2, 2.5, 5, 10]) {
40 if (step * power >= value) return step * power;
41 }
42 return 10 * power;
43}
44
45/** Evenly spaced ticks from zero to `top`, inclusive. */
46export function ticks(top: number, count = 4): number[] {
47 return Array.from({ length: count + 1 }, (_, index) => (top / count) * index);
48}
49
50/** A margin and its share of what was charged; no share when nothing was. */
51export function marginOf(chargedMicros: number, costMicros: number): { micros: number; percent: number | null } {
52 const micros = chargedMicros - costMicros;
53 return { micros, percent: chargedMicros > 0 ? Math.round((micros / chargedMicros) * 100) : null };
54}
55
56/** The change from one figure to the next, as a whole percent; none from zero. */
57export function change(before: number, after: number): number | null {
58 if (before <= 0) return null;
59 return Math.round(((after - before) / before) * 100);
60}
61
62export type Rect = { x: number; y: number; width: number; height: number };
63
64export type MonthBars = {
65 month: string;
66 label: string;
67 /** Where the month's slot is, for its label and hover target. */
68 slot: Rect;
69 charged: Rect;
70 cost: Rect;
71 figures: MonthFigures;
72};
73
74export type BarChart = {
75 width: number;
76 height: number;
77 /** The plot area, inside the axis labels. */
78 plot: Rect;
79 top: number;
80 /** Each tick's value and its y. */
81 ticks: { value: number; y: number }[];
82 bars: MonthBars[];
83};
84
85/**
86 * Two bars a month, charged beside cost, scaled to one axis from zero.
87 * Bars keep a 2px gap between them and a minimum visible height when
88 * their value is above zero, so a small month still shows.
89 */
90export function monthBars(
91 months: MonthFigures[],
92 { width = 600, height = 220, left = 52, right = 8, top: padTop = 10, bottom = 26, count = 4 } = {},
93): BarChart {
94 const plot = { x: left, y: padTop, width: Math.max(1, width - left - right), height: Math.max(1, height - padTop - bottom) };
95 const top = niceCeiling(Math.max(0, ...months.flatMap((month) => [month.chargedMicros, month.costMicros])));
96 const baseline = plot.y + plot.height;
97 const scale = (value: number) => {
98 const clamped = Math.max(0, value);
99 const h = (clamped / top) * plot.height;
100 return clamped > 0 ? Math.max(1.5, h) : 0;
101 };
102 const slotWidth = months.length > 0 ? plot.width / months.length : plot.width;
103 const gap = 2;
104 const barWidth = Math.max(2, Math.min(28, slotWidth * 0.3));
105 const bars = months.map((figures, index) => {
106 const slotX = plot.x + index * slotWidth;
107 const center = slotX + slotWidth / 2;
108 const chargedHeight = scale(figures.chargedMicros);
109 const costHeight = scale(figures.costMicros);
110 return {
111 month: figures.month,
112 label: monthShort(figures.month),
113 slot: { x: slotX, y: plot.y, width: slotWidth, height: plot.height },
114 charged: { x: center - gap / 2 - barWidth, y: baseline - chargedHeight, width: barWidth, height: chargedHeight },
115 cost: { x: center + gap / 2, y: baseline - costHeight, width: barWidth, height: costHeight },
116 figures,
117 };
118 });
119 return {
120 width,
121 height,
122 plot,
123 top,
124 ticks: ticks(top, count).map((value) => ({ value, y: baseline - (value / top) * plot.height })),
125 bars,
126 };
127}
128
129/**
130 * A bar's outline with its top corners rounded and its foot square on
131 * the baseline. Nothing for an empty bar.
132 */
133export function barPath({ x, y, width, height }: Rect, radius = 4): string {
134 if (height <= 0 || width <= 0) return "";
135 const r = Math.min(radius, width / 2, height);
136 const bottom = y + height;
137 const round = (value: number) => Math.round(value * 100) / 100;
138 return [
139 `M${round(x)} ${round(bottom)}`,
140 `V${round(y + r)}`,
141 `Q${round(x)} ${round(y)} ${round(x + r)} ${round(y)}`,
142 `H${round(x + width - r)}`,
143 `Q${round(x + width)} ${round(y)} ${round(x + width)} ${round(y + r)}`,
144 `V${round(bottom)}`,
145 "Z",
146 ].join(" ");
147}
148
149/** An axis label: `$0`, `$250`, `$1.5k`, `$20k`, `$1.2M`. */
150export function axisDollars(micros: number): string {
151 const dollars = micros / 1_000_000;
152 const trim = (value: number) => String(Number(value.toFixed(1)));
153 if (dollars >= 1_000_000) return `$${trim(dollars / 1_000_000)}M`;
154 if (dollars >= 1_000) return `$${trim(dollars / 1_000)}k`;
155 if (dollars >= 1 || dollars === 0) return `$${trim(dollars)}`;
156 return `$${dollars.toFixed(2)}`;
157}
158
159/** Each value's share of the largest, from 0 to 1, for a row of bars. */
160export function shares(values: number[]): number[] {
161 const max = Math.max(0, ...values);
162 return values.map((value) => (max > 0 ? Math.max(0, value) / max : 0));
163}