feat(cli): add spaced-repetition script

This commit is contained in:
Prad Nukala
2026-08-29 17:26:05 -04:00
parent e23a68c91c
commit bf4a1f4c7a
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env bun
/**
* Open today's spaced-repetition issue: everything whose problem issue closed
* 3 and 7 days ago, linked straight to LeetCode.
*
* This is the whole SRS. There is no stored ladder, no scheduler and no state
* to drift: "what do I drill today" is a pure function of the issue tracker's
* close dates and today's ET calendar date, recomputed from scratch on every
* run. A day's issue is keyed by its title (`Spaced Repetition — <Month D,
* YYYY>`), so re-runs rewrite that one body instead of stacking duplicates,
* and a backfilled close shows up in the next run's windows for free.
*
* Windows are +3 and +7 days because the campaign's day rule is three new core
* problems: the +3 pass catches a problem while the solution is still half
* remembered, the +7 pass catches it after a week of interference. Each window
* asks for at most WINDOW_CAP problems, so a normal day is 3 new + up to 6
* re-solves and no levelling logic is needed to keep the load bounded.
*
* Links come from the problem issue body's first line (the canonical LeetCode
* URL written by the campaign-issues convention). A body with no URL falls back
* to a slug guessed from the title and is flagged `inferred` in the report,
* because repo titles are sometimes abbreviated ("LC 167 · Two Sum II") and a
* guessed slug can 404.
*
* bun apps/cli/spaced-repetition.ts # create/update today's issue
* bun apps/cli/spaced-repetition.ts --dry-run # print the body, touch nothing
* bun apps/cli/spaced-repetition.ts --date=2026-09-02 # pretend it is that ET day
*
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
*/
import { github } from "./github.ts";
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
const DRY =
process.argv.includes("--dry-run") ||
process.env.DRY_RUN === "1" ||
process.env.DRY_RUN === "true";
/** Review windows, in days since the problem issue closed. */
const WINDOWS = [3, 7] as const;
/**
* Required re-solves per window: 3 + 3 on top of the day's 3 new core problems
* is already a 90-minute session. A window that closed more than this (the
* catalog backfill closed 19 issues in one day) keeps the rest listed under a
* collapsed "optional" block rather than pretending they were never due.
*/
const WINDOW_CAP = 3;
const LABEL = "spaced-repetition";
// ── ET calendar dates ────────────────────────────────────────────
// Every date in this script is an ET calendar string (YYYY-MM-DD): the campaign
// runs on ET days, and an issue closed at 21:30 ET belongs to that ET day, not
// to the next UTC one. Arithmetic is anchored at noon UTC so a DST jump can
// never move a date by a day.
// Same helpers as apps/api/src/srs.ts, which cannot be imported here: that
// module bundles the schedule and is typed against the Worker's Env.
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
timeZone: "America/New_York",
dateStyle: "short",
});
/** Noon-UTC anchor: date-only arithmetic immune to DST edges. */
function atNoon(date: string): Date {
return new Date(`${date}T12:00:00Z`);
}
/** `date` moved by `days`, still a calendar string. */
function addDays(date: string, days: number): string {
return new Date(atNoon(date).getTime() + days * 86_400_000).toISOString().slice(0, 10);
}
const LONG = new Intl.DateTimeFormat("en-US", {
timeZone: "UTC",
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
/** `2026-08-26` -> `August 26, 2026`, or with the weekday prefix. */
function longDate(date: string, weekday = false): string {
const text = LONG.format(atNoon(date));
return weekday ? text : text.slice(text.indexOf(", ") + 2);
}
// ── today ────────────────────────────────────────────────────────
// `||`, not `??`: the workflow passes SRS_TODAY="" on scheduled runs, where the
// input has no value.
const override = process.argv.find((a) => a.startsWith("--date="))?.slice(7) || process.env.SRS_TODAY;
if (override && !/^\d{4}-\d{2}-\d{2}$/.test(override)) {
throw new Error(`--date wants YYYY-MM-DD, got ${override}`);
}
const today = override || ET_DATE.format(new Date());
/** Window length -> the ET day whose closes it reviews. */
const windowDate = new Map(WINDOWS.map((days) => [days, addDays(today, -days)]));
const targets = new Set(windowDate.values());
// ── repo + auth ──────────────────────────────────────────────────
const gh = await github();
// ── closed problem issues ────────────────────────────────────────
interface Solved {
lc: number;
name: string;
issue: number;
url: string;
/** false when the URL was guessed from the title instead of read from the body. */
linked: boolean;
difficulty: string;
/** ET date the issue closed. */
closed: string;
}
const TITLE = /^LC\s+(\d+)\s+·\s+(.+?)\s*$/;
const LC_URL = /https:\/\/leetcode\.com\/problems\/[a-z0-9-]+/;
/** LeetCode's own slug shape, for bodies that lost their URL line. */
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Narrow one element of the /issues payload to the fields this script needs.
* Drops pull requests (the `problem` label filter cannot), anything whose title
* is not a `LC <num> · <Name>` problem, and anything closed outside the two
* review windows.
*/
function readSolved(value: unknown): Solved | undefined {
if (!value || typeof value !== "object") return;
if ("pull_request" in value) return; // the /issues route also lists PRs
if (!("number" in value) || typeof value.number !== "number") return;
if (!("title" in value) || typeof value.title !== "string") return;
if (!("closed_at" in value) || typeof value.closed_at !== "string") return;
// Closed "not planned" means dropped from the curriculum, not solved — the
// reconcilers only ever close as completed, so this is a human's decision.
if ("state_reason" in value && value.state_reason === "not_planned") return;
const closed = ET_DATE.format(new Date(value.closed_at));
if (!targets.has(closed)) return;
const title = TITLE.exec(value.title);
if (!title) return;
const body = "body" in value && typeof value.body === "string" ? value.body : "";
const url = LC_URL.exec(body)?.[0];
const labels =
"labels" in value && Array.isArray(value.labels)
? value.labels.flatMap((l) =>
l && typeof l === "object" && "name" in l && typeof l.name === "string" ? [l.name] : [],
)
: [];
const diff = labels.find((n) => n.startsWith("diff:"))?.slice(5) ?? "unrated";
return {
lc: Number(title[1]),
name: title[2]!,
issue: value.number,
url: `${url ?? `https://leetcode.com/problems/${slugify(title[2]!)}`}/`,
linked: url !== undefined,
difficulty: diff.charAt(0).toUpperCase() + diff.slice(1),
closed,
};
}
// The whole closed set is walked rather than filtered with `since`: it is two
// pages for the entire 161-problem curriculum, and `since` filters on
// updated_at, which a stray comment moves off the close date.
const solved: Solved[] = [];
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=closed`)) {
const row = readSolved(raw);
if (row) solved.push(row);
}
solved.sort((a, b) => a.issue - b.issue);
// ── issue body ───────────────────────────────────────────────────
const title = `Spaced Repetition — ${longDate(today)}`;
/** Every problem whose issue closed on that window's day, curriculum order. */
function bucket(days: number): Solved[] {
const date = windowDate.get(days)!;
return solved.filter((s) => s.closed === date);
}
/** One checkbox line, straight to LeetCode. */
function line(s: Solved, box: boolean): string {
return (
`${box ? "- [ ] " : "- "}[LC ${s.lc} · ${s.name}](${s.url}) · ${s.difficulty} · #${s.issue}` +
(s.linked ? "" : " · ⚠️ link guessed from title")
);
}
const sections = WINDOWS.map((days) => {
const date = windowDate.get(days)!;
const rows = bucket(days);
const due = rows.slice(0, WINDOW_CAP);
const spill = rows.slice(WINDOW_CAP);
return [
`### +${days} days — solved ${longDate(date)}`,
"",
...(due.length ? due.map((s) => line(s, true)) : ["_Nothing closed that day._"]),
// Overflow is shown, not dropped: a day that closed more than WINDOW_CAP
// problems (a backfill, or a catch-up weekend) would otherwise silently
// lose reviews, and this issue is the only record of what was due.
...(spill.length
? [
"",
`<details><summary>${spill.length} more solved that day — optional</summary>`,
"",
...spill.map((s) => line(s, false)),
"",
"</details>",
]
: []),
"",
].join("\n");
});
const total = WINDOWS.reduce((n, days) => n + Math.min(bucket(days).length, WINDOW_CAP), 0);
const body = [
`Re-solve from memory, ${longDate(today, true)}. Blind: no notes, no \`work/\` file, no editor history.`,
"",
"- 15 minutes per problem, then stop and read your own solution.",
"- Say the invariant and the complexity out loud before typing.",
"- Failed one? Tick it anyway and expect it back in the next window.",
"",
...sections,
`<sub>Recomputed from the problem issues closed ${WINDOWS.join(" and ")} days ago ` +
"by `.github/workflows/spaced-repetition.yml` — re-runs on the same day rewrite this body.</sub>",
].join("\n");
// ── create or refresh today's issue ──────────────────────────────
interface Existing {
number: number;
title: string;
body: string;
}
function readExisting(value: unknown): Existing | undefined {
if (!value || typeof value !== "object") return;
if ("pull_request" in value) return;
if (!("number" in value) || typeof value.number !== "number") return;
if (!("title" in value) || typeof value.title !== "string") return;
const text = "body" in value && typeof value.body === "string" ? value.body : "";
return { number: value.number, title: value.title, body: text };
}
// state=all: a day's issue that was already closed must not be recreated.
let existing: Existing | undefined;
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=${LABEL}&state=all`)) {
const row = readExisting(raw);
if (row?.title === title) {
existing = row;
break;
}
}
let status: string;
if (total === 0) {
status = existing
? `nothing due — left #${existing.number} alone`
: "nothing due — no issue created";
} else if (existing && existing.body.trim() === body.trim()) {
status = `#${existing.number} already current`;
} else if (DRY) {
status = existing ? `would update #${existing.number}` : "would create";
} else if (existing) {
await gh.api(`/repos/${gh.repo}/issues/${existing.number}`, {
method: "PATCH",
body: JSON.stringify({ body }),
});
status = `updated #${existing.number}`;
} else {
// Ensure the label exists before it is used (422 = someone already made it).
try {
await gh.api(`/repos/${gh.repo}/labels`, {
method: "POST",
body: JSON.stringify({
name: LABEL,
color: "0E8A16",
description: "Daily blind re-solve of problems closed 3 and 7 days ago",
}),
});
} catch (err) {
if (!String(err).includes("422")) throw err;
}
const created = (await gh.api(`/repos/${gh.repo}/issues`, {
method: "POST",
body: JSON.stringify({ title, body, labels: [LABEL] }),
})) as { number: number };
status = `created #${created.number}`;
}
// ── report ───────────────────────────────────────────────────────
const report = WINDOWS.flatMap((days) =>
bucket(days).map((s, i) => [
`+${days}d`,
String(s.lc),
`#${s.issue}`,
s.difficulty,
i < WINDOW_CAP ? "due" : "optional",
s.linked ? s.url : `${s.url} (inferred)`,
]),
);
const rows = [["window", "lc", "issue", "diff", "state", "leetcode"], ...report];
if (report.length) printTable(rows);
console.log(
`\n${title}${DRY ? " (dry run)" : ""}\n` +
WINDOWS.map((days) => {
const n = bucket(days).length;
return `+${days}d ${windowDate.get(days)}: ${Math.min(n, WINDOW_CAP)} due${n > WINDOW_CAP ? ` (+${n - WINDOW_CAP} optional)` : ""}`;
}).join(" · ") +
`\n${status}`,
);
if (DRY) console.log(`\n${body}`);
await writeStepSummary(
`### ${title}${DRY ? " (dry run)" : ""}\n\n` +
`${total} problem${total === 1 ? "" : "s"} due · ${status}\n\n` +
(report.length ? `${markdownTable(rows)}\n` : "Nothing closed in either window.\n"),
);