From e8b0d4dc30e59fee4f247a45ba8c76d9546568b7 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Mon, 31 Aug 2026 10:41:21 -0400 Subject: [PATCH] =?UTF-8?q?feat(api):=20migrate=20SRS=20ladder=20to=20+3/+?= =?UTF-8?q?7=20and=20adjust=20rest=E2=80=91day=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cli/close-solved.ts | 52 +------- apps/cli/pick.ts | 216 +++++++++++++++++++++++++++++----- apps/cli/solutions.ts | 17 +-- apps/cli/source.ts | 61 ++++++++++ apps/cli/spaced-repetition.ts | 110 +++++++++-------- apps/cli/sync.ts | 10 +- apps/cli/work.ts | 180 ++++++++++++++++++++++++++++ 7 files changed, 506 insertions(+), 140 deletions(-) create mode 100644 apps/cli/source.ts create mode 100644 apps/cli/work.ts diff --git a/apps/cli/close-solved.ts b/apps/cli/close-solved.ts index a9081e8..d667b58 100755 --- a/apps/cli/close-solved.ts +++ b/apps/cli/close-solved.ts @@ -10,7 +10,7 @@ * * `bun run pick` scaffolds a statement header plus an empty function body, so * file existence alone means nothing — a stub must not close its issue. See - * isImplemented(). + * isImplemented() in source.ts. * * bun apps/cli/close-solved.ts # close matches, log them in D1 * bun apps/cli/close-solved.ts --dry-run # report only, touch nothing @@ -23,6 +23,7 @@ import { basename, 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"); @@ -32,55 +33,6 @@ const DRY = process.env.DRY_RUN === "1" || process.env.DRY_RUN === "true"; -// ── is the file a real solution or just a scaffolded stub? ──────── - -/** Placeholder bodies that leetcode-cli / a human leaves behind. */ -const PLACEHOLDERS: Record = { pass: true, "...": true, TODO: true }; - -/** - * Decide whether a work/ file contains an implementation. - * - * The header comment is dropped the same way sync.ts splitSource() does it - * (duplicated rather than imported, because sync.ts runs its whole pipeline on - * import). Comments must go before any brace analysis: the scaffold's JSDoc - * carries `@param {number[]}`, whose braces would otherwise read as a body. - * - * A brace-language file counts as implemented when at least one *innermost* - * brace pair holds real content. That distinguishes a bare stub - * (`function(nums) {}`) and a class-shaped design stub (every method body - * empty) from any genuine solution, whose innermost block always has code. - */ -function isImplemented(src: string, lang: "js" | "py"): boolean { - if (lang === "py") { - const open = src.indexOf('"""'); - const close = src.indexOf('"""', open + 3); - const code = open === -1 || close === -1 ? src : src.slice(close + 3); - for (const raw of code.split("\n")) { - const line = raw.replace(/#.*$/, "").trim(); - if (!line || PLACEHOLDERS[line]) continue; - if (/^(?:@|def\s|class\s)/.test(line)) continue; - return true; // a statement inside some def body - } - return false; - } - - const headerEnd = src.indexOf("*/"); - const body = headerEnd === -1 ? src : src.slice(headerEnd + 2); - const code = body.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/.*$/gm, " "); - - let innermost = -1; - for (let i = 0; i < code.length; i++) { - if (code[i] === "{") { - innermost = i; - } else if (code[i] === "}" && innermost !== -1) { - const inner = code.slice(innermost + 1, i).replace(/[\s;]/g, ""); - if (inner && !PLACEHOLDERS[inner]) return true; - innermost = -1; // measured; the enclosing pair is not innermost - } - } - return false; -} - // ── repo + auth ────────────────────────────────────────────────── const gh = await github(); diff --git a/apps/cli/pick.ts b/apps/cli/pick.ts index d74ad16..9398140 100755 --- a/apps/cli/pick.ts +++ b/apps/cli/pick.ts @@ -1,19 +1,24 @@ #!/usr/bin/env bun /** - * Fuzzy-pick a LeetCode problem and scaffold it via leetcode-cli. + * Fuzzy-pick a problem and scaffold it via leetcode-cli, in one of the three + * work/ buckets (see work.ts): a first solve under work/1, or the 3-day and + * 7-day blind re-solves under work/3 and work/7. Tab cycles the sections. * * The problem index lives in the local SQLite cache (see db.ts), refreshed at * most once per 24h; campaign problem descriptions from README.md are - * backfilled into the same db on the way. Problems that already have a - * numbered file under work/ are excluded from the picker — re-picking them - * would only clobber the existing scaffold. + * backfilled into the same db on the way. The new-problem section hides + * anything that already has a numbered file under work/1 — re-picking it would + * only clobber the existing scaffold — and each review section hides whatever + * its own bucket already holds, so a finished re-solve leaves the list. */ -import { autocomplete, cancel, isCancel, spinner } from "@clack/prompts"; -import { basename, join } from "node:path"; -import { fetchDescription, problemDb, readmeSlugs, type Problem } from "./db"; - -const ROOT = join(import.meta.dir, "..", ".."); -const WORK_DIR = join(ROOT, "work"); +import { autocomplete, cancel, isCancel, log, spinner } from "@clack/prompts"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { styleText } from "node:util"; +import { etDate } from "../api/src/srs.ts"; +import { fetchDescription, problemDb, readmeSlugs } from "./db"; +import { ROOT, WINDOWS, bucketDir, reviewQueues, scanBucket, type Bucket } from "./work"; const db = problemDb(); @@ -57,26 +62,110 @@ if (missing.length > 0) { s.stop(failed ? `Cached ${fetched} descriptions (${failed} failed, will retry next run)` : `Cached ${fetched} problem descriptions`); } -// ── exclude problems already picked or solved under work/ ──────── +// ── the three sections ─────────────────────────────────────────── -const done = new Set(); -for (const rel of new Bun.Glob("**/*.*").scanSync({ cwd: WORK_DIR })) { - const m = basename(rel).match(/^(\d+)\./); - if (m) done.add(Number(m[1])); +/** What Enter scaffolds: this problem, into this bucket. */ +interface Choice { + bucket: Bucket; + lc: number; + slug: string; } -const problems = db.all().filter((p) => !done.has(p.id)); +interface Section { + label: string; + options: { value: Choice; label: string; hint: string }[]; +} + +const first = scanBucket(1); +const problems = db.all(); +const titles = new Map(problems.map((p) => [p.id, p.title] as const)); db.close(); -const picked = await autocomplete({ - message: "Pick a problem", - placeholder: "Type to search by number or title...", - maxItems: 12, - options: problems.map((p) => ({ - value: p, - label: `${p.id}. ${p.title}`, - hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty, +const queues = reviewQueues(etDate(new Date())); + +const sections: Section[] = [ + { + label: "new", + options: problems + .filter((p) => !first.has(p.id)) + .map((p) => ({ + value: { bucket: 1 as Bucket, lc: p.id, slug: p.slug }, + label: `${p.id}. ${p.title}`, + hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty, + })), + }, + ...WINDOWS.map((window) => ({ + label: `${window}-day`, + options: queues.get(window)!.map((owed) => ({ + value: { bucket: window as Bucket, lc: owed.lc, slug: owed.slug }, + label: `${owed.lc}. ${titles.get(owed.lc) ?? owed.slug}`, + hint: `${owed.difficulty} · ${owed.category} · solved ${owed.solved}, ${owed.days}d ago`, + })), })), +]; + +let active = 0; + +/** + * The slice of clack's AutocompletePrompt this picker drives. @clack/prompts + * re-exports neither the class nor its type, and apps/cli does not depend on + * @clack/core directly, so the contract is spelled out structurally. + */ +interface Autocomplete { + userInput: string; + on(event: "key", cb: (char: string | undefined, key: { name?: string }) => void): void; + emit(event: "userInput", input: string): void; +} + +/** Search text that cannot match any option — see refresh() below. */ +const NO_MATCH = "\u0000"; + +let hooked = false; + +/** + * Bind Tab to the section switch, once per prompt. + * + * clack has no hook for extra keys, but it calls `options` with the prompt as + * `this`, which is all a subscription needs. Switching sections also has to + * re-filter the list, and the prompt only recomputes `filteredOptions` when + * the search text *changes* — hence the pair of userInput events: an + * unmatchable string, then the real one, which recomputes against the new + * section, resets the cursor onto its first match and leaves the typed query + * on screen untouched. clack repaints as soon as the key handlers return. + * + * No `placeholder` is passed to the prompt on purpose: clack's own Tab binding + * (fill the input with the placeholder) is inert without one. + */ +function hookTab(prompt: Autocomplete): void { + if (hooked) return; + hooked = true; + prompt.on("key", (_char, key) => { + if (key.name !== "tab") return; + active = (active + 1) % sections.length; + prompt.emit("userInput", NO_MATCH); + prompt.emit("userInput", prompt.userInput); + }); +} + +// Widened, not asserted: clack resolves `undefined` when Enter lands on an +// empty list, which its own types do not admit. +const picked: Choice | symbol | undefined = await autocomplete({ + // Getter, not a string: clack reads `message` on every repaint, so the tab + // bar tracks the active section. + get message(): string { + const bar = sections + .map((section, i) => { + const text = `${section.label} (${section.options.length})`; + return i === active ? styleText("cyan", `▸ ${text}`) : styleText("dim", text); + }) + .join(styleText("dim", " · ")); + return `Pick a problem ${bar} ${styleText("dim", "Tab: next section")}`; + }, + maxItems: 12, + options(this: Autocomplete) { + hookTab(this); + return sections[active]!.options; + }, filter: (search, option) => { const haystack = option.label!.toLowerCase(); return search @@ -90,10 +179,77 @@ if (isCancel(picked)) { cancel("Nothing picked."); process.exit(0); } +if (picked === undefined) { + // Enter on an empty section: nothing was focused, so there is nothing to do. + cancel(`Nothing owed in the ${sections[active]!.label} section.`); + process.exit(0); +} -const proc = Bun.spawn( - // bun installs workspace-dep bins into this package's node_modules, not the root's - [join(import.meta.dir, "node_modules", ".bin", "leetcode"), "pick", picked.slug], - { cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] }, -); -process.exit(await proc.exited); +// ── scaffold into the picked bucket ────────────────────────────── + +// The active workspace names the config file; everything else about it belongs +// to leetcode-cli. +const registry = join(homedir(), ".leetcode", "workspaces.json"); +let workspace = "default"; +if (existsSync(registry)) { + const parsed: unknown = JSON.parse(readFileSync(registry, "utf8")); + if (parsed && typeof parsed === "object" && "active" in parsed && typeof parsed.active === "string") { + workspace = parsed.active || workspace; + } +} +const configPath = join(homedir(), ".leetcode", "workspaces", workspace, "config.json"); +if (!existsSync(configPath)) { + log.error(`No leetcode-cli config at ${configPath} — run \`leetcode config -i\` once.`); + process.exit(1); +} + +/** + * Point leetcode-cli at one bucket. It reads its output directory from the + * active workspace config and from nowhere else — no flag, no environment + * override — so `workDir` is the whole handover. + * + * Each write MERGES the file as it is on disk at that moment; a saved copy is + * never played back. The CLI owns this file too (theme, language, whatever a + * `leetcode login` adds), and replaying stale bytes over it would silently + * undo what it wrote while the child ran. After the pick, `workDir` is set to + * work/1 instead of to whatever it was before: the first-solve bucket is the + * one resting value that is always right, so even a killed run leaves a bare + * `leetcode pick` on-layout. + */ +function setWorkDir(target: string): void { + const raw: unknown = JSON.parse(readFileSync(configPath, "utf8")); + if (raw === null || typeof raw !== "object") { + log.error(`${configPath} holds no JSON object — run \`leetcode config -i\` to rewrite it.`); + process.exit(1); + } + // 2-space JSON with a trailing newline: leetcode-cli's own format. + writeFileSync(configPath, `${JSON.stringify({ ...raw, workDir: target }, null, 2)}\n`); +} + +const dir = bucketDir(picked.bucket); +mkdirSync(dir, { recursive: true }); +setWorkDir(dir); + +let code: number; +try { + const proc = Bun.spawn( + // bun installs workspace-dep bins into this package's node_modules, not the root's + [join(import.meta.dir, "node_modules", ".bin", "leetcode"), "pick", picked.slug], + { cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] }, + ); + code = await proc.exited; +} finally { + setWorkDir(bucketDir(1)); +} + +// leetcode-cli exits 0 even when it wrote nothing (an expired session only +// prints a warning), so the scaffold itself is the acceptance test: without a +// file for this problem in the bucket, the pick failed. +if (!scanBucket(picked.bucket).has(picked.lc)) { + log.error( + `leetcode-cli scaffolded nothing for LC ${picked.lc} under work/${picked.bucket}/.\n` + + "If it printed “Session expired”, run `leetcode login` and pick again.", + ); + process.exit(code === 0 ? 1 : code); +} +process.exit(code); diff --git a/apps/cli/solutions.ts b/apps/cli/solutions.ts index 493be6a..97fedc7 100644 --- a/apps/cli/solutions.ts +++ b/apps/cli/solutions.ts @@ -2,6 +2,11 @@ * Shared work/ solution picker for the test and submit entries: fuzzy-select * a solution file, then hand it to leetcode-cli. * + * Every bucket is listed — a 3-day or 7-day re-solve is tested and submitted + * exactly like a first solve — and the hint says which one a file belongs to. + * The db's per-solution timestamps are keyed by LC number, so the buckets of + * one problem share them. + * * Every run backfills the db's solutions table with created_at (from the * file's birthtime, mtime when the filesystem has none) so ordering works for * files that predate the db. Ordering is per command — test lists the most @@ -16,9 +21,7 @@ import { autocomplete, cancel, isCancel, log } from "@clack/prompts"; import { statSync } from "node:fs"; import { basename, join, relative } from "node:path"; import { problemDb } from "./db"; - -const ROOT = join(import.meta.dir, "..", ".."); -const WORK_DIR = join(ROOT, "work"); +import { ROOT, WORK as WORK_DIR, parseWorkPath } from "./work"; /** Leading LC number of a work/ file, or undefined for unnumbered names. */ function lcNumber(rel: string): number | undefined { @@ -70,12 +73,12 @@ export async function runOnSolution(command: "test" | "submit", message: string) placeholder: "Type to search...", maxItems: 12, options: files.map((f) => { - // work layout: Difficulty/Category/.. - const [difficulty, category, name] = f.split("/"); + const parsed = parseWorkPath(f); + const stage = parsed?.bucket === 1 ? "first solve" : `${parsed?.bucket}-day review`; return { value: f, - label: name ?? f, - hint: category ? `${difficulty} · ${category}` : difficulty, + label: basename(f), + hint: parsed ? `${stage} · ${parsed.difficulty} · ${parsed.category}` : f, }; }), filter: (search, option) => { diff --git a/apps/cli/source.ts b/apps/cli/source.ts new file mode 100644 index 0000000..13f7645 --- /dev/null +++ b/apps/cli/source.ts @@ -0,0 +1,61 @@ +/** + * Is a work/ file a real solution or just a scaffolded stub? + * + * `bun run pick` writes a statement header plus an empty function body, so + * file existence alone means nothing: close-solved.ts must not close an issue + * on a stub, and work.ts must not ask for the spaced-repetition re-solve of a + * problem that was never solved in the first place. + * + * sync.ts keeps its own copy of the header split — it runs its whole pipeline + * on import, so it cannot be imported from. Don't "deduplicate" that one. + * + * Library: side-effect-free on import. + */ + +/** Placeholder bodies that leetcode-cli / a human leaves behind. */ +const PLACEHOLDERS: Record = { pass: true, "...": true, TODO: true }; + +/** + * Decide whether a work/ file contains an implementation. + * + * The header comment is dropped the same way sync.ts splitSource() does it. + * Comments must go before any brace analysis: the scaffold's JSDoc carries + * `@param {number[]}`, whose braces would otherwise read as a body. + * + * A brace-language file counts as implemented when at least one *innermost* + * brace pair holds real content. That distinguishes a bare stub + * (`function(nums) {}`) and a class-shaped design stub (every method body + * empty) from any genuine solution, whose innermost block always has code. + * Every non-Python language the campaign has ever used is brace-shaped, so + * "not .py" is the only branch that matters. + */ +export function isImplemented(src: string, lang: "js" | "py"): boolean { + if (lang === "py") { + const open = src.indexOf('"""'); + const close = src.indexOf('"""', open + 3); + const code = open === -1 || close === -1 ? src : src.slice(close + 3); + for (const raw of code.split("\n")) { + const line = raw.replace(/#.*$/, "").trim(); + if (!line || PLACEHOLDERS[line]) continue; + if (/^(?:@|def\s|class\s)/.test(line)) continue; + return true; // a statement inside some def body + } + return false; + } + + const headerEnd = src.indexOf("*/"); + const body = headerEnd === -1 ? src : src.slice(headerEnd + 2); + const code = body.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/.*$/gm, " "); + + let innermost = -1; + for (let i = 0; i < code.length; i++) { + if (code[i] === "{") { + innermost = i; + } else if (code[i] === "}" && innermost !== -1) { + const inner = code.slice(innermost + 1, i).replace(/[\s;]/g, ""); + if (inner && !PLACEHOLDERS[inner]) return true; + innermost = -1; // measured; the enclosing pair is not innermost + } + } + return false; +} diff --git a/apps/cli/spaced-repetition.ts b/apps/cli/spaced-repetition.ts index d22465e..6c3073e 100755 --- a/apps/cli/spaced-repetition.ts +++ b/apps/cli/spaced-repetition.ts @@ -3,18 +3,23 @@ * 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. + * 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 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. + * 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 @@ -28,6 +33,7 @@ * * 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"; @@ -36,9 +42,6 @@ const DRY = 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 @@ -51,27 +54,10 @@ 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); -} +// 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", @@ -83,7 +69,8 @@ const LONG = new Intl.DateTimeFormat("en-US", { /** `2026-08-26` -> `August 26, 2026`, or with the weekday prefix. */ function longDate(date: string, weekday = false): string { - const text = LONG.format(atNoon(date)); + // 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); } @@ -95,11 +82,33 @@ const override = process.argv.find((a) => a.startsWith("--date="))?.slice(7) || 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()); +const today = override || etDate(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()); +// 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 ────────────────────────────────────────────────── @@ -146,7 +155,7 @@ function readSolved(value: unknown): Solved | undefined { // 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)); + const closed = etDate(new Date(value.closed_at)); if (!targets.has(closed)) return; const title = TITLE.exec(value.title); @@ -187,10 +196,10 @@ solved.sort((a, b) => a.issue - b.issue); const title = `Spaced Repetition — ${longDate(today)}`; -/** Every problem whose issue closed on that window's day, curriculum order. */ +/** Every problem whose issue closed on one of a window's source days. */ function bucket(days: number): Solved[] { - const date = windowDate.get(days)!; - return solved.filter((s) => s.closed === date); + const dates = new Set(windowSources(days)); + return solved.filter((s) => dates.has(s.closed)); } /** One checkbox line, straight to LeetCode. */ @@ -202,21 +211,21 @@ function line(s: Solved, box: boolean): string { } const sections = WINDOWS.map((days) => { - const date = windowDate.get(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 ${longDate(date)}`, + `### +${days} days — solved ${from}`, "", - ...(due.length ? due.map((s) => line(s, true)) : ["_Nothing closed that day._"]), + ...(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 that day — optional`, + `
${spill.length} more solved then — optional`, "", ...spill.map((s) => line(s, false)), "", @@ -238,7 +247,8 @@ const body = [ "", ...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.", + "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 ────────────────────────────── @@ -324,7 +334,7 @@ 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)` : ""}`; + return `+${days}d ${windowSources(days).join("+")}: ${Math.min(n, WINDOW_CAP)} due${n > WINDOW_CAP ? ` (+${n - WINDOW_CAP} optional)` : ""}`; }).join(" · ") + `\n${status}`, ); diff --git a/apps/cli/sync.ts b/apps/cli/sync.ts index e9c8f77..d8ec742 100755 --- a/apps/cli/sync.ts +++ b/apps/cli/sync.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun /** - * Sync work/ leetcode solutions into apps/docs/content/ pages. + * Sync work/1 leetcode solutions into apps/docs/content/ pages. * * - New problems get a full page (frontmatter, badge, warning, examples, * constraints, solution) in the gold-standard format. @@ -9,12 +9,16 @@ * - When a problem is solved in both JavaScript and Python the solution * renders as a (Python first); single-language solutions * render as a plain fence. + * + * Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves + * of problems whose page already exists (see work.ts), and a fresh scaffold + * there would otherwise wipe the page's `## Solution`. */ import { mkdir, rename } from "node:fs/promises"; import { basename, join } from "node:path"; const ROOT = join(import.meta.dir, "..", ".."); -const WORK = join(ROOT, "work"); +const WORK = join(ROOT, "work", "1"); const DOCS = join(ROOT, "apps", "docs", "content"); type Lang = "js" | "py"; @@ -323,7 +327,7 @@ for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) { const file = basename(rel); const m = file.match(/^(\d+)\.(.+)\.(?:js|py)$/); if (!m) { - console.warn(`skip (unrecognized name): work/${rel}`); + console.warn(`skip (unrecognized name): work/1/${rel}`); continue; } const num = Number(m[1]); diff --git a/apps/cli/work.ts b/apps/cli/work.ts new file mode 100644 index 0000000..ffdf259 --- /dev/null +++ b/apps/cli/work.ts @@ -0,0 +1,180 @@ +/** + * The work/ layout: one directory per spaced-repetition window. + * + * work/1///.. first solve + * work/3///.. 3-day blind re-solve + * work/7///.. 7-day blind re-solve + * + * The bucket name is the review window in days — the same +3/+7 windows the + * campaign's `Spaced Repetition — ` issues use (WINDOWS in + * spaced-repetition.ts). Below the bucket the layout is leetcode-cli's own + * `workDir/Difficulty/Category/` shape, so a re-solve lands beside its first + * solve under a different bucket. + * + * A window is *owed* when the first solve is at least that many days old and + * the bucket has no file for that problem yet. Reconciled from the filesystem + * on every run the way close-solved.ts reconciles issues: no state file, no + * bookkeeping, and a re-solve closes its window just by existing — presence, + * not content, so a stub in work/3 counts as done the same way `bun run pick` + * treats a scaffold under work/1 as picked. The first solve is the one place + * content is read: an empty scaffold in work/1 owes nothing, it is simply + * unsolved (isImplemented() in source.ts, the same gate close-solved.ts uses + * before it closes an issue). + * + * "First solve" is the ET date of the commit that first added the problem's + * work/ file — the same push that closes its problem issue and therefore + * starts the campaign's +3/+7 windows. An uncommitted solution has no date and + * is never owed a review yet: you just solved it. ET dates and the date + * arithmetic come from apps/api/src/srs.ts so the local picker and D1 cannot + * disagree about what day it is. + * + * Library: side-effect-free on import — nothing scans or shells out until a + * function is called. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { WINDOWS, type Window, daysBetween } from "../api/src/srs.ts"; +import { isImplemented } from "./source.ts"; + +export const ROOT = join(import.meta.dir, "..", ".."); +export const WORK = join(ROOT, "work"); + +// The windows are the SRS ladder's rungs: srs.ts defines them once and the +// bucket directories are named after them. Re-exported so the picker needs +// only this module to know the work/ layout. +export { WINDOWS, type Window }; + +/** work/1 is the first solve, the rest are the review windows. */ +export const BUCKETS = [1, ...WINDOWS] as const; +export type Bucket = (typeof BUCKETS)[number]; +const BUCKET_BY_DIR: Record = { "1": 1, "3": 3, "7": 7 }; + +export function bucketDir(bucket: Bucket): string { + return join(WORK, String(bucket)); +} + +export interface WorkFile { + lc: number; + slug: string; + bucket: Bucket; + /** Path relative to work/, bucket included. */ + rel: string; + difficulty: string; + category: string; +} + +/** `..` — the only file name the campaign scripts parse. */ +function parseName(name: string): { lc: number; slug: string } | null { + const m = name.match(/^(\d+)\.(.+)\.[A-Za-z0-9]+$/); + return m ? { lc: Number(m[1]), slug: m[2]! } : null; +} + +/** Parse `1/Medium/Array/15.3sum.py` — null for anything off-layout. */ +export function parseWorkPath(rel: string): WorkFile | null { + const parts = rel.split("/"); + const name = parts.pop(); + const bucket = BUCKET_BY_DIR[parts[0] ?? ""]; + if (name === undefined || bucket === undefined) return null; + const parsed = parseName(name); + if (!parsed) return null; + return { ...parsed, bucket, rel, difficulty: parts[1] ?? "", category: parts[2] ?? "" }; +} + +/** LC number -> its files in that bucket (a problem may have both js and py). */ +export function scanBucket(bucket: Bucket): Map { + const files = new Map(); + for (const name of new Bun.Glob("**/*.*").scanSync({ cwd: bucketDir(bucket) })) { + const file = parseWorkPath(`${bucket}/${name}`); + if (!file) continue; + const found = files.get(file.lc); + if (found) found.push(file); + else files.set(file.lc, [file]); + } + return files; +} + +/** + * LC number -> ET date of the commit that first added a work/ file for it. + * + * One `git log` over the whole of work/, oldest commit first, so the first + * date seen for a problem wins. Keyed by LC number rather than by path, which + * makes every past reshuffle of the directory layout — including the move into + * work/1 — irrelevant: the original add still counts. TZ + `format-local` + * put the dates on the ET calendar srs.ts speaks. + */ +export function firstSolved(): Map { + const git = Bun.spawnSync( + [ + "git", + "log", + "--reverse", + "--no-merges", + "--diff-filter=A", + "--date=format-local:%Y-%m-%d", + "--format=%ad", + "--name-only", + "--", + "work", + ], + { cwd: ROOT, env: { ...process.env, TZ: "America/New_York" } }, + ); + if (git.exitCode !== 0) { + throw new Error(`git log failed: ${git.stderr.toString().trim()}`); + } + + const dates = new Map(); + let date = ""; + for (const line of git.stdout.toString().split("\n")) { + if (/^\d{4}-\d{2}-\d{2}$/.test(line)) { + date = line; + continue; + } + const parsed = line === "" ? null : parseName(line.slice(line.lastIndexOf("/") + 1)); + if (parsed && !dates.has(parsed.lc)) dates.set(parsed.lc, date); + } + return dates; +} + +export interface Owed extends WorkFile { + /** ET date of the first solve. */ + solved: string; + /** Days since the first solve — at least the window. */ + days: number; +} + +/** + * Both review windows for `today`, oldest solve first. + * + * Uncapped, unlike the review issue's WINDOW_CAP: the issue is one day's + * assignment, this is everything still owed. A window skipped for a day stays + * on the list until its file exists. + */ +export function reviewQueues(today: string): Map { + const dates = firstSolved(); + + // An empty scaffold under work/1 is a problem still unsolved, not one owing + // a review — the same call close-solved.ts makes before it closes an issue. + const solved: { file: WorkFile; date: string }[] = []; + for (const files of scanBucket(1).values()) { + const date = dates.get(files[0]!.lc); + if (date === undefined) continue; // uncommitted: you only just solved it + const file = files.find((f) => + isImplemented(readFileSync(join(WORK, f.rel), "utf8"), f.rel.endsWith(".py") ? "py" : "js"), + ); + if (file) solved.push({ file, date }); + } + + const queues = new Map(); + for (const window of WINDOWS) { + const done = scanBucket(window); + const owed: Owed[] = []; + for (const { file, date } of solved) { + if (done.has(file.lc)) continue; + const days = daysBetween(date, today); + if (days >= window) owed.push({ ...file, solved: date, days }); + } + owed.sort((a, b) => b.days - a.days || a.lc - b.lc); + queues.set(window, owed); + } + return queues; +}