feat(api): add review day handling and dynamic campaign length

This commit is contained in:
Prad Nukala committed 2026-09-30 17:33:32 -04:00
1 parent 7e9ea8d7c2
commit 239c7013f8
9 files changed
+176 -32

No files matched your search

+6 -3
View File
@@ -17,9 +17,12 @@ Live at `https://srs-api.prdlk.workers.dev`.
- **The repo owns the plan** — `data/schedule.json`, human-edited, - **The repo owns the plan** — `data/schedule.json`, human-edited,
bundled at deploy. Git history is its audit log. Each date is a bundled at deploy. Git history is its audit log. Each date is a
`ScheduledDay`: `topics` (one or more topic issues), `problems` (a set day, `ScheduledDay`: `topics` (one or more topic issues), `problems` (a set day,
fixed numbers), `gate` (Saturday's blind set, verbatim), or `sweep` (redo fixed numbers), `review` (a `Review <N> — <Phase>` issue with its problems
every miss, nothing new). Numbers a `gate` or `problems` entry names are in solve order; Mon/Tue/Thu/Fri only), `gate` (Saturday's blind set,
`RESERVED`: the drill and gate samplers never draw them. verbatim; `[]` skips that week's checkpoint), or `sweep` (redo every miss,
nothing new). Numbers a `gate`, `problems` or `review` entry names are
`RESERVED`: the drill and gate samplers never draw them. The catalog
reconcile reports any drift between a `review` entry and its issue.
- The GitHub Project mirror writes `Target Date` and nothing else: it is - The GitHub Project mirror writes `Target Date` and nothing else: it is
best-effort, so failures log warnings and never block a D1 write, and topic best-effort, so failures log warnings and never block a D1 write, and topic
rows are never written. SRS stage and first-attempt result stay in D1 only — rows are never written. SRS stage and first-attempt result stay in D1 only —
+21 -3
View File
@@ -22,7 +22,25 @@
"2026-09-28": { "topics": [24] }, "2026-09-28": { "topics": [24] },
"2026-09-29": { "topics": [25] }, "2026-09-29": { "topics": [25] },
"2026-09-30": { "topics": [26] }, "2026-09-30": { "topics": [26] },
"2026-10-01": { "problems": [399, 261, 909, 77, 52] }, "2026-10-01": { "review": { "issue": 225, "title": "Review 1 — Linear Structures", "problems": [205, 974, 209, 34, 42] } },
"2026-10-02": { "sweep": true }, "2026-10-02": { "review": { "issue": 226, "title": "Review 2 — Linear Structures", "problems": [290, 525, 189, 1004, 84] }, "sweep": true },
"2026-10-03": { "gate": [347, 1254, 1971, 131, 494, 986] } "2026-10-03": { "gate": [] },
"2026-10-05": { "review": { "issue": 227, "title": "Review 3 — Linear Structures", "problems": [643, 128, 162, 71, 4] } },
"2026-10-06": { "review": { "issue": 228, "title": "Review 4 — Linear Structures", "problems": [496, 80, 901, 76, 224] } },
"2026-10-08": { "review": { "issue": 229, "title": "Review 5 — Nodal & Grid", "problems": [876, 19, 36] } },
"2026-10-09": { "review": { "issue": 230, "title": "Review 6 — Nodal & Grid", "problems": [92, 82, 380] } },
"2026-10-12": { "review": { "issue": 231, "title": "Review 7 — Nodal & Grid", "problems": [143, 86, 289] } },
"2026-10-13": { "review": { "issue": 232, "title": "Review 8 — Nodal & Grid", "problems": [61, 138] } },
"2026-10-15": { "review": { "issue": 233, "title": "Review 9 — Hierarchical", "problems": [100, 112, 530, 1046, 103, 124] } },
"2026-10-16": { "review": { "issue": 234, "title": "Review 10 — Hierarchical", "problems": [572, 543, 703, 105, 295] } },
"2026-10-19": { "review": { "issue": 235, "title": "Review 11 — Hierarchical", "problems": [101, 637, 173, 621, 502] } },
"2026-10-20": { "review": { "issue": 236, "title": "Review 12 — Hierarchical", "problems": [108, 129, 373, 648, 212] } },
"2026-10-22": { "review": { "issue": 237, "title": "Review 13 — Relational", "problems": [1971, 695, 547, 433] } },
"2026-10-23": { "review": { "issue": 238, "title": "Review 14 — Relational", "problems": [130, 261, 909, 1462] } },
"2026-10-26": { "review": { "issue": 239, "title": "Review 15 — Relational", "problems": [286, 1254, 721, 399] } },
"2026-10-27": { "review": { "issue": 240, "title": "Review 16 — Relational", "problems": [1091, 802, 269] } },
"2026-10-29": { "review": { "issue": 241, "title": "Review 17 — Decision Space", "problems": [746, 228, 17, 77, 213, 63, 122] } },
"2026-10-30": { "review": { "issue": 242, "title": "Review 18 — Decision Space", "problems": [22, 90, 139, 120, 45, 253, 72] } },
"2026-11-02": { "review": { "issue": 243, "title": "Review 19 — Decision Space", "problems": [79, 131, 91, 494, 763, 986, 920] } },
"2026-11-03": { "review": { "issue": 244, "title": "Review 20 — Decision Space", "problems": [300, 647, 97, 221, 918, 452, 52] } }
} }
+38 -4
View File
@@ -16,9 +16,9 @@
* problem's row: upserts only, so nothing breaks — its labels just stop * problem's row: upserts only, so nothing breaks — its labels just stop
* syncing. * syncing.
*/ */
import { DIFFICULTIES, SETS, workingDay } from "./srs.ts"; import { DIFFICULTIES, SCHEDULE, SETS, workingDay } from "./srs.ts";
import type { GitHub } from "./github.ts"; import type { GitHub } from "./github.ts";
import { movedFromTopics, readReviews } from "./reviews.ts"; import { movedFromTopics, readReviews, type Review } from "./reviews.ts";
const TITLE_RE = /^LC (\d+) · (.+)$/; const TITLE_RE = /^LC (\d+) · (.+)$/;
@@ -28,6 +28,37 @@ const DEFER_FROM = "2026-09-28";
export interface ReconcileReport { export interface ReconcileReport {
topics: number; topics: number;
problems: number; problems: number;
/** Where schedule.json's review days disagree with the Review issues; empty when in step. */
reviewDrift: string[];
}
/**
* schedule.json copies each review day's issue, title and problems so the
* digest and RESERVED need no GitHub call; the issues stay the truth. Compared
* here because this is the one place that already holds both.
*/
function reviewDrift(reviews: Review[]): string[] {
const drift: string[] = [];
const booked = new Map<number, string>();
for (const [date, day] of Object.entries(SCHEDULE)) {
if (!day.review) continue;
const { issue, title, problems } = day.review;
booked.set(issue, date);
const review = reviews.find((r) => r.number === issue);
if (!review) {
drift.push(`${date}: #${issue} is not a "Review <N> — <Phase>" issue`);
continue;
}
if (review.title !== title) drift.push(`${date}: #${issue} is titled "${review.title}", schedule says "${title}"`);
const actual = review.problems.map((p) => Number(p.title.match(TITLE_RE)?.[1]));
if (actual.join(",") !== problems.join(",")) {
drift.push(`${date}: #${issue} holds LC ${actual.join(", ")}, schedule says LC ${problems.join(", ")}`);
}
}
for (const review of reviews) {
if (!booked.has(review.number)) drift.push(`#${review.number} "${review.title}" has no day in schedule.json`);
}
return drift;
} }
/** Label names on one issue payload, narrowed with guards rather than cast. */ /** Label names on one issue payload, narrowed with guards rather than cast. */
@@ -79,7 +110,10 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
); );
} }
const moved = movedFromTopics(await readReviews(gh)); const reviews = await readReviews(gh);
const drift = reviewDrift(reviews);
for (const line of drift) console.warn(`catalog: review drift — ${line}`);
const moved = movedFromTopics(reviews);
let problems = 0; let problems = 0;
let deferredSeen = 0; let deferredSeen = 0;
for (const t of topics) { for (const t of topics) {
@@ -128,5 +162,5 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
for (let i = 0; i < statements.length; i += 50) { for (let i = 0; i < statements.length; i += 50) {
await db.batch(statements.slice(i, i + 50)); await db.batch(statements.slice(i, i + 50));
} }
return { topics: topics.length, problems }; return { topics: topics.length, problems, reviewDrift: drift };
} }
+15 -2
View File
@@ -8,8 +8,9 @@
* Retrieval rules: review, drill and sweep rows carry number + difficulty * Retrieval rules: review, drill and sweep rows carry number + difficulty
* only — never the topic, never a solution link. `DigestRow` has no field for * only — never the topic, never a solution link. `DigestRow` has no field for
* either, so the rule holds by construction. The plan's own sections — a * either, so the rule holds by construction. The plan's own sections — a
* learning day's topics (core, then the optional "if time" list) and a set * learning day's topics (core, then the optional "if time" list), a set
* day's problems — are the only labeled, linked ones. At most REVIEW_CAP (3) * day's problems, and a review day's `Review <N>` issue — are the only
* labeled, linked ones. At most REVIEW_CAP (3)
* reviews per day; drills fill toward DAILY_CAP (6) only when the due queue * reviews per day; drills fill toward DAILY_CAP (6) only when the due queue
* is light, and never on a sweep day, which assigns every open miss and * is light, and never on a sweep day, which assigns every open miss and
* nothing new. Overflow is levelled, not stacked: sendDigest gives every * nothing new. Overflow is levelled, not stacked: sendDigest gives every
@@ -150,6 +151,18 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
data.assigned = rows.map((p) => topicRow(env, p)); data.assigned = rows.map((p) => topicRow(env, p));
} }
// A review day: the `Review <N> — <Phase>` issue's problems, in its solve
// order, linked like a set day's and headed by the issue itself.
if (plan.review) {
const { rows, missing } = await lookupProblems(db, plan.review.problems);
if (missing.length) console.warn(`digest ${date}: review problems not in the catalog: ${missing.join(", ")}`);
data.review = {
title: plan.review.title,
url: `https://github.com/${env.REPO}/issues/${plan.review.issue}`,
rows: rows.map((p) => topicRow(env, p)),
};
}
// A sweep day works the miss log: every problem still standing on a // A sweep day works the miss log: every problem still standing on a
// failure, minus the ones today's review list already carries. // failure, minus the ones today's review list already carries.
if (plan.sweep) { if (plan.sweep) {
+41 -8
View File
@@ -71,6 +71,13 @@ const LAST_SEEN: Record<string, string> = {
retired: "retired", retired: "retired",
}; };
// A review day's rules, the same ones its GitHub issue carries — one sentence
// here so the HTML and the plain-text alternative cannot word them apart.
const REVIEW_RULES =
"90 minutes, top to bottom — easy to hard; skip what is already solved. " +
"Before each submit, say the time and space complexity out loud and trace one edge case. " +
"Whatever is unfinished stays open.";
// ── data model ─────────────────────────────────────────────────── // ── data model ───────────────────────────────────────────────────
/** A review or drill line: number + difficulty + one-tap links, nothing else. */ /** A review or drill line: number + difficulty + one-tap links, nothing else. */
@@ -101,7 +108,7 @@ export interface TopicSection {
export interface DigestData { export interface DigestData {
/** "Tuesday, August 25" */ /** "Tuesday, August 25" */
day: string; day: string;
/** "Day 9 of 56 · Week 2" */ /** "Day 9 of 79 · Week 2" — the length is CAMPAIGN_DAYS, derived from the plan. */
progress: string; progress: string;
streak: number; streak: number;
rest: boolean; rest: boolean;
@@ -109,6 +116,8 @@ export interface DigestData {
topics: TopicSection[]; topics: TopicSection[];
/** A set day's problems, assigned by number rather than by topic. */ /** A set day's problems, assigned by number rather than by topic. */
assigned?: TopicRow[]; assigned?: TopicRow[];
/** A review day: the `Review <N> — <Phase>` issue and its problems in solve order. */
review?: { title: string; url: string; rows: TopicRow[] };
reviews: DigestRow[]; reviews: DigestRow[];
/** Reviews past the daily cap; levelled onto the coming days. */ /** Reviews past the daily cap; levelled onto the coming days. */
carried: number; carried: number;
@@ -293,11 +302,13 @@ function DigestEmail({ data }: { data: DigestData }) {
const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0); const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
const lead = data.topics[0] const lead = data.topics[0]
? `, starting with ${data.topics[0].name}` ? `, starting with ${data.topics[0].name}`
: data.assigned : data.review
? ", starting with today's set" ? `, starting with ${data.review.title}`
: data.sweep : data.assigned
? " — sweep day, nothing new" ? ", starting with today's set"
: ""; : data.sweep
? " — sweep day, nothing new"
: "";
const preview = data.rest ? "Rest day — nothing to do but rest." : `${load} to work through today${lead}.`; const preview = data.rest ? "Rest day — nothing to do but rest." : `${load} to work through today${lead}.`;
const passed = data.yesterday.total - data.yesterday.failed.length; const passed = data.yesterday.total - data.yesterday.failed.length;
@@ -350,6 +361,19 @@ function DigestEmail({ data }: { data: DigestData }) {
</Section> </Section>
))} ))}
{data.review ? (
<Section style={{ marginTop: "28px" }}>
<Text style={label}>Today's review</Text>
<Text style={{ fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
<Link href={data.review.url} style={{ color: INK, textDecoration: "underline" }}>
{data.review.title}
</Link>
</Text>
<Text style={hint}>{REVIEW_RULES}</Text>
<TopicTable rows={data.review.rows} />
</Section>
) : null}
{data.assigned ? ( {data.assigned ? (
<Section style={{ marginTop: "28px" }}> <Section style={{ marginTop: "28px" }}>
<Text style={label}>Today's set</Text> <Text style={label}>Today's set</Text>
@@ -407,14 +431,14 @@ function DigestEmail({ data }: { data: DigestData }) {
</> </>
)} )}
{/* Eight weeks at a glance — the one thing here that is pure {/* The whole campaign at a glance — the one thing here that is pure
encouragement rather than instruction. */} encouragement rather than instruction. */}
<Section style={{ marginTop: "28px" }}> <Section style={{ marginTop: "28px" }}>
<Text style={label}>Every day you showed up</Text> <Text style={label}>Every day you showed up</Text>
<Link href={data.progressUrl}> <Link href={data.progressUrl}>
<Img <Img
src={data.heatmapUrl} src={data.heatmapUrl}
alt="Attempt heatmap across the eight-week campaign" alt="Attempt heatmap across the whole campaign"
width="552" width="552"
style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }} style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }}
/> />
@@ -473,6 +497,15 @@ function plainDigest(data: DigestData): string {
out.push(" If time — only once the core is done inside the clock:", ...topic.ifTime.map(linked)); out.push(" If time — only once the core is done inside the clock:", ...topic.ifTime.map(linked));
} }
} }
if (data.review) {
out.push(
"",
`TODAY'S REVIEW — ${data.review.title}`,
` ${data.review.url}`,
REVIEW_RULES,
...data.review.rows.map(linked),
);
}
if (data.assigned) { if (data.assigned) {
out.push( out.push(
"", "",
+3 -1
View File
@@ -5,7 +5,8 @@
* The gate half is a topic-blind quiz. When the plan fixes the set * The gate half is a topic-blind quiz. When the plan fixes the set
* (`gate` in data/schedule.json) that list IS the gate, verbatim — every * (`gate` in data/schedule.json) that list IS the gate, verbatim — every
* number must be a catalog problem, or the issue is not created, because a * number must be a catalog problem, or the issue is not created, because a
* `/done` on an uncatalogued number can never be logged. Otherwise the set * `/done` on an uncatalogued number can never be logged; an empty list skips
* the week's checkpoint outright. Otherwise the set
* is sampled: one unsolved optional problem per topic scheduled this week * is sampled: one unsolved optional problem per topic scheduled this week
* (which is why daily drills only draw from earlier weeks), never one the * (which is why daily drills only draw from earlier weeks), never one the
* plan has reserved. The recap half is generated data, deliberately NOT a * plan has reserved. The recap half is generated data, deliberately NOT a
@@ -77,6 +78,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
// week without topics, across every topic that still has an unsolved pool. // week without topics, across every topic that still has an unsolved pool.
const picks: { p: ProblemRow; fallback: boolean }[] = []; const picks: { p: ProblemRow; fallback: boolean }[] = [];
const planned = SCHEDULE[date]?.gate; const planned = SCHEDULE[date]?.gate;
if (planned?.length === 0) return { created: false, reason: `the plan skips the week ${week} checkpoint` };
if (planned) { if (planned) {
const { rows, missing } = await lookupProblems(db, planned); const { rows, missing } = await lookupProblems(db, planned);
if (missing.length) { if (missing.length) {
+3 -2
View File
@@ -18,6 +18,7 @@ import { type GitHub, github } from "./github.ts";
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts"; import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
import { type Mirror, projectMirror } from "./mirror.ts"; import { type Mirror, projectMirror } from "./mirror.ts";
import { import {
CAMPAIGN_DAYS,
CAMPAIGN_START, CAMPAIGN_START,
type LogOutcome, type LogOutcome,
type Stage, type Stage,
@@ -333,10 +334,10 @@ export default {
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS }); return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
} }
if (path === "/chart/heatmap.svg") { if (path === "/chart/heatmap.svg") {
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, 56), { headers: SVG_HEADERS }); return new Response(await heatmapChart(env.DB, CAMPAIGN_START, CAMPAIGN_DAYS), { headers: SVG_HEADERS });
} }
if (path === "/chart/heatmap.png") { if (path === "/chart/heatmap.png") {
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, 56), { headers: PNG_HEADERS }); return new Response(await heatmapPng(env.DB, CAMPAIGN_START, CAMPAIGN_DAYS), { headers: PNG_HEADERS });
} }
if (path === "/badge/gate.svg") { if (path === "/badge/gate.svg") {
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS }); return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
+16
View File
@@ -134,6 +134,8 @@ describe("the plan", () => {
expect(isRestDay(date)).toBe(false); expect(isRestDay(date)).toBe(false);
if (day.gate) expect(weekdayOf(date)).toBe(6); if (day.gate) expect(weekdayOf(date)).toBe(6);
if (day.topics) expect(weekdayOf(date)).toBeLessThan(6); if (day.topics) expect(weekdayOf(date)).toBeLessThan(6);
// Reviews run Mon/Tue and Thu/Fri; Wednesday and the weekend stay free.
if (day.review) expect([1, 2, 4, 5]).toContain(weekdayOf(date));
} }
}); });
@@ -150,6 +152,20 @@ describe("the plan", () => {
expect(topicDate(999)).toBeUndefined(); expect(topicDate(999)).toBeUndefined();
}); });
test("a review is booked once, and no problem sits in two reviews", () => {
const issues = new Set<number>();
const problems = new Set<number>();
for (const day of Object.values(SCHEDULE)) {
if (!day.review) continue;
expect(issues.has(day.review.issue)).toBe(false);
issues.add(day.review.issue);
for (const lc of day.review.problems) {
expect(problems.has(lc)).toBe(false);
problems.add(lc);
}
}
});
test("every gate and set problem is reserved from the samplers", () => { test("every gate and set problem is reserved from the samplers", () => {
for (const day of Object.values(SCHEDULE)) { for (const day of Object.values(SCHEDULE)) {
for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) { for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) {
+33 -9
View File
@@ -22,8 +22,8 @@ import scheduleJson from "../data/schedule.json";
// ── dates (America/New_York) ───────────────────────────────────── // ── dates (America/New_York) ─────────────────────────────────────
/** Day 1. The last day is not a constant: see CAMPAIGN_END below the schedule. */
export const CAMPAIGN_START = "2026-08-17"; export const CAMPAIGN_START = "2026-08-17";
export const CAMPAIGN_DAYS = 56;
const ET_DATE = new Intl.DateTimeFormat("en-CA", { const ET_DATE = new Intl.DateTimeFormat("en-CA", {
timeZone: "America/New_York", timeZone: "America/New_York",
@@ -130,15 +130,26 @@ export function prettyDate(date: string): string {
* One calendar day of the plan. Most days teach topics (one, or two when the * One calendar day of the plan. Most days teach topics (one, or two when the
* plan doubles up); the rest are named by what they assign instead: a fixed * plan doubles up); the rest are named by what they assign instead: a fixed
* gate set for Saturday's review issue, a fixed problem set (a "B-set" day), * gate set for Saturday's review issue, a fixed problem set (a "B-set" day),
* or a sweep — redo every miss, nothing new. Absent dates are unplanned: * a transfer-phase review day, or a sweep — redo every miss, nothing new.
* the ladder still runs, the digest just has nothing new to say. * Absent dates are unplanned: the ladder still runs, the digest just has
* nothing new to say.
*/ */
export interface ScheduledDay { export interface ScheduledDay {
/** Topic issues taught that day, in the order the digest presents them. */ /** Topic issues taught that day, in the order the digest presents them. */
topics?: number[]; topics?: number[];
/** Problems assigned by number instead of by topic. */ /** Problems assigned by number instead of by topic. */
problems?: number[]; problems?: number[];
/** Saturday: the blind set, fixed by the plan rather than sampled. */ /**
* A `Review <N> — <Phase>` day (Mon/Tue/Thu/Fri only): the issue, its title
* and its problems in solve order. The issue's sub-issues are the truth; this
* copy is what the digest and RESERVED read without calling GitHub, and the
* catalog reconcile reports any drift between the two.
*/
review?: { issue: number; title: string; problems: number[] };
/**
* Saturday: the blind set, fixed by the plan rather than sampled. An empty
* list skips that week's checkpoint — no issue is created.
*/
gate?: number[]; gate?: number[];
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */ /** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
sweep?: boolean; sweep?: boolean;
@@ -146,6 +157,15 @@ export interface ScheduledDay {
export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson; export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
/**
* The last day the plan books anything — the campaign ends there. Derived,
* not declared: booking a later day (the review phase ran the plan past its
* original eight weeks) moves the digest's "Day N/M", the stats' `heat` and
* the heatmap charts with it, and nothing can disagree about when it ends.
*/
export const CAMPAIGN_END = Object.keys(SCHEDULE).sort().at(-1)!;
export const CAMPAIGN_DAYS = daysBetween(CAMPAIGN_START, CAMPAIGN_END) + 1;
/** Date the schedule teaches a topic on, if it teaches it at all. */ /** Date the schedule teaches a topic on, if it teaches it at all. */
export function topicDate(topic: number): string | undefined { export function topicDate(topic: number): string | undefined {
for (const [date, day] of Object.entries(SCHEDULE)) { for (const [date, day] of Object.entries(SCHEDULE)) {
@@ -161,13 +181,17 @@ export function topicWeek(topic: number): number | undefined {
} }
/** /**
* Every problem the plan names outright — a gate's blind set or a set day's * Every problem the plan names outright — a gate's blind set, a set day's
* list. The plan owns these: the samplers (daily drills, a sampled gate) * list, or a review day's. The plan owns these: the samplers (daily drills,
* never draw them, so a gate problem stays unseen until its Saturday and a * a sampled gate) never draw them, so a gate problem stays unseen until its
* set day's problems wait for their day. * Saturday and a set or review day's problems wait for their day.
*/ */
export const RESERVED: ReadonlySet<number> = new Set( export const RESERVED: ReadonlySet<number> = new Set(
Object.values(SCHEDULE).flatMap((day) => [...(day.gate ?? []), ...(day.problems ?? [])]), Object.values(SCHEDULE).flatMap((day) => [
...(day.gate ?? []),
...(day.problems ?? []),
...(day.review?.problems ?? []),
]),
); );
// ── deterministic sampling ─────────────────────────────────────── // ── deterministic sampling ───────────────────────────────────────