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:
@@ -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}`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user