#!/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"), );