diff --git a/apps/cli/api.ts b/apps/cli/api.ts new file mode 100644 index 0000000..869b473 --- /dev/null +++ b/apps/cli/api.ts @@ -0,0 +1,87 @@ +/** + * The SRS Worker, from the CLI side: one base URL and one read-side payload. + * + * `SRS_API` points a script at a `wrangler dev` instance instead of production + * — the same switch close-solved.ts uses for its `/admin/solved` push, which is + * why the default lives here rather than in either script. + * + * The payload is narrowed with a guard rather than cast: D1 is the only source + * of SRS truth, so if the Worker answers something else the caller must say so + * out loud, not render half a dashboard off undefined fields. + */ +import { STAGES, TEMPERATURES, type Stage, type Temperature } from "../api/src/srs.ts"; + +export const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev"; + +// The rungs and the bands come straight from the Worker's domain module — the +// same import work.ts makes for WINDOWS — so a change to either vocabulary +// reaches this dashboard without a second list to remember. +export { STAGES, TEMPERATURES, type Stage, type Temperature }; + +export interface Phase { + milestone: number; + name: string; + core_done: number; + core_total: number; + optional_done: number; + optional_total: number; + deferred_done: number; + deferred_total: number; +} + +export interface Topic { + issue: number; + name: string; + milestone: number | null; + week: number | null; + taught: boolean; + total: number; + done: number; + retired: number; + due: number; + last: string | null; + age: number | null; + temperature: Temperature; +} + +export interface Stats { + generated: string; + campaign: { day: number; week: number; start: string; days: number }; + phases: Phase[]; + ladder: Record; + difficulty: { difficulty: string; total: number; done: number }[]; + topics: Topic[]; + /** week is the 1-based campaign week — the Worker converts from ISO. */ + gates: { week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }[]; + streak: number; + queue: { date: string; due: number }[]; + /** Every campaign day, zeros and future days included. */ + heat: { date: string; attempts: number; passes: number }[]; +} + +function isStats(value: unknown): value is Stats { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return ( + typeof v.campaign === "object" && + v.campaign !== null && + typeof v.ladder === "object" && + v.ladder !== null && + typeof v.streak === "number" && + Array.isArray(v.phases) && + Array.isArray(v.difficulty) && + Array.isArray(v.topics) && + Array.isArray(v.gates) && + Array.isArray(v.queue) && + Array.isArray(v.heat) + ); +} + +/** GET /api/stats. Throws with the reason; there is no local substitute. */ +export async function fetchStats(signal?: AbortSignal): Promise { + const res = await fetch(`${SRS_API}/api/stats`, { signal }); + if (!res.ok) throw new Error(`GET /api/stats -> ${res.status} ${await res.text()}`); + const body: unknown = await res.json(); + if (!isStats(body)) throw new Error("GET /api/stats -> unrecognised payload shape"); + return body; +} diff --git a/apps/cli/close-solved.ts b/apps/cli/close-solved.ts index a7a963e..6ec6ef1 100755 --- a/apps/cli/close-solved.ts +++ b/apps/cli/close-solved.ts @@ -21,6 +21,7 @@ */ import { join } from "node:path"; +import { SRS_API } from "./api.ts"; import { github } from "./github.ts"; import { markdownTable, printTable, writeStepSummary } from "./report.ts"; import { isImplemented } from "./source.ts"; @@ -162,7 +163,8 @@ for (const num of [...work.keys()].sort((a, b) => a - b)) { // rung named, so the whole implemented set can go over on every push (a total // recompute that backfills whatever earlier runs missed) and re-runs write // nothing. -const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev"; +// SRS_API (apps/cli/api.ts) is the switch that points this at a wrangler dev +// instance instead of production. const solved = [...work.entries()] .flatMap(([lc, entry]) => [...entry.solved].sort().map((bucket) => ({ lc, bucket }))) .sort((a, b) => a.lc - b.lc || a.bucket - b.bucket); diff --git a/apps/cli/package.json b/apps/cli/package.json index d3c8fa8..f437570 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "pick": "bun ./pick.ts", + "stats": "bun ./stats.ts", "test": "bun ./test.ts", "submit": "bun ./submit.ts", "sync": "bun ./sync.ts", diff --git a/apps/cli/stats.ts b/apps/cli/stats.ts new file mode 100755 index 0000000..75bb3de --- /dev/null +++ b/apps/cli/stats.ts @@ -0,0 +1,458 @@ +#!/usr/bin/env bun +/** + * `bun run stats` — the campaign dashboard, in the terminal. + * + * Four panes over ONE source: GET /api/stats. D1 owns SRS state, so this + * fetches the Worker's aggregate rather than recomputing anything from work/ + * or from git — a second answer to "how far along am I" is exactly the drift + * the campaign is built to avoid. The one local fact shown is the work/ file + * inventory, which is inventory, not schedule. Unreachable Worker = an error, + * never a reconstructed guess. + * + * The panes: + * Overview campaign clock, solved counts, phase bars, recent activity + * Ladder the +3/+7 rungs and the 14-day due queue + * Concepts every topic by temperature — what has gone cold + * Activity the campaign heatmap and the weekly gates + * + * Outside a TTY (a pipe, CI) it prints every pane once and exits, so the same + * command works in a scrollback or a file. + */ +import { + fetchStats, + SRS_API, + STAGES, + TEMPERATURES, + type Stats, + type Temperature, + type Topic, +} from "./api.ts"; +import { + BLUE, + FG, + GREEN, + GREENS, + LINE, + MUTED, + RED, + YELLOW, + bar, + bold, + cell, + fg, + heatCell, + openScreen, + padTo, + rule, + sparkline, + stackedBar, + width, +} from "./tui.ts"; +import { BUCKETS, WINDOWS, solvedInBucket } from "./work.ts"; + +// ── shared vocabulary → colour ─────────────────────────────────── + +// Temperature is an action ramp, not a thermometer: green is warm enough to +// leave alone, red is the concept that wants revisiting. The band names carry +// the heat, the colours carry the urgency — and the glyphs carry both again, +// so a piped or NO_COLOR dashboard still separates the bands. +const TEMP_COLOR: Record = { + hot: GREENS[4]!, + fresh: GREEN, + fading: YELLOW, + cold: RED, +}; + +const TEMP_GLYPH: Record = { + hot: "█", + fresh: "▓", + fading: "▒", + cold: "░", +}; + +const DIFF_COLOR: Record = { easy: GREEN, medium: YELLOW, hard: RED }; + +const LABEL = 15; // left-hand label column, shared by every pane + +/** "2026-08-31" → "Mon 31", read as a plain calendar date, never localised. */ +function dayLabel(iso: string): string { + const [y, m, d] = iso.split("-").map(Number); + const weekday = new Date(Date.UTC(y!, m! - 1, d!)).getUTCDay(); + return `${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][weekday]} ${String(d).padStart(2, "0")}`; +} + +// ── overview ───────────────────────────────────────────────────── + +function overview(s: Stats, cols: number): string[] { + const bars = Math.max(10, Math.min(48, cols - LABEL - 18)); + const lines: string[] = []; + + const { day, days, week } = s.campaign; + lines.push(rule("campaign", cols)); + lines.push( + `${cell("Day", LABEL)}${bar(day, days, bars)} ${fg(FG, `${day}/${days}`)}` + + fg(MUTED, ` week ${week}`), + ); + const streak = s.streak > 0 ? fg(GREENS[4]!, `${s.streak} day${s.streak === 1 ? "" : "s"}`) : fg(MUTED, "none"); + lines.push(`${cell("Streak", LABEL)}${streak}`); + + const total = s.difficulty.reduce((n, d) => n + d.total, 0); + const done = s.difficulty.reduce((n, d) => n + d.done, 0); + lines.push(""); + lines.push(rule("solved", cols)); + lines.push( + `${cell("All", LABEL)}${bar(done, total, bars)} ${fg(FG, `${done}/${total}`)}` + + fg(MUTED, ` ${total > 0 ? Math.round((done / total) * 100) : 0}%`), + ); + for (const d of s.difficulty) { + lines.push( + `${cell(d.difficulty, LABEL)}${bar(d.done, d.total, bars, DIFF_COLOR[d.difficulty] ?? GREEN)}` + + ` ${fg(MUTED, `${d.done}/${d.total}`)}`, + ); + } + + lines.push(""); + lines.push(rule("phases", cols)); + lines.push( + `${" ".repeat(LABEL)}${fg(GREEN, "█")} ${fg(MUTED, "core")} ${fg(BLUE, "█")} ${fg(MUTED, "optional")} ` + + `${fg(RED, "█")} ${fg(MUTED, "deferred")} ${fg(LINE, "░")} ${fg(MUTED, "remaining")}`, + ); + // Every phase bar is scaled to the LARGEST phase, not to itself: phases are + // not the same size, and equal-length bars would hide that. + const scale = Math.max( + 1, + ...s.phases.map((p) => p.core_total + p.optional_total + p.deferred_total), + ); + for (const p of s.phases) { + const phaseDone = p.core_done + p.optional_done + p.deferred_done; + const phaseTotal = p.core_total + p.optional_total + p.deferred_total; + const segments = [ + { value: p.core_done, color: GREEN }, + { value: p.optional_done, color: BLUE }, + { value: p.deferred_done, color: RED }, + { value: phaseTotal - phaseDone, color: LINE, glyph: "░" }, + ]; + lines.push( + `${cell(p.name, LABEL - 1)} ${stackedBar(segments, scale, bars)} ${fg(MUTED, `${phaseDone}/${phaseTotal}`)}`, + ); + } + + lines.push(""); + lines.push(rule("last 14 days", cols)); + const window = s.heat.slice(Math.max(0, day - 14), day); + const attempts = window.reduce((n, h) => n + h.attempts, 0); + const passes = window.reduce((n, h) => n + h.passes, 0); + lines.push( + `${cell("Attempts", LABEL)}${sparkline(window.map((h) => h.attempts))} ` + + fg(MUTED, `${attempts} attempts · ${passes} passed`), + ); + + // The one local fact on the dashboard: solutions on disk, per + // spaced-repetition bucket. Inventory, not schedule — D1's ladder is the + // schedule, and the Ladder pane is where it is shown. Empty scaffolds do not + // count, the same rule close-solved.ts closes issues by. + lines.push(""); + lines.push(rule("work/ on disk", cols)); + lines.push( + cell("Solutions", LABEL) + + BUCKETS.map((b) => `${fg(FG, String(solvedInBucket(b)))} ${fg(MUTED, `in work/${b}`)}`).join( + fg(MUTED, " · "), + ), + ); + + return lines; +} + +// ── ladder ─────────────────────────────────────────────────────── + +function ladder(s: Stats, cols: number): string[] { + const bars = Math.max(10, Math.min(48, cols - LABEL - 18)); + const lines: string[] = [rule("ladder", cols)]; + lines.push(fg(MUTED, `${" ".repeat(LABEL)}the review each problem owes next`)); + + const rungs = STAGES.map((stage) => s.ladder[stage] ?? 0); + const peak = Math.max(1, ...rungs); + STAGES.forEach((stage, i) => { + const n = rungs[i]!; + // new is the untouched pile and retired is the finished one; only the + // middle rungs are live work, so only they get the live colour. + const color = stage === "new" ? LINE : stage === "retired" ? GREENS[4]! : GREEN; + lines.push(`${cell(stage, LABEL)}${bar(n, peak, bars, color)} ${fg(FG, String(n))}`); + }); + + lines.push(""); + lines.push(rule("due queue", cols)); + const today = s.queue[0]; + lines.push( + fg(MUTED, `${" ".repeat(LABEL)}cumulative: everything the ladder wants by that day`) + + (today ? fg(FG, ` · ${today.due} due today`) : ""), + ); + const depth = Math.max(1, ...s.queue.map((q) => q.due)); + for (const q of s.queue) { + const color = q.due > 3 ? RED : q.due > 0 ? GREEN : LINE; + lines.push( + `${cell(dayLabel(q.date), LABEL)}${bar(q.due, depth, bars, color)} ${fg(MUTED, String(q.due))}`, + ); + } + + return lines; +} + +// ── concepts ───────────────────────────────────────────────────── + +/** Coldest first, and everything the schedule has not reached yet last. */ +function byUrgency(a: Topic, b: Topic): number { + if (a.taught !== b.taught) return a.taught ? -1 : 1; + return ( + TEMPERATURES.indexOf(b.temperature) - TEMPERATURES.indexOf(a.temperature) || + (b.age ?? Infinity) - (a.age ?? Infinity) || + a.issue - b.issue + ); +} + +// Row layout: "● " + name + temp + coverage bar + done/total + last + due. +// Everything but the name is fixed, so the name absorbs the terminal width. +const TEMP_W = 7; +const COVER_W = 10; +const COUNT_W = 6; +const AGE_W = 6; +const DUE_W = 3; +const CONCEPT_FIXED = 3 + (TEMP_W + 1) + (COVER_W + 1) + (COUNT_W + 1) + (AGE_W + 1) + DUE_W + 1; + +function concepts(s: Stats, cols: number): string[] { + const lines: string[] = [rule("concepts by temperature", cols)]; + // Thresholds are the ladder's windows; print them rather than restate them. + const bands: [Temperature, string][] = [ + ["hot", `≤${WINDOWS[0]}d`], + ["fresh", `≤${WINDOWS[1]}d`], + ["fading", `≤${WINDOWS[1] * 2}d`], + ["cold", "older, or never"], + ]; + lines.push( + fg(MUTED, " last exercised — ") + + bands + .map(([t, when]) => `${fg(TEMP_COLOR[t], `${TEMP_GLYPH[t]} ${t}`)} ${fg(MUTED, when)}`) + .join(fg(MUTED, " · ")), + ); + + const taught = s.topics.filter((t) => t.taught); + const counts = TEMPERATURES.map((t) => ({ + value: taught.filter((x) => x.temperature === t).length, + color: TEMP_COLOR[t], + glyph: TEMP_GLYPH[t], + })); + lines.push(""); + lines.push(` ${stackedBar(counts, Math.max(1, taught.length), Math.max(10, Math.min(60, cols - 4)))}`); + lines.push( + ` ${TEMPERATURES.map((t, i) => `${fg(TEMP_COLOR[t], TEMP_GLYPH[t])} ${counts[i]!.value} ${fg(MUTED, t)}`).join(" ")}` + + fg(MUTED, ` of ${taught.length} taught so far`), + ); + + const nameW = Math.max(12, Math.min(34, cols - CONCEPT_FIXED)); + lines.push(""); + lines.push( + fg( + MUTED, + ` ${cell("concept", nameW)} ${cell("temp", TEMP_W)} ` + + `${cell("coverage", COVER_W + 1 + COUNT_W)} ${cell("last", AGE_W)} ${cell("due", DUE_W, "right")}`, + ), + ); + + for (const t of s.topics.slice().sort(byUrgency)) { + // An unreached concept has no temperature to report — it shows the week + // the schedule gets to it instead, so the list stays one list. + const color = t.taught ? TEMP_COLOR[t.temperature] : LINE; + const age = + !t.taught ? "—" : t.age === null ? "never" : t.age <= 0 ? "today" : `${t.age}d`; + lines.push( + ` ${fg(color, t.taught ? TEMP_GLYPH[t.temperature] : "·")} ${fg(t.taught ? FG : MUTED, cell(t.name, nameW))} ` + + `${fg(color, cell(t.taught ? t.temperature : `wk ${t.week ?? "?"}`, TEMP_W))} ` + + `${bar(t.done, t.total, COVER_W, t.taught ? GREEN : LINE)} ` + + `${fg(MUTED, cell(`${t.done}/${t.total}`, COUNT_W))} ${fg(MUTED, cell(age, AGE_W))} ` + + `${t.due > 0 ? fg(GREEN, cell(String(t.due), DUE_W, "right")) : fg(LINE, cell("·", DUE_W, "right"))}`, + ); + } + + return lines; +} + +// ── activity ───────────────────────────────────────────────────── + +const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +function activity(s: Stats, cols: number): string[] { + const lines: string[] = [rule("attempts per campaign day", cols)]; + lines.push(fg(MUTED, " the README heatmap, same ramp — Sunday is the rest day")); + lines.push(""); + + // CAMPAIGN_START is a Monday, so the grid is simply the series in weeks of + // seven: column = week, row = weekday, exactly as the SVG lays it out. + const weeks = Math.ceil(s.heat.length / 7); + lines.push( + ` ${fg(MUTED, Array.from({ length: weeks }, (_, w) => ` ${String(w + 1).padStart(2)}`).join(""))}`, + ); + WEEKDAYS.forEach((name, row) => { + const cells = Array.from({ length: weeks }, (_, w) => { + const entry = s.heat[w * 7 + row]; + if (entry === undefined) return " "; + // The marker sits in the gutter, not inside the cell: a foreground + // escape within a background run would end the background early. + const marker = s.campaign.day - 1 === w * 7 + row ? fg(FG, "▸") : " "; + return marker + heatCell(entry.attempts, " "); + }).join(""); + lines.push(` ${fg(MUTED, cell(name, 4))}${cells}`); + }); + lines.push(""); + lines.push( + ` ${fg(MUTED, "less ")}${GREENS.map((_, level) => heatCell(level, " ")).join("")}${fg(MUTED, " more")}` + + fg(MUTED, " · one cell per day, colour = attempts, ▸ marks today"), + ); + + lines.push(""); + lines.push(rule("weekly gates", cols)); + if (s.gates.length === 0) { + lines.push(fg(MUTED, " no gate has been scored yet")); + } + const gateW = Math.max(10, Math.min(24, cols - 30)); + for (const g of s.gates) { + // Gates were stored as an integer percent before they were stored as a + // fraction; both are in the table, so normalise on read. + const rate = g.pass_rate === null ? null : g.pass_rate <= 1 ? g.pass_rate : g.pass_rate / 100; + const color = rate === null ? LINE : rate >= 0.7 ? GREEN : RED; + lines.push( + ` ${fg(FG, cell(`Week ${g.week}`, 9))}` + + `${bar(rate ?? 0, 1, gateW, color)}` + + ` ${fg(color, cell(rate === null ? "open" : `${Math.round(rate * 100)}%`, 5))}` + + ` ${fg(MUTED, g.issue === null ? "" : `#${g.issue}`)}${fg(MUTED, g.closed_on ? ` closed ${g.closed_on}` : "")}`, + ); + } + + return lines; +} + +// ── panes ──────────────────────────────────────────────────────── + +const PANES: { name: string; render: (s: Stats, cols: number) => string[] }[] = [ + { name: "Overview", render: overview }, + { name: "Ladder", render: ladder }, + { name: "Concepts", render: concepts }, + { name: "Activity", render: activity }, +]; + +// ── non-interactive: every pane, once ──────────────────────────── + +let stats: Stats; +try { + stats = await fetchStats(); +} catch (err) { + console.error(`stats: ${err instanceof Error ? err.message : String(err)}`); + console.error(`stats: D1 is the only source of SRS truth — nothing to fall back to (SRS_API=${SRS_API}).`); + process.exit(1); +} + +if (!process.stdout.isTTY) { + const cols = 80; + for (const pane of PANES) { + console.log(bold(pane.name.toUpperCase())); + for (const line of pane.render(stats, cols)) console.log(line); + console.log(""); + } + process.exit(0); +} + +// ── interactive ────────────────────────────────────────────────── + +let pane = 0; +let offset = 0; +let note = ""; + +// Quitting resolves this; the finally below is what restores the terminal, so +// a thrown render never leaves the shell in raw mode on the alternate screen. +const { promise: quit, resolve: leave } = Promise.withResolvers(); +const screen = openScreen(onKey, draw); + +function frame(): string[] { + const cols = screen.columns; + const tabs = PANES.map((p, i) => + i === pane ? bold(fg(FG, ` ${p.name} `)) : fg(MUTED, ` ${p.name} `), + ).join(fg(LINE, "│")); + const clock = fg(MUTED, `day ${stats.campaign.day}/${stats.campaign.days} · streak ${stats.streak} `); + const header = padTo(tabs, Math.max(0, cols - width(clock))) + clock; + + // header + rule + body + footer fills the terminal exactly. + const body = PANES[pane]!.render(stats, cols); + const height = Math.max(1, screen.rows - 3); + offset = Math.max(0, Math.min(offset, Math.max(0, body.length - height))); + const scrolled = + body.length > height ? ` · ${offset + 1}-${Math.min(offset + height, body.length)}/${body.length}` : ""; + + return [ + header, + fg(LINE, "─".repeat(cols)), + ...body.slice(offset, offset + height), + fg(MUTED, `Tab/←→ pane · ↑↓ scroll · r refresh · q quit${scrolled}`) + (note ? ` ${fg(YELLOW, note)}` : ""), + ]; +} + +function draw(): void { + screen.draw(frame()); +} + +function onKey(chunk: string): void { + switch (chunk) { + // Raw mode delivers Ctrl-C as a byte, not a signal, so it is handled here. + case "q": + case "\x03": + case "\x1b": + leave(); + return; + case "\t": + case "\x1b[C": + case "l": + pane = (pane + 1) % PANES.length; + offset = 0; + break; + case "\x1b[Z": + case "\x1b[D": + case "h": + pane = (pane + PANES.length - 1) % PANES.length; + offset = 0; + break; + case "\x1b[A": + case "k": + offset = Math.max(0, offset - 1); + break; + case "\x1b[B": + case "j": + offset += 1; + break; + case "r": + note = "refreshing…"; + draw(); + void fetchStats() + .then((fresh) => { + stats = fresh; + note = ""; + }) + .catch((err: unknown) => { + note = `refresh failed: ${err instanceof Error ? err.message : String(err)}`; + }) + .finally(draw); + return; + default: + if (chunk >= "1" && chunk <= String(PANES.length)) { + pane = Number(chunk) - 1; + offset = 0; + break; + } + return; + } + draw(); +} + +try { + draw(); + await quit; +} finally { + screen.close(); +} diff --git a/apps/cli/tui.ts b/apps/cli/tui.ts new file mode 100644 index 0000000..b741a99 --- /dev/null +++ b/apps/cli/tui.ts @@ -0,0 +1,214 @@ +/** + * 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); + }, + }; +} diff --git a/apps/cli/work.ts b/apps/cli/work.ts index ffdf259..bfc92a3 100644 --- a/apps/cli/work.ts +++ b/apps/cli/work.ts @@ -93,6 +93,25 @@ export function scanBucket(bucket: Bucket): Map { return files; } +/** + * Is this file a real solution rather than an empty scaffold? The same call + * close-solved.ts makes before it closes an issue — a stub under work/1 is a + * problem still unsolved, and owes no review. + */ +export function isSolution(file: WorkFile): boolean { + const source = readFileSync(join(WORK, file.rel), "utf8"); + return isImplemented(source, file.rel.endsWith(".py") ? "py" : "js"); +} + +/** Problems in a bucket with a solution on disk; scaffolds do not count. */ +export function solvedInBucket(bucket: Bucket): number { + let solved = 0; + for (const files of scanBucket(bucket).values()) { + if (files.some(isSolution)) solved++; + } + return solved; +} + /** * LC number -> ET date of the commit that first added a work/ file for it. * @@ -153,14 +172,12 @@ export function reviewQueues(today: string): Map { const dates = firstSolved(); // An empty scaffold under work/1 is a problem still unsolved, not one owing - // a review — the same call close-solved.ts makes before it closes an issue. + // a review. const solved: { file: WorkFile; date: string }[] = []; for (const files of scanBucket(1).values()) { const date = dates.get(files[0]!.lc); if (date === undefined) continue; // uncommitted: you only just solved it - const file = files.find((f) => - isImplemented(readFileSync(join(WORK, f.rel), "utf8"), f.rel.endsWith(".py") ? "py" : "js"), - ); + const file = files.find(isSolution); if (file) solved.push({ file, date }); }