mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 15:36:26 +00:00
459 lines
16 KiB
TypeScript
Executable File
459 lines
16 KiB
TypeScript
Executable File
#!/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();
|
|
}
|