feat(api): migrate SRS ladder to +3/+7 and adjust rest‑day logic

This commit is contained in:
Prad Nukala
2026-08-31 10:41:21 -04:00
parent bd9afc6ea1
commit e8b0d4dc30
7 changed files with 506 additions and 140 deletions
+2 -50
View File
@@ -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<string, true> = { 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();
+179 -23
View File
@@ -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<number>();
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<Problem>({
message: "Pick a problem",
placeholder: "Type to search by number or title...",
maxItems: 12,
options: problems.map((p) => ({
value: p,
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<Choice>({
// 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);
}
// ── 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"] },
);
process.exit(await proc.exited);
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);
+10 -7
View File
@@ -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/<id>.<slug>.<ext>
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) => {
+61
View File
@@ -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<string, true> = { 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;
}
+60 -50
View File
@@ -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 — <Month D,
* YYYY>`), 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 — <Month D, YYYY>`), 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
? [
"",
`<details><summary>${spill.length} more solved that day — optional</summary>`,
`<details><summary>${spill.length} more solved then — optional</summary>`,
"",
...spill.map((s) => line(s, false)),
"",
@@ -238,7 +247,8 @@ const body = [
"",
...sections,
`<sub>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.</sub>",
"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.</sub>",
].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}`,
);
+7 -3
View File
@@ -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 <CodeGroup> (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]);
+180
View File
@@ -0,0 +1,180 @@
/**
* The work/ layout: one directory per spaced-repetition window.
*
* work/1/<Difficulty>/<Category>/<num>.<slug>.<ext> first solve
* work/3/<Difficulty>/<Category>/<num>.<slug>.<ext> 3-day blind re-solve
* work/7/<Difficulty>/<Category>/<num>.<slug>.<ext> 7-day blind re-solve
*
* The bucket name is the review window in days — the same +3/+7 windows the
* campaign's `Spaced Repetition — <date>` 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<string, Bucket> = { "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;
}
/** `<num>.<slug>.<ext>` — 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<number, WorkFile[]> {
const files = new Map<number, WorkFile[]>();
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<number, string> {
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<number, string>();
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<Window, Owed[]> {
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<Window, Owed[]>();
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;
}