#!/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(); }