From bf4a1f4c7aa23510177e658fcac1d6c2fe049be7 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Sat, 29 Aug 2026 17:26:05 -0400 Subject: [PATCH] feat(cli): add spaced-repetition script --- apps/cli/spaced-repetition.ts | 337 ++++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100755 apps/cli/spaced-repetition.ts diff --git a/apps/cli/spaced-repetition.ts b/apps/cli/spaced-repetition.ts new file mode 100755 index 0000000..d22465e --- /dev/null +++ b/apps/cli/spaced-repetition.ts @@ -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 — `), 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 · ` 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 + ? [ + "", + `
${spill.length} more solved that day — optional`, + "", + ...spill.map((s) => line(s, false)), + "", + "
", + ] + : []), + "", + ].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, + `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.", +].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"), +);