mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 07:26:27 +00:00
feat(cli): add terminal stats dashboard and solution detection utilities
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
*/
|
*/
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { SRS_API } from "./api.ts";
|
||||||
import { github } from "./github.ts";
|
import { github } from "./github.ts";
|
||||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
import { isImplemented } from "./source.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
|
// 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
|
// recompute that backfills whatever earlier runs missed) and re-runs write
|
||||||
// nothing.
|
// 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()]
|
const solved = [...work.entries()]
|
||||||
.flatMap(([lc, entry]) => [...entry.solved].sort().map((bucket) => ({ lc, bucket })))
|
.flatMap(([lc, entry]) => [...entry.solved].sort().map((bucket) => ({ lc, bucket })))
|
||||||
.sort((a, b) => a.lc - b.lc || a.bucket - b.bucket);
|
.sort((a, b) => a.lc - b.lc || a.bucket - b.bucket);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"pick": "bun ./pick.ts",
|
"pick": "bun ./pick.ts",
|
||||||
|
"stats": "bun ./stats.ts",
|
||||||
"test": "bun ./test.ts",
|
"test": "bun ./test.ts",
|
||||||
"submit": "bun ./submit.ts",
|
"submit": "bun ./submit.ts",
|
||||||
"sync": "bun ./sync.ts",
|
"sync": "bun ./sync.ts",
|
||||||
|
|||||||
Executable
+458
@@ -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
@@ -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
@@ -93,6 +93,25 @@ export function scanBucket(bucket: Bucket): Map<number, WorkFile[]> {
|
|||||||
return files;
|
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.
|
* 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();
|
const dates = firstSolved();
|
||||||
|
|
||||||
// An empty scaffold under work/1 is a problem still unsolved, not one owing
|
// 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 }[] = [];
|
const solved: { file: WorkFile; date: string }[] = [];
|
||||||
for (const files of scanBucket(1).values()) {
|
for (const files of scanBucket(1).values()) {
|
||||||
const date = dates.get(files[0]!.lc);
|
const date = dates.get(files[0]!.lc);
|
||||||
if (date === undefined) continue; // uncommitted: you only just solved it
|
if (date === undefined) continue; // uncommitted: you only just solved it
|
||||||
const file = files.find((f) =>
|
const file = files.find(isSolution);
|
||||||
isImplemented(readFileSync(join(WORK, f.rel), "utf8"), f.rel.endsWith(".py") ? "py" : "js"),
|
|
||||||
);
|
|
||||||
if (file) solved.push({ file, date });
|
if (file) solved.push({ file, date });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user