mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 07:26:27 +00:00
feat(cli): add spaced-repetition script
This commit is contained in:
Executable
+337
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Links come from the problem issue body's first line (the canonical LeetCode
|
||||
* URL written by the campaign-issues convention). A body with no URL falls back
|
||||
* to a slug guessed from the title and is flagged `inferred` in the report,
|
||||
* because repo titles are sometimes abbreviated ("LC 167 · Two Sum II") and a
|
||||
* guessed slug can 404.
|
||||
*
|
||||
* bun apps/cli/spaced-repetition.ts # create/update today's issue
|
||||
* bun apps/cli/spaced-repetition.ts --dry-run # print the body, touch nothing
|
||||
* bun apps/cli/spaced-repetition.ts --date=2026-09-02 # pretend it is that ET day
|
||||
*
|
||||
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
||||
*/
|
||||
import { github } from "./github.ts";
|
||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
|
||||
const DRY =
|
||||
process.argv.includes("--dry-run") ||
|
||||
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
|
||||
* catalog backfill closed 19 issues in one day) keeps the rest listed under a
|
||||
* collapsed "optional" block rather than pretending they were never due.
|
||||
*/
|
||||
const WINDOW_CAP = 3;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const LONG = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "UTC",
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
/** `2026-08-26` -> `August 26, 2026`, or with the weekday prefix. */
|
||||
function longDate(date: string, weekday = false): string {
|
||||
const text = LONG.format(atNoon(date));
|
||||
return weekday ? text : text.slice(text.indexOf(", ") + 2);
|
||||
}
|
||||
|
||||
// ── today ────────────────────────────────────────────────────────
|
||||
|
||||
// `||`, not `??`: the workflow passes SRS_TODAY="" on scheduled runs, where the
|
||||
// input has no value.
|
||||
const override = process.argv.find((a) => a.startsWith("--date="))?.slice(7) || process.env.SRS_TODAY;
|
||||
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());
|
||||
|
||||
/** 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());
|
||||
|
||||
// ── repo + auth ──────────────────────────────────────────────────
|
||||
|
||||
const gh = await github();
|
||||
|
||||
// ── closed problem issues ────────────────────────────────────────
|
||||
|
||||
interface Solved {
|
||||
lc: number;
|
||||
name: string;
|
||||
issue: number;
|
||||
url: string;
|
||||
/** false when the URL was guessed from the title instead of read from the body. */
|
||||
linked: boolean;
|
||||
difficulty: string;
|
||||
/** ET date the issue closed. */
|
||||
closed: string;
|
||||
}
|
||||
|
||||
const TITLE = /^LC\s+(\d+)\s+·\s+(.+?)\s*$/;
|
||||
const LC_URL = /https:\/\/leetcode\.com\/problems\/[a-z0-9-]+/;
|
||||
|
||||
/** LeetCode's own slug shape, for bodies that lost their URL line. */
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow one element of the /issues payload to the fields this script needs.
|
||||
* Drops pull requests (the `problem` label filter cannot), anything whose title
|
||||
* is not a `LC <num> · <Name>` problem, and anything closed outside the two
|
||||
* review windows.
|
||||
*/
|
||||
function readSolved(value: unknown): Solved | undefined {
|
||||
if (!value || typeof value !== "object") return;
|
||||
if ("pull_request" in value) return; // the /issues route also lists PRs
|
||||
if (!("number" in value) || typeof value.number !== "number") return;
|
||||
if (!("title" in value) || typeof value.title !== "string") return;
|
||||
if (!("closed_at" in value) || typeof value.closed_at !== "string") return;
|
||||
// Closed "not planned" means dropped from the curriculum, not solved — the
|
||||
// reconcilers only ever close as completed, so this is a human's decision.
|
||||
if ("state_reason" in value && value.state_reason === "not_planned") return;
|
||||
|
||||
const closed = ET_DATE.format(new Date(value.closed_at));
|
||||
if (!targets.has(closed)) return;
|
||||
|
||||
const title = TITLE.exec(value.title);
|
||||
if (!title) return;
|
||||
|
||||
const body = "body" in value && typeof value.body === "string" ? value.body : "";
|
||||
const url = LC_URL.exec(body)?.[0];
|
||||
const labels =
|
||||
"labels" in value && Array.isArray(value.labels)
|
||||
? value.labels.flatMap((l) =>
|
||||
l && typeof l === "object" && "name" in l && typeof l.name === "string" ? [l.name] : [],
|
||||
)
|
||||
: [];
|
||||
const diff = labels.find((n) => n.startsWith("diff:"))?.slice(5) ?? "unrated";
|
||||
|
||||
return {
|
||||
lc: Number(title[1]),
|
||||
name: title[2]!,
|
||||
issue: value.number,
|
||||
url: `${url ?? `https://leetcode.com/problems/${slugify(title[2]!)}`}/`,
|
||||
linked: url !== undefined,
|
||||
difficulty: diff.charAt(0).toUpperCase() + diff.slice(1),
|
||||
closed,
|
||||
};
|
||||
}
|
||||
|
||||
// The whole closed set is walked rather than filtered with `since`: it is two
|
||||
// pages for the entire 161-problem curriculum, and `since` filters on
|
||||
// updated_at, which a stray comment moves off the close date.
|
||||
const solved: Solved[] = [];
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=closed`)) {
|
||||
const row = readSolved(raw);
|
||||
if (row) solved.push(row);
|
||||
}
|
||||
solved.sort((a, b) => a.issue - b.issue);
|
||||
|
||||
// ── issue body ───────────────────────────────────────────────────
|
||||
|
||||
const title = `Spaced Repetition — ${longDate(today)}`;
|
||||
|
||||
/** Every problem whose issue closed on that window's day, curriculum order. */
|
||||
function bucket(days: number): Solved[] {
|
||||
const date = windowDate.get(days)!;
|
||||
return solved.filter((s) => s.closed === date);
|
||||
}
|
||||
|
||||
/** One checkbox line, straight to LeetCode. */
|
||||
function line(s: Solved, box: boolean): string {
|
||||
return (
|
||||
`${box ? "- [ ] " : "- "}[LC ${s.lc} · ${s.name}](${s.url}) · ${s.difficulty} · #${s.issue}` +
|
||||
(s.linked ? "" : " · ⚠️ link guessed from title")
|
||||
);
|
||||
}
|
||||
|
||||
const sections = WINDOWS.map((days) => {
|
||||
const date = windowDate.get(days)!;
|
||||
const rows = bucket(days);
|
||||
const due = rows.slice(0, WINDOW_CAP);
|
||||
const spill = rows.slice(WINDOW_CAP);
|
||||
return [
|
||||
`### +${days} days — solved ${longDate(date)}`,
|
||||
"",
|
||||
...(due.length ? due.map((s) => line(s, true)) : ["_Nothing closed that day._"]),
|
||||
// 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>`,
|
||||
"",
|
||||
...spill.map((s) => line(s, false)),
|
||||
"",
|
||||
"</details>",
|
||||
]
|
||||
: []),
|
||||
"",
|
||||
].join("\n");
|
||||
});
|
||||
|
||||
const total = WINDOWS.reduce((n, days) => n + Math.min(bucket(days).length, WINDOW_CAP), 0);
|
||||
|
||||
const body = [
|
||||
`Re-solve from memory, ${longDate(today, true)}. Blind: no notes, no \`work/\` file, no editor history.`,
|
||||
"",
|
||||
"- 15 minutes per problem, then stop and read your own solution.",
|
||||
"- Say the invariant and the complexity out loud before typing.",
|
||||
"- Failed one? Tick it anyway and expect it back in the next window.",
|
||||
"",
|
||||
...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>",
|
||||
].join("\n");
|
||||
|
||||
// ── create or refresh today's issue ──────────────────────────────
|
||||
|
||||
interface Existing {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function readExisting(value: unknown): Existing | undefined {
|
||||
if (!value || typeof value !== "object") return;
|
||||
if ("pull_request" in value) return;
|
||||
if (!("number" in value) || typeof value.number !== "number") return;
|
||||
if (!("title" in value) || typeof value.title !== "string") return;
|
||||
const text = "body" in value && typeof value.body === "string" ? value.body : "";
|
||||
return { number: value.number, title: value.title, body: text };
|
||||
}
|
||||
|
||||
// state=all: a day's issue that was already closed must not be recreated.
|
||||
let existing: Existing | undefined;
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=${LABEL}&state=all`)) {
|
||||
const row = readExisting(raw);
|
||||
if (row?.title === title) {
|
||||
existing = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let status: string;
|
||||
if (total === 0) {
|
||||
status = existing
|
||||
? `nothing due — left #${existing.number} alone`
|
||||
: "nothing due — no issue created";
|
||||
} else if (existing && existing.body.trim() === body.trim()) {
|
||||
status = `#${existing.number} already current`;
|
||||
} else if (DRY) {
|
||||
status = existing ? `would update #${existing.number}` : "would create";
|
||||
} else if (existing) {
|
||||
await gh.api(`/repos/${gh.repo}/issues/${existing.number}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
status = `updated #${existing.number}`;
|
||||
} else {
|
||||
// Ensure the label exists before it is used (422 = someone already made it).
|
||||
try {
|
||||
await gh.api(`/repos/${gh.repo}/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: LABEL,
|
||||
color: "0E8A16",
|
||||
description: "Daily blind re-solve of problems closed 3 and 7 days ago",
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!String(err).includes("422")) throw err;
|
||||
}
|
||||
|
||||
const created = (await gh.api(`/repos/${gh.repo}/issues`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, body, labels: [LABEL] }),
|
||||
})) as { number: number };
|
||||
status = `created #${created.number}`;
|
||||
}
|
||||
|
||||
// ── report ───────────────────────────────────────────────────────
|
||||
|
||||
const report = WINDOWS.flatMap((days) =>
|
||||
bucket(days).map((s, i) => [
|
||||
`+${days}d`,
|
||||
String(s.lc),
|
||||
`#${s.issue}`,
|
||||
s.difficulty,
|
||||
i < WINDOW_CAP ? "due" : "optional",
|
||||
s.linked ? s.url : `${s.url} (inferred)`,
|
||||
]),
|
||||
);
|
||||
const rows = [["window", "lc", "issue", "diff", "state", "leetcode"], ...report];
|
||||
if (report.length) printTable(rows);
|
||||
|
||||
console.log(
|
||||
`\n${title}${DRY ? " (dry run)" : ""}\n` +
|
||||
WINDOWS.map((days) => {
|
||||
const n = bucket(days).length;
|
||||
return `+${days}d ${windowDate.get(days)}: ${Math.min(n, WINDOW_CAP)} due${n > WINDOW_CAP ? ` (+${n - WINDOW_CAP} optional)` : ""}`;
|
||||
}).join(" · ") +
|
||||
`\n${status}`,
|
||||
);
|
||||
if (DRY) console.log(`\n${body}`);
|
||||
|
||||
await writeStepSummary(
|
||||
`### ${title}${DRY ? " (dry run)" : ""}\n\n` +
|
||||
`${total} problem${total === 1 ? "" : "s"} due · ${status}\n\n` +
|
||||
(report.length ? `${markdownTable(rows)}\n` : "Nothing closed in either window.\n"),
|
||||
);
|
||||
Reference in New Issue
Block a user