#!/usr/bin/env bun /** * Open today's spaced-repetition issue: everything whose problem issue closed * 3 and 7 days ago, linked straight to LeetCode. * * The issue itself holds no state and there is no scheduler here: "what do I * re-solve today" is a pure function of the tracker's close dates and today's * ET 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 because that is the campaign's ladder: WINDOWS lives * in apps/api/src/srs.ts, names the `work/3` and `work/7` buckets the picker * scaffolds into, and is chosen so a solve never comes back on the Sunday rest * day — the +3 pass catches a problem while the solution is half remembered, * the +7 pass after a week of interference. The one exception, a Thursday * solve's +3, is slid to Monday by the same workingDay() the Worker schedules * with, which is why a window can have two source days. Each window asks for * at most WINDOW_CAP problems, so a normal day is 3 new + up to 6 re-solves * with no levelling logic needed to bound the load. On Sunday this script * writes nothing at all. * * 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 { WINDOWS, addDays, etDate, isRestDay, workingDay } from "../api/src/srs.ts"; 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"; /** * 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 here 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. The helpers come from the Worker's SRS domain so this script, // the picker and D1 cannot disagree about a date, a window, or the rest day. 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 { // Noon-UTC anchor, like every date in srs.ts: no DST edge can move the day. const text = LONG.format(new Date(`${date}T12:00:00Z`)); 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 || etDate(new Date()); // Sunday is the campaign's rest day and the reason the windows are 3 and 7: // nothing is ever booked on it, so there is no issue to write. The cron still // fires — the guard lives here, not in the workflow, so a manual run agrees. if (isRestDay(today)) { console.log(`${today} is the rest day — no spaced-repetition issue.`); await writeStepSummary(`### Spaced Repetition — ${longDate(today)}\n\nRest day: nothing scheduled.\n`); process.exit(0); } /** * The close dates whose `days`-day review comes due today, in close order. * * Normally one day, `today - days`. Two when the plain interval would have * landed on the rest day: workingDay() slides that review to Monday, so a * Monday run also owes Thursday's +3 (Thu + 3 = Sunday). Nothing is dropped * and nothing is counted twice — a close date qualifies only when its shifted * review date IS today, which is exactly the rule srs.ts schedules by. */ function windowSources(days: number): string[] { return [addDays(today, -days - 1), addDays(today, -days)].filter( (closed) => workingDay(addDays(closed, days)) === today, ); } const targets = new Set(WINDOWS.flatMap((days) => windowSources(days))); // ── 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 = etDate(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 one of a window's source days. */ function bucket(days: number): Solved[] { const dates = new Set(windowSources(days)); return solved.filter((s) => dates.has(s.closed)); } /** 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 from = windowSources(days).map((d) => longDate(d)).join(" and "); const rows = bucket(days); const due = rows.slice(0, WINDOW_CAP); const spill = rows.slice(WINDOW_CAP); return [ `### +${days} days — solved ${from}`, "", ...(due.length ? due.map((s) => line(s, true)) : ["_Nothing closed then._"]), // 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 then — 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. " + "A window that would fall on Sunday is served on the Monday instead.", ].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 ${windowSources(days).join("+")}: ${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"), );