mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
feat(api): migrate SRS ladder to +3/+7 and adjust rest‑day logic
This commit is contained in:
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user