Files
leetcode/apps/cli/spaced-repetition.ts
T

348 lines
14 KiB
TypeScript
Raw Normal View History

2026-08-29 17:26:05 -04:00
#!/usr/bin/env bun
/**
* Open today's spaced-repetition issue: everything whose problem issue closed
* 3 and 7 days ago, linked straight to LeetCode.
*
* 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.
2026-08-29 17:26:05 -04:00
*
* 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.
2026-08-29 17:26:05 -04:00
*
* 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 { WINDOWS, addDays, etDate, isRestDay, workingDay } from "../api/src/srs.ts";
2026-08-29 17:26:05 -04:00
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";
/**
* 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 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.
2026-08-29 17:26:05 -04:00
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 {
// 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`));
2026-08-29 17:26:05 -04:00
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 || etDate(new Date());
// 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,
);
}
2026-08-29 17:26:05 -04:00
const targets = new Set(WINDOWS.flatMap((days) => windowSources(days)));
2026-08-29 17:26:05 -04:00
// ── 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 = etDate(new Date(value.closed_at));
2026-08-29 17:26:05 -04:00
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 one of a window's source days. */
2026-08-29 17:26:05 -04:00
function bucket(days: number): Solved[] {
const dates = new Set(windowSources(days));
return solved.filter((s) => dates.has(s.closed));
2026-08-29 17:26:05 -04:00
}
/** 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 from = windowSources(days).map((d) => longDate(d)).join(" and ");
2026-08-29 17:26:05 -04:00
const rows = bucket(days);
const due = rows.slice(0, WINDOW_CAP);
const spill = rows.slice(WINDOW_CAP);
return [
`### +${days} days — solved ${from}`,
2026-08-29 17:26:05 -04:00
"",
...(due.length ? due.map((s) => line(s, true)) : ["_Nothing closed then._"]),
2026-08-29 17:26:05 -04:00
// 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 then — optional</summary>`,
2026-08-29 17:26:05 -04:00
"",
...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. " +
"A window that would fall on Sunday is served on the Monday instead.</sub>",
2026-08-29 17:26:05 -04:00
].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 ${windowSources(days).join("+")}: ${Math.min(n, WINDOW_CAP)} due${n > WINDOW_CAP ? ` (+${n - WINDOW_CAP} optional)` : ""}`;
2026-08-29 17:26:05 -04:00
}).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"),
);