feat(api): migrate SRS ladder to +3/+7 and adjust rest‑day logic

This commit is contained in:
Prad Nukala
2026-08-31 10:41:21 -04:00
parent bd9afc6ea1
commit e8b0d4dc30
7 changed files with 506 additions and 140 deletions
+60 -50
View File
@@ -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}`,
);