/** * Terminal drawing primitives for the stats dashboard: colour, block-glyph * charts, and the alternate-screen plumbing. * * Hand-rolled for the same reason apps/api/src/charts.ts and png.ts are — the * repo carries near-zero runtime dependencies, and everything a progress * dashboard needs is a bar, a sparkline and a coloured cell. The palette is * charts.ts's, so the terminal, the README SVGs and the digest email are the * same picture in three renderers. * * Side-effect-free on import except for reading the colour environment once; * nothing is written to the terminal until openScreen() is called. */ // ── palette (apps/api/src/charts.ts) ───────────────────────────── export const FG = "#fafafa"; // zinc-50 text export const MUTED = "#a1a1aa"; // zinc-400 secondary text export const LINE = "#27272a"; // zinc-800 structure / empty track export const GREEN = "#16a34a"; // green-600 (core / pass) export const BLUE = "#2563eb"; // blue-600 (optional) export const YELLOW = "#eab308"; // yellow-500 (attention) export const RED = "#f87171"; // red-400 (fail, on dark) /** GitHub dark-mode contribution hues; level 0 is the empty cell. */ export const GREENS = ["#27272a", "#0e4429", "#006d32", "#26a641", "#39d353"]; // ── colour ─────────────────────────────────────────────────────── /** * Honour NO_COLOR and pipes, and let FORCE_COLOR override both — the dashboard * is legible without colour, and `bun run stats | less -R` should still be. */ export const COLOR = process.env.FORCE_COLOR !== undefined ? process.env.FORCE_COLOR !== "0" : process.env.NO_COLOR === undefined && process.stdout.isTTY === true; const RESET = "\x1b[0m"; function channels(hex: string): string { return `${parseInt(hex.slice(1, 3), 16)};${parseInt(hex.slice(3, 5), 16)};${parseInt(hex.slice(5, 7), 16)}`; } export function fg(hex: string, text: string): string { return COLOR ? `\x1b[38;2;${channels(hex)}m${text}${RESET}` : text; } export function bg(hex: string, text: string): string { return COLOR ? `\x1b[48;2;${channels(hex)}m${text}${RESET}` : text; } export function bold(text: string): string { return COLOR ? `\x1b[1m${text}${RESET}` : text; } const ANSI = /\x1b\[[0-9;]*m/g; /** Printable width — every glyph used here is one cell wide. */ export function width(text: string): number { return text.replace(ANSI, "").length; } /** Pad or cut a *plain* string to exactly n cells; colour it afterwards. */ export function cell(text: string, n: number, align: "left" | "right" = "left"): string { const clipped = text.length > n ? `${text.slice(0, Math.max(0, n - 1))}…` : text; return align === "left" ? clipped.padEnd(n) : clipped.padStart(n); } /** Pad an already-coloured string to n cells. */ export function padTo(text: string, n: number): string { return text + " ".repeat(Math.max(0, n - width(text))); } // ── charts ─────────────────────────────────────────────────────── const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]; const SPARKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; /** * Filled and empty. The two differ by GLYPH as well as by colour, so a bar * still reads under NO_COLOR or in a pipe — every chart here has to survive * `bun run stats > notes.txt`. */ const FILL = "█"; const TRACK = "░"; /** * One bar over a muted track, `cells` wide. Sub-cell remainders use the * eighth-block glyphs, so one solved problem out of 161 still shows. */ export function bar(value: number, total: number, cells: number, color = GREEN): string { const units = Math.round(Math.max(0, Math.min(1, total > 0 ? value / total : 0)) * cells * 8); const filled = FILL.repeat(Math.floor(units / 8)) + EIGHTHS[units % 8]!; return fg(color, filled) + fg(LINE, TRACK.repeat(Math.max(0, cells - filled.length))); } export interface Segment { value: number; color: string; /** Defaults to the solid block; pass TRACK-ish glyphs for "not done yet". */ glyph?: string; } /** * Segments laid end to end, `total` mapped to `cells`. Anything short of * `total` is left BLANK, not tracked: bars sharing a scale must show their * different lengths. A caller wanting a visible remainder passes it as a * segment of its own. * * Non-zero segments are floored but never vanish; the overshoot that floor * creates is taken back from the widest segment, which can spare it. */ export function stackedBar(segments: Segment[], total: number, cells: number): string { if (total <= 0) return " ".repeat(cells); const widths = segments.map((s) => s.value > 0 ? Math.max(1, Math.floor((s.value / total) * cells)) : 0, ); let over = widths.reduce((a, b) => a + b, 0) - cells; while (over > 0) { const i = widths.indexOf(Math.max(...widths)); if (widths[i]! <= 1) break; widths[i]!--; over--; } const drawn = widths.map((n, i) => fg(segments[i]!.color, (segments[i]!.glyph ?? FILL).repeat(n))); return drawn.join("") + " ".repeat(Math.max(0, cells - widths.reduce((a, b) => a + b, 0))); } /** * One cell of the campaign heatmap. With colour it is the GREENS ramp the * README and the digest use; without it, the same five levels as shading. */ export function heatCell(attempts: number, body: string): string { const level = Math.min(attempts, GREENS.length - 1); return COLOR ? bg(GREENS[level]!, body) : [" ", "·", "▒", "▓", "█"][level]!.repeat(body.length); } /** One glyph per value, scaled to the largest. All-zero renders as a floor. */ export function sparkline(values: number[], color = GREEN): string { const peak = Math.max(...values, 0); const line = values .map((v) => (peak === 0 ? SPARKS[0]! : SPARKS[Math.min(7, Math.round((v / peak) * 7))]!)) .join(""); return fg(color, line); } // ── layout ─────────────────────────────────────────────────────── /** A section rule that fills the width: `── name ────────────`. */ export function rule(title: string, cells: number): string { const label = ` ${title} `; return ( fg(LINE, "──") + fg(MUTED, label) + fg(LINE, "─".repeat(Math.max(0, cells - 2 - label.length))) ); } // ── screen ─────────────────────────────────────────────────────── const ALT_ON = "\x1b[?1049h\x1b[?25l"; const ALT_OFF = "\x1b[?25h\x1b[?1049l"; export interface Screen { readonly rows: number; readonly columns: number; /** Draw a frame, clipped to the terminal height. */ draw(lines: string[]): void; close(): void; } /** * Take over the terminal: alternate buffer, hidden cursor, raw keys. `onKey` * receives the raw chunk (`"\t"`, `"\x1b[B"`, `"q"`, …) — decoding belongs to * the app, which is the only thing that knows what a key means. * * The caller MUST close() in a finally: raw mode survives a thrown exception. */ export function openScreen(onKey: (chunk: string) => void, onResize: () => void): Screen { const out = process.stdout; const input = process.stdin; out.write(ALT_ON); input.setRawMode?.(true); input.setEncoding("utf8"); input.resume(); input.on("data", onKey); out.on("resize", onResize); let closed = false; return { get rows() { return out.rows ?? 24; }, get columns() { return out.columns ?? 80; }, draw(lines: string[]): void { const visible = lines.slice(0, this.rows); // Home, then erase each line as it is rewritten plus everything below the // last one. No full clear, so a redraw does not flash, and no trailing // newline, which would scroll the alternate screen by a row per frame. out.write(`\x1b[H${visible.map((l) => `${l}\x1b[K`).join("\r\n")}\x1b[J`); }, close(): void { if (closed) return; closed = true; input.off("data", onKey); out.off("resize", onResize); input.setRawMode?.(false); input.pause(); out.write(ALT_OFF); }, }; }