flagon-io/g1t

public

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/apps/sudo/app/lib/chart.ts

156 lines5,947 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales1/**
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/**
25 * The top of an axis for values up to `max`: the next 1, 2, 2.5 or 5
26 * times a power of ten, so its ticks are round numbers. A dollar when
27 * there is nothing to show, so an empty chart still has an axis.
28 */
29export function niceCeiling(max: number, floor = 1_000_000): number {
30 const value = Math.max(max, floor);
31 const power = 10 ** Math.floor(Math.log10(value));
32 for (const step of [1, 2, 2.5, 5, 10]) {
33 if (step * power >= value) return step * power;
34 }
35 return 10 * power;
36}
37
38/** Evenly spaced ticks from zero to `top`, inclusive. */
39export function ticks(top: number, count = 4): number[] {
40 return Array.from({ length: count + 1 }, (_, index) => (top / count) * index);
41}
42
43/** A margin and its share of what was charged; no share when nothing was. */
44export function marginOf(chargedMicros: number, costMicros: number): { micros: number; percent: number | null } {
45 const micros = chargedMicros - costMicros;
46 return { micros, percent: chargedMicros > 0 ? Math.round((micros / chargedMicros) * 100) : null };
47}
48
49/** The change from one figure to the next, as a whole percent; none from zero. */
50export function change(before: number, after: number): number | null {
51 if (before <= 0) return null;
52 return Math.round(((after - before) / before) * 100);
53}
54
55export type Rect = { x: number; y: number; width: number; height: number };
56
57export type MonthBars = {
58 month: string;
59 label: string;
60 /** Where the month's slot is, for its label and hover target. */
61 slot: Rect;
62 charged: Rect;
63 cost: Rect;
64 figures: MonthFigures;
65};
66
67export type BarChart = {
68 width: number;
69 height: number;
70 /** The plot area, inside the axis labels. */
71 plot: Rect;
72 top: number;
73 /** Each tick's value and its y. */
74 ticks: { value: number; y: number }[];
75 bars: MonthBars[];
76};
77
78/**
79 * Two bars a month, charged beside cost, scaled to one axis from zero.
80 * Bars keep a 2px gap between them and a minimum visible height when
81 * their value is above zero, so a small month still shows.
82 */
83export function monthBars(
84 months: MonthFigures[],
85 { width = 600, height = 220, left = 52, right = 8, top: padTop = 10, bottom = 26, count = 4 } = {},
86): BarChart {
87 const plot = { x: left, y: padTop, width: Math.max(1, width - left - right), height: Math.max(1, height - padTop - bottom) };
88 const top = niceCeiling(Math.max(0, ...months.flatMap((month) => [month.chargedMicros, month.costMicros])));
89 const baseline = plot.y + plot.height;
90 const scale = (value: number) => {
91 const clamped = Math.max(0, value);
92 const h = (clamped / top) * plot.height;
93 return clamped > 0 ? Math.max(1.5, h) : 0;
94 };
95 const slotWidth = months.length > 0 ? plot.width / months.length : plot.width;
96 const gap = 2;
97 const barWidth = Math.max(2, Math.min(28, slotWidth * 0.3));
98 const bars = months.map((figures, index) => {
99 const slotX = plot.x + index * slotWidth;
100 const center = slotX + slotWidth / 2;
101 const chargedHeight = scale(figures.chargedMicros);
102 const costHeight = scale(figures.costMicros);
103 return {
104 month: figures.month,
105 label: monthShort(figures.month),
106 slot: { x: slotX, y: plot.y, width: slotWidth, height: plot.height },
107 charged: { x: center - gap / 2 - barWidth, y: baseline - chargedHeight, width: barWidth, height: chargedHeight },
108 cost: { x: center + gap / 2, y: baseline - costHeight, width: barWidth, height: costHeight },
109 figures,
110 };
111 });
112 return {
113 width,
114 height,
115 plot,
116 top,
117 ticks: ticks(top, count).map((value) => ({ value, y: baseline - (value / top) * plot.height })),
118 bars,
119 };
120}
121
122/**
123 * A bar's outline with its top corners rounded and its foot square on
124 * the baseline. Nothing for an empty bar.
125 */
126export function barPath({ x, y, width, height }: Rect, radius = 4): string {
127 if (height <= 0 || width <= 0) return "";
128 const r = Math.min(radius, width / 2, height);
129 const bottom = y + height;
130 const round = (value: number) => Math.round(value * 100) / 100;
131 return [
132 `M${round(x)} ${round(bottom)}`,
133 `V${round(y + r)}`,
134 `Q${round(x)} ${round(y)} ${round(x + r)} ${round(y)}`,
135 `H${round(x + width - r)}`,
136 `Q${round(x + width)} ${round(y)} ${round(x + width)} ${round(y + r)}`,
137 `V${round(bottom)}`,
138 "Z",
139 ].join(" ");
140}
141
142/** An axis label: `$0`, `$250`, `$1.5k`, `$20k`, `$1.2M`. */
143export function axisDollars(micros: number): string {
144 const dollars = micros / 1_000_000;
145 const trim = (value: number) => String(Number(value.toFixed(1)));
146 if (dollars >= 1_000_000) return `$${trim(dollars / 1_000_000)}M`;
147 if (dollars >= 1_000) return `$${trim(dollars / 1_000)}k`;
148 if (dollars >= 1 || dollars === 0) return `$${trim(dollars)}`;
149 return `$${dollars.toFixed(2)}`;
150}
151
152/** Each value's share of the largest, from 0 to 1, for a row of bars. */
153export function shares(values: number[]): number[] {
154 const max = Math.max(0, ...values);
155 return values.map((value) => (max > 0 ? Math.max(0, value) / max : 0));
156}