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
|
* `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
|
* 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 # close matches, log them in D1
|
||||||
* bun apps/cli/close-solved.ts --dry-run # report only, touch nothing
|
* 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 { github } from "./github.ts";
|
||||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
|
import { isImplemented } from "./source.ts";
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
const WORK = join(ROOT, "work");
|
const WORK = join(ROOT, "work");
|
||||||
@@ -32,55 +33,6 @@ const DRY =
|
|||||||
process.env.DRY_RUN === "1" ||
|
process.env.DRY_RUN === "1" ||
|
||||||
process.env.DRY_RUN === "true";
|
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 ──────────────────────────────────────────────────
|
// ── repo + auth ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const gh = await github();
|
const gh = await github();
|
||||||
|
|||||||
+179
-23
@@ -1,19 +1,24 @@
|
|||||||
#!/usr/bin/env bun
|
#!/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
|
* 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
|
* most once per 24h; campaign problem descriptions from README.md are
|
||||||
* backfilled into the same db on the way. Problems that already have a
|
* backfilled into the same db on the way. The new-problem section hides
|
||||||
* numbered file under work/ are excluded from the picker — re-picking them
|
* anything that already has a numbered file under work/1 — re-picking it would
|
||||||
* would only clobber the existing scaffold.
|
* 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 { autocomplete, cancel, isCancel, log, spinner } from "@clack/prompts";
|
||||||
import { basename, join } from "node:path";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { fetchDescription, problemDb, readmeSlugs, type Problem } from "./db";
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
import { styleText } from "node:util";
|
||||||
const WORK_DIR = join(ROOT, "work");
|
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();
|
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`);
|
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>();
|
/** What Enter scaffolds: this problem, into this bucket. */
|
||||||
for (const rel of new Bun.Glob("**/*.*").scanSync({ cwd: WORK_DIR })) {
|
interface Choice {
|
||||||
const m = basename(rel).match(/^(\d+)\./);
|
bucket: Bucket;
|
||||||
if (m) done.add(Number(m[1]));
|
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();
|
db.close();
|
||||||
|
|
||||||
const picked = await autocomplete<Problem>({
|
const queues = reviewQueues(etDate(new Date()));
|
||||||
message: "Pick a problem",
|
|
||||||
placeholder: "Type to search by number or title...",
|
const sections: Section[] = [
|
||||||
maxItems: 12,
|
{
|
||||||
options: problems.map((p) => ({
|
label: "new",
|
||||||
value: p,
|
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}`,
|
label: `${p.id}. ${p.title}`,
|
||||||
hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty,
|
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) => {
|
filter: (search, option) => {
|
||||||
const haystack = option.label!.toLowerCase();
|
const haystack = option.label!.toLowerCase();
|
||||||
return search
|
return search
|
||||||
@@ -90,10 +179,77 @@ if (isCancel(picked)) {
|
|||||||
cancel("Nothing picked.");
|
cancel("Nothing picked.");
|
||||||
process.exit(0);
|
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(
|
const proc = Bun.spawn(
|
||||||
// bun installs workspace-dep bins into this package's node_modules, not the root's
|
// 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],
|
[join(import.meta.dir, "node_modules", ".bin", "leetcode"), "pick", picked.slug],
|
||||||
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
|
{ 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
|
* Shared work/ solution picker for the test and submit entries: fuzzy-select
|
||||||
* a solution file, then hand it to leetcode-cli.
|
* 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
|
* 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
|
* 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
|
* 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 { statSync } from "node:fs";
|
||||||
import { basename, join, relative } from "node:path";
|
import { basename, join, relative } from "node:path";
|
||||||
import { problemDb } from "./db";
|
import { problemDb } from "./db";
|
||||||
|
import { ROOT, WORK as WORK_DIR, parseWorkPath } from "./work";
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
|
||||||
const WORK_DIR = join(ROOT, "work");
|
|
||||||
|
|
||||||
/** Leading LC number of a work/ file, or undefined for unnumbered names. */
|
/** Leading LC number of a work/ file, or undefined for unnumbered names. */
|
||||||
function lcNumber(rel: string): number | undefined {
|
function lcNumber(rel: string): number | undefined {
|
||||||
@@ -70,12 +73,12 @@ export async function runOnSolution(command: "test" | "submit", message: string)
|
|||||||
placeholder: "Type to search...",
|
placeholder: "Type to search...",
|
||||||
maxItems: 12,
|
maxItems: 12,
|
||||||
options: files.map((f) => {
|
options: files.map((f) => {
|
||||||
// work layout: Difficulty/Category/<id>.<slug>.<ext>
|
const parsed = parseWorkPath(f);
|
||||||
const [difficulty, category, name] = f.split("/");
|
const stage = parsed?.bucket === 1 ? "first solve" : `${parsed?.bucket}-day review`;
|
||||||
return {
|
return {
|
||||||
value: f,
|
value: f,
|
||||||
label: name ?? f,
|
label: basename(f),
|
||||||
hint: category ? `${difficulty} · ${category}` : difficulty,
|
hint: parsed ? `${stage} · ${parsed.difficulty} · ${parsed.category}` : f,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
filter: (search, option) => {
|
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
|
* Open today's spaced-repetition issue: everything whose problem issue closed
|
||||||
* 3 and 7 days ago, linked straight to LeetCode.
|
* 3 and 7 days ago, linked straight to LeetCode.
|
||||||
*
|
*
|
||||||
* This is the whole SRS. There is no stored ladder, no scheduler and no state
|
* The issue itself holds no state and there is no scheduler here: "what do I
|
||||||
* to drift: "what do I drill today" is a pure function of the issue tracker's
|
* re-solve today" is a pure function of the tracker's close dates and today's
|
||||||
* close dates and today's ET calendar date, recomputed from scratch on every
|
* ET date, recomputed from scratch on every run. A day's issue is keyed by its
|
||||||
* run. A day's issue is keyed by its title (`Spaced Repetition — <Month D,
|
* title (`Spaced Repetition — <Month D, YYYY>`), so re-runs rewrite that one
|
||||||
* YYYY>`), so re-runs rewrite that one body instead of stacking duplicates,
|
* body instead of stacking duplicates, and a backfilled close shows up in the
|
||||||
* and a backfilled close shows up in the next run's windows for free.
|
* next run's windows for free.
|
||||||
*
|
*
|
||||||
* Windows are +3 and +7 days because the campaign's day rule is three new core
|
* Windows are +3 and +7 because that is the campaign's ladder: WINDOWS lives
|
||||||
* problems: the +3 pass catches a problem while the solution is still half
|
* in apps/api/src/srs.ts, names the `work/3` and `work/7` buckets the picker
|
||||||
* remembered, the +7 pass catches it after a week of interference. Each window
|
* scaffolds into, and is chosen so a solve never comes back on the Sunday rest
|
||||||
* asks for at most WINDOW_CAP problems, so a normal day is 3 new + up to 6
|
* day — the +3 pass catches a problem while the solution is half remembered,
|
||||||
* re-solves and no levelling logic is needed to keep the load bounded.
|
* 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
|
* 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
|
* 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`.
|
* 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 { github } from "./github.ts";
|
||||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
|
|
||||||
@@ -36,9 +42,6 @@ const DRY =
|
|||||||
process.env.DRY_RUN === "1" ||
|
process.env.DRY_RUN === "1" ||
|
||||||
process.env.DRY_RUN === "true";
|
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
|
* 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
|
* 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 ────────────────────────────────────────────
|
// ── ET calendar dates ────────────────────────────────────────────
|
||||||
|
|
||||||
// Every date in this script is an ET calendar string (YYYY-MM-DD): the campaign
|
// Every date here is an ET calendar string (YYYY-MM-DD): the campaign runs on
|
||||||
// runs on ET days, and an issue closed at 21:30 ET belongs to that ET day, not
|
// ET days, and an issue closed at 21:30 ET belongs to that ET day, not to the
|
||||||
// to the next UTC one. Arithmetic is anchored at noon UTC so a DST jump can
|
// next UTC one. The helpers come from the Worker's SRS domain so this script,
|
||||||
// never move a date by a day.
|
// the picker and D1 cannot disagree about a date, a window, or the rest 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const LONG = new Intl.DateTimeFormat("en-US", {
|
const LONG = new Intl.DateTimeFormat("en-US", {
|
||||||
timeZone: "UTC",
|
timeZone: "UTC",
|
||||||
@@ -83,7 +69,8 @@ const LONG = new Intl.DateTimeFormat("en-US", {
|
|||||||
|
|
||||||
/** `2026-08-26` -> `August 26, 2026`, or with the weekday prefix. */
|
/** `2026-08-26` -> `August 26, 2026`, or with the weekday prefix. */
|
||||||
function longDate(date: string, weekday = false): string {
|
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);
|
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)) {
|
if (override && !/^\d{4}-\d{2}-\d{2}$/.test(override)) {
|
||||||
throw new Error(`--date wants YYYY-MM-DD, got ${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. */
|
// Sunday is the campaign's rest day and the reason the windows are 3 and 7:
|
||||||
const windowDate = new Map(WINDOWS.map((days) => [days, addDays(today, -days)]));
|
// nothing is ever booked on it, so there is no issue to write. The cron still
|
||||||
const targets = new Set(windowDate.values());
|
// 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 ──────────────────────────────────────────────────
|
// ── 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.
|
// reconcilers only ever close as completed, so this is a human's decision.
|
||||||
if ("state_reason" in value && value.state_reason === "not_planned") return;
|
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;
|
if (!targets.has(closed)) return;
|
||||||
|
|
||||||
const title = TITLE.exec(value.title);
|
const title = TITLE.exec(value.title);
|
||||||
@@ -187,10 +196,10 @@ solved.sort((a, b) => a.issue - b.issue);
|
|||||||
|
|
||||||
const title = `Spaced Repetition — ${longDate(today)}`;
|
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[] {
|
function bucket(days: number): Solved[] {
|
||||||
const date = windowDate.get(days)!;
|
const dates = new Set(windowSources(days));
|
||||||
return solved.filter((s) => s.closed === date);
|
return solved.filter((s) => dates.has(s.closed));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One checkbox line, straight to LeetCode. */
|
/** One checkbox line, straight to LeetCode. */
|
||||||
@@ -202,21 +211,21 @@ function line(s: Solved, box: boolean): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sections = WINDOWS.map((days) => {
|
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 rows = bucket(days);
|
||||||
const due = rows.slice(0, WINDOW_CAP);
|
const due = rows.slice(0, WINDOW_CAP);
|
||||||
const spill = rows.slice(WINDOW_CAP);
|
const spill = rows.slice(WINDOW_CAP);
|
||||||
return [
|
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
|
// Overflow is shown, not dropped: a day that closed more than WINDOW_CAP
|
||||||
// problems (a backfill, or a catch-up weekend) would otherwise silently
|
// problems (a backfill, or a catch-up weekend) would otherwise silently
|
||||||
// lose reviews, and this issue is the only record of what was due.
|
// lose reviews, and this issue is the only record of what was due.
|
||||||
...(spill.length
|
...(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)),
|
...spill.map((s) => line(s, false)),
|
||||||
"",
|
"",
|
||||||
@@ -238,7 +247,8 @@ const body = [
|
|||||||
"",
|
"",
|
||||||
...sections,
|
...sections,
|
||||||
`<sub>Recomputed from the problem issues closed ${WINDOWS.join(" and ")} days ago ` +
|
`<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");
|
].join("\n");
|
||||||
|
|
||||||
// ── create or refresh today's issue ──────────────────────────────
|
// ── create or refresh today's issue ──────────────────────────────
|
||||||
@@ -324,7 +334,7 @@ console.log(
|
|||||||
`\n${title}${DRY ? " (dry run)" : ""}\n` +
|
`\n${title}${DRY ? " (dry run)" : ""}\n` +
|
||||||
WINDOWS.map((days) => {
|
WINDOWS.map((days) => {
|
||||||
const n = bucket(days).length;
|
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(" · ") +
|
}).join(" · ") +
|
||||||
`\n${status}`,
|
`\n${status}`,
|
||||||
);
|
);
|
||||||
|
|||||||
+7
-3
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bun
|
#!/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,
|
* - New problems get a full page (frontmatter, badge, warning, examples,
|
||||||
* constraints, solution) in the gold-standard format.
|
* constraints, solution) in the gold-standard format.
|
||||||
@@ -9,12 +9,16 @@
|
|||||||
* - When a problem is solved in both JavaScript and Python the solution
|
* - When a problem is solved in both JavaScript and Python the solution
|
||||||
* renders as a <CodeGroup> (Python first); single-language solutions
|
* renders as a <CodeGroup> (Python first); single-language solutions
|
||||||
* render as a plain fence.
|
* 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 { mkdir, rename } from "node:fs/promises";
|
||||||
import { basename, join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
const WORK = join(ROOT, "work");
|
const WORK = join(ROOT, "work", "1");
|
||||||
const DOCS = join(ROOT, "apps", "docs", "content");
|
const DOCS = join(ROOT, "apps", "docs", "content");
|
||||||
|
|
||||||
type Lang = "js" | "py";
|
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 file = basename(rel);
|
||||||
const m = file.match(/^(\d+)\.(.+)\.(?:js|py)$/);
|
const m = file.match(/^(\d+)\.(.+)\.(?:js|py)$/);
|
||||||
if (!m) {
|
if (!m) {
|
||||||
console.warn(`skip (unrecognized name): work/${rel}`);
|
console.warn(`skip (unrecognized name): work/1/${rel}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const num = Number(m[1]);
|
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