#!/usr/bin/env bun /** * Open today's spaced-repetition issue: whatever the SRS ladder has due, * linked straight to LeetCode. * * D1 is the schedule and this script is a renderer. `POST /admin/due` returns * exactly the rows the 8 AM digest mails — reviews standing at the `+3` and * `+7` rungs, oldest first — so the issue and the email can never disagree * about a day, and there is no second scheduler to drift. The rung is also * the `work/` bucket the blind re-solve belongs in, which is the section * `bun run pick` opens on Tab. * * How a problem gets here: closing its issue (a push, a digest tap, a `/done` * comment) enters it on the ladder at `+3` with a concrete next_review, and * passing that review moves it to `+7`. Nothing is "activated" by this script * — it asks what is standing today and writes it down. A day's issue is keyed * by its title (`Spaced Repetition — `), so re-runs rewrite * that one body instead of stacking duplicates. * * Sunday writes nothing: the ladder never schedules the rest day (WINDOWS of * 3 and 7 exist for exactly that reason, see apps/api/src/srs.ts), so there is * nothing to render. Each rung shows at most WINDOW_CAP problems, with the * overflow listed as optional — the digest's leveller moves that overflow to * later working days the next morning. * * Links: D1 stores titles, not URLs, so each due problem's issue body is read * for the canonical LeetCode link. A body with no URL falls back to a slug * guessed from the title and is flagged in the report, because repo titles are * sometimes abbreviated ("LC 167 · Two Sum II") and a guess 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`. * SRS_ADMIN_KEY reads the due list; SRS_API overrides the Worker URL for * local `wrangler dev` runs. */ import { WINDOWS, etDate, isRestDay } 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); } // ── what is due today (D1, via the Worker) ─────────────────────── // The ladder is the schedule: `POST /admin/due` returns exactly the rows the // morning digest mails, so the issue and the email can never disagree about a // day. This script adds no scheduling of its own — it renders, caps, and // links. const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev"; /** One problem the ladder wants re-solved today, as the Worker reports it. */ interface Due { lc: number; issue: number; title: string; difficulty: string; /** The rung being settled: "+3" or "+7". */ stage: string; /** The rung's number — also the `work/` bucket the re-solve belongs in. */ bucket: number; due: string; late: number; last_seen: string | null; days_since: number | null; } function readDue(value: unknown): Due | undefined { if (!value || typeof value !== "object") return; const num = (key: string): number | undefined => key in value && typeof (value as Record)[key] === "number" ? ((value as Record)[key] as number) : undefined; const str = (key: string): string | undefined => key in value && typeof (value as Record)[key] === "string" ? ((value as Record)[key] as string) : undefined; const lc = num("lc"); const issue = num("issue"); const bucket = num("bucket"); const stage = str("stage"); const title = str("title"); const dueOn = str("due"); if (lc === undefined || issue === undefined || bucket === undefined) return; if (stage === undefined || title === undefined || dueOn === undefined) return; const difficulty = str("difficulty") ?? "unrated"; return { lc, issue, title, difficulty: difficulty.charAt(0).toUpperCase() + difficulty.slice(1), stage, bucket, due: dueOn, late: num("late") ?? 0, last_seen: str("last_seen") ?? null, days_since: num("days_since") ?? null, }; } if (!process.env.SRS_ADMIN_KEY) { // Failing loudly beats writing an issue from a second, guessed schedule: // there is only one schedule now, and it lives in D1. console.error("SRS_ADMIN_KEY unset — cannot read today's due list from the Worker."); process.exit(1); } const res = await fetch(`${SRS_API}/admin/due?date=${today}`, { method: "POST", headers: { authorization: `Bearer ${process.env.SRS_ADMIN_KEY}` }, }); if (!res.ok) throw new Error(`GET due list -> ${res.status} ${await res.text()}`); const payload: unknown = await res.json(); const dueRaw: unknown = payload && typeof payload === "object" && "due" in payload ? payload.due : null; const due: Due[] = (Array.isArray(dueRaw) ? dueRaw : []).flatMap((r) => { const row = readDue(r); return row ? [row] : []; }); // ── repo + auth ────────────────────────────────────────────────── const gh = await github(); // ── canonical LeetCode links ───────────────────────────────────── 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, ""); } /** * D1 stores titles, not URLs, so each due problem's issue body is read for the * canonical LeetCode link written by the campaign-issues convention. That is * one request per due problem — at most REVIEW_CAP per rung — instead of the * old walk over every closed issue in the repo. A body with no URL falls back * to a slug guessed from the title and is flagged in the report, because repo * titles are sometimes abbreviated ("LC 167 · Two Sum II") and a guess can 404. */ interface Linked extends Due { url: string; linked: boolean; } async function link(row: Due): Promise { let url: string | undefined; try { const issue: unknown = await gh.api(`/repos/${gh.repo}/issues/${row.issue}`); const body = issue && typeof issue === "object" && "body" in issue && typeof issue.body === "string" ? issue.body : ""; url = LC_URL.exec(body)?.[0]; } catch (err) { console.warn(`#${row.issue}: could not read the issue body (${err})`); } return { ...row, url: `${url ?? `https://leetcode.com/problems/${slugify(row.title)}`}/`, linked: url !== undefined, }; } // ── issue body ─────────────────────────────────────────────────── const title = `Spaced Repetition — ${longDate(today)}`; /** The window's due problems, capped, oldest scheduled first. */ const byWindow = new Map(); for (const days of WINDOWS) { const rows = due.filter((d) => d.bucket === days); byWindow.set(days, await Promise.all(rows.map(link))); } function bucket(days: number): Linked[] { return byWindow.get(days) ?? []; } /** One checkbox line, straight to LeetCode. */ function line(s: Linked, box: boolean): string { const seen = s.days_since === null ? "unlogged" : `last seen ${s.days_since}d ago`; return ( `${box ? "- [ ] " : "- "}[LC ${s.lc} · ${s.title}](${s.url}) · ${s.difficulty} · #${s.issue}` + ` · ${seen}${s.late > 0 ? ` · ⏰ ${s.late}d late` : ""}` + (s.linked ? "" : " · ⚠️ link guessed from title") ); } const sections = WINDOWS.map((days) => { const rows = bucket(days); const required = rows.slice(0, WINDOW_CAP); const spill = rows.slice(WINDOW_CAP); return [ `### +${days} days — re-solve into \`work/${days}/\``, "", ...(required.length ? required.map((s) => line(s, true)) : [`_Nothing at the +${days} rung today._`]), // Overflow is shown, not dropped: the ladder levels everything past the // cap onto later working days, but until that morning's digest runs this // issue is the only record of what was standing. ...(spill.length ? [ "", `
${spill.length} more at this rung — 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 at +3.", "- `bun run pick` → Tab to this rung's section scaffolds the blind re-solve.", "", ...sections, "Today's due list straight from the SRS ladder in D1 (`POST /admin/due`), rendered by " + "`.github/workflows/spaced-repetition.yml` — re-runs on the same day rewrite this body. " + "The ladder never schedules a Sunday, so there is no issue on the rest day.", ].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 whatever the SRS ladder has due", }), }); } 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, s.due + (s.late > 0 ? ` (${s.late}d late)` : ""), i < WINDOW_CAP ? "due" : "optional", s.linked ? s.url : `${s.url} (inferred)`, ]), ); const rows = [["rung", "lc", "issue", "diff", "scheduled", "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: ${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` : "The ladder has nothing due today.\n"), );