diff --git a/apps/cli/close-solved.ts b/apps/cli/close-solved.ts index d667b58..a7a963e 100755 --- a/apps/cli/close-solved.ts +++ b/apps/cli/close-solved.ts @@ -19,14 +19,12 @@ * SRS_ADMIN_KEY for the D1 push (unset = skip it); SRS_API overrides * the Worker URL for local `wrangler dev` runs. */ -import { basename, join } from "node:path"; +import { join } from "node:path"; import { github } from "./github.ts"; import { markdownTable, printTable, writeStepSummary } from "./report.ts"; import { isImplemented } from "./source.ts"; - -const ROOT = join(import.meta.dir, "..", ".."); -const WORK = join(ROOT, "work"); +import { WORK, parseWorkPath, type Bucket } from "./work.ts"; const DRY = process.argv.includes("--dry-run") || @@ -41,27 +39,27 @@ const gh = await github(); interface WorkEntry { files: string[]; + /** Any bucket implemented — this is what closes the problem issue. */ implemented: boolean; + /** Buckets holding a real solution: 1 = first solve, 3/7 = the re-solves. */ + solved: Set; } -/** LC number -> solution files (a problem may have both js and py). */ +/** LC number -> its solution files across every bucket (js and py both count). */ const work = new Map(); for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) { - const m = basename(rel).match(/^(\d+)\.(.+)\.(?:js|py)$/); - if (!m) { - console.warn(`skip (unrecognized name): work/${rel}`); + const file = parseWorkPath(rel); + if (!file) { + console.warn(`skip (off-layout): work/${rel}`); continue; } - const num = Number(m[1]); const src = await Bun.file(join(WORK, rel)).text(); const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js"); - const entry = work.get(num); - if (entry) { - entry.files.push(`work/${rel}`); - entry.implemented ||= implemented; - } else { - work.set(num, { files: [`work/${rel}`], implemented }); - } + const entry = work.get(file.lc) ?? { files: [], implemented: false, solved: new Set() }; + entry.files.push(`work/${rel}`); + entry.implemented ||= implemented; + if (implemented) entry.solved.add(file.bucket); + work.set(file.lc, entry); } // ── open problem issues ────────────────────────────────────────── @@ -157,14 +155,17 @@ for (const num of [...work.keys()].sort((a, b) => a - b)) { // or the charts, the digest's "already solved" ticks, and the drill/gate // pools all keep treating it as untouched. // -// The whole implemented set goes over, not just this run's closes: the -// Worker drops anything already on the ladder, so the call is a total -// recompute and backfills whatever earlier runs missed. +// Each entry carries the BUCKET the file sits in, which is the rung it +// settles: work/1 is the first solve, work/3 the 3-day review, work/7 the +// 7-day one. That is what makes a pushed re-solve advance the ladder instead +// of vanishing — and the Worker writes only when a problem is standing on the +// rung named, so the whole implemented set can go over on every push (a total +// recompute that backfills whatever earlier runs missed) and re-runs write +// nothing. const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev"; const solved = [...work.entries()] - .filter(([, entry]) => entry.implemented) - .map(([lc]) => lc) - .sort((a, b) => a - b); + .flatMap(([lc, entry]) => [...entry.solved].sort().map((bucket) => ({ lc, bucket }))) + .sort((a, b) => a.lc - b.lc || a.bucket - b.bucket); let syncNote: string; let syncFailed = false; @@ -180,7 +181,7 @@ if (!process.env.SRS_ADMIN_KEY) { authorization: `Bearer ${process.env.SRS_ADMIN_KEY}`, "content-type": "application/json", }, - body: JSON.stringify({ lc: solved }), + body: JSON.stringify({ solved }), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); // Narrow the report payload instead of casting it; a shape change should @@ -209,7 +210,7 @@ printTable(rows); const actionable = report.filter((r) => r[3] !== "no open issue"); console.log( - `\n${work.size} in work/ · ${solved.length} implemented · ` + + `\n${work.size} problems in work/ · ${solved.length} solved files across buckets · ` + `${actionable.length} matched an open issue · ` + `${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}` + `\nd1: ${syncNote}`, @@ -217,7 +218,7 @@ console.log( await writeStepSummary( `### close-solved${DRY ? " (dry run)" : ""}\n\n` + - `${work.size} files in \`work/\`, ${solved.length} implemented, ` + + `${work.size} problems in \`work/\`, ${solved.length} solved files across buckets, ` + `${actionable.length} matched an open issue.\n\n` + (actionable.length ? `${markdownTable([rows[0]!, ...actionable])}\n` diff --git a/apps/cli/spaced-repetition.ts b/apps/cli/spaced-repetition.ts index 6c3073e..0b5cfee 100755 --- a/apps/cli/spaced-repetition.ts +++ b/apps/cli/spaced-repetition.ts @@ -1,39 +1,42 @@ #!/usr/bin/env bun /** - * Open today's spaced-repetition issue: everything whose problem issue closed - * 3 and 7 days ago, linked straight to LeetCode. + * Open today's spaced-repetition issue: whatever the SRS ladder has due, + * 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. + * 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. * - * 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. + * 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. * - * 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. + * 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, addDays, etDate, isRestDay, workingDay } from "../api/src/srs.ts"; +import { WINDOWS, etDate, isRestDay } from "../api/src/srs.ts"; import { github } from "./github.ts"; import { markdownTable, printTable, writeStepSummary } from "./report.ts"; @@ -93,42 +96,87 @@ if (isRestDay(today)) { 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, - ); +// ── 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; } -const targets = new Set(WINDOWS.flatMap((days) => windowSources(days))); +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(); -// ── closed problem issues ──────────────────────────────────────── +// ── canonical LeetCode links ───────────────────────────────────── -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. */ @@ -140,92 +188,79 @@ function slugify(name: string): string { } /** - * 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. + * 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. */ -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"; +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 { - lc: Number(title[1]), - name: title[2]!, - issue: value.number, - url: `${url ?? `https://leetcode.com/problems/${slugify(title[2]!)}`}/`, + ...row, + url: `${url ?? `https://leetcode.com/problems/${slugify(row.title)}`}/`, 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)); +/** 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: Solved, box: boolean): string { +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.name}](${s.url}) · ${s.difficulty} · #${s.issue}` + + `${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 from = windowSources(days).map((d) => longDate(d)).join(" and "); const rows = bucket(days); - const due = rows.slice(0, WINDOW_CAP); + const required = rows.slice(0, WINDOW_CAP); const spill = rows.slice(WINDOW_CAP); return [ - `### +${days} days — solved ${from}`, + `### +${days} days — re-solve into \`work/${days}/\``, "", - ...(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. + ...(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 solved then — optional`, + `
${spill.length} more at this rung — optional`, "", ...spill.map((s) => line(s, false)), "", @@ -243,12 +278,13 @@ const body = [ "", "- 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.", + "- 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, - `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.", + "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 ────────────────────────────── @@ -301,7 +337,7 @@ if (total === 0) { body: JSON.stringify({ name: LABEL, color: "0E8A16", - description: "Daily blind re-solve of problems closed 3 and 7 days ago", + description: "Daily blind re-solve of whatever the SRS ladder has due", }), }); } catch (err) { @@ -323,18 +359,19 @@ const report = WINDOWS.flatMap((days) => 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 = [["window", "lc", "issue", "diff", "state", "leetcode"], ...report]; +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 ${windowSources(days).join("+")}: ${Math.min(n, WINDOW_CAP)} due${n > WINDOW_CAP ? ` (+${n - WINDOW_CAP} optional)` : ""}`; + return `+${days}d: ${Math.min(n, WINDOW_CAP)} due${n > WINDOW_CAP ? ` (+${n - WINDOW_CAP} optional)` : ""}`; }).join(" · ") + `\n${status}`, ); @@ -343,5 +380,5 @@ 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"), + (report.length ? `${markdownTable(rows)}\n` : "The ladder has nothing due today.\n"), );