feat(cli): add terminal stats dashboard and solution detection utilities

This commit is contained in:
Prad Nukala
2026-08-31 18:56:42 -04:00
parent 7bae7b2920
commit b86f36daf1
6 changed files with 784 additions and 5 deletions
+87
View File
@@ -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<Stage, number>;
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<string, unknown>;
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<Stats> {
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;
}
+3 -1
View File
@@ -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);
+1
View File
@@ -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",
+458
View File
@@ -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<Temperature, string> = {
hot: GREENS[4]!,
fresh: GREEN,
fading: YELLOW,
cold: RED,
};
const TEMP_GLYPH: Record<Temperature, string> = {
hot: "█",
fresh: "▓",
fading: "▒",
cold: "░",
};
const DIFF_COLOR: Record<string, string> = { 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<void>();
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();
}
+214
View File
@@ -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);
},
};
}
+21 -4
View File
@@ -93,6 +93,25 @@ export function scanBucket(bucket: Bucket): Map<number, WorkFile[]> {
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<Window, Owed[]> {
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 });
}