mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 15:36:26 +00:00
348 lines
14 KiB
TypeScript
Executable File
348 lines
14 KiB
TypeScript
Executable File
#!/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.
|
|
*
|
|
* 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
|
|
* 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";
|
|
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.
|
|
|
|
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`));
|
|
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,
|
|
);
|
|
}
|
|
|
|
const targets = new Set(WINDOWS.flatMap((days) => windowSources(days)));
|
|
|
|
// ── 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));
|
|
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. */
|
|
function bucket(days: number): Solved[] {
|
|
const dates = new Set(windowSources(days));
|
|
return solved.filter((s) => dates.has(s.closed));
|
|
}
|
|
|
|
/** 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 ");
|
|
const rows = bucket(days);
|
|
const due = rows.slice(0, WINDOW_CAP);
|
|
const spill = rows.slice(WINDOW_CAP);
|
|
return [
|
|
`### +${days} days — solved ${from}`,
|
|
"",
|
|
...(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 then — 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. " +
|
|
"A window that would fall on Sunday is served on the Monday instead.</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 ${windowSources(days).join("+")}: ${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"),
|
|
);
|