From 86107d5ef7eb589bed96c9f29d85e008b674a402 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Mon, 21 Sep 2026 12:14:25 -0400 Subject: [PATCH] feat(api): add structured schedule plan with topics, problems, gate, sweep and reserved problem handling --- apps/api/README.md | 13 +++-- apps/api/data/schedule.json | 50 +++++++++--------- apps/api/src/digest.ts | 94 ++++++++++++++++++++++++--------- apps/api/src/email.tsx | 102 ++++++++++++++++++++++++++++++------ apps/api/src/gate.ts | 98 ++++++++++++++++++++-------------- apps/api/src/srs.test.ts | 35 +++++++++++++ apps/api/src/srs.ts | 65 +++++++++++++++++++++-- 7 files changed, 344 insertions(+), 113 deletions(-) diff --git a/apps/api/README.md b/apps/api/README.md index 1480cce..61182ed 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -14,8 +14,12 @@ Live at `https://srs-api.prdlk.workers.dev`. labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via `/admin/reconcile`); it recomputes from scratch, never invents rows, and never overwrites SRS-owned columns (`stage`, `next_review`). -- **The repo owns the schedule** — `data/schedule.json`, human-edited, - bundled at deploy. Git history is its audit log. +- **The repo owns the plan** — `data/schedule.json`, human-edited, + bundled at deploy. Git history is its audit log. Each date is a + `ScheduledDay`: `topics` (one or more topic issues), `problems` (a set day, + fixed numbers), `gate` (Saturday's blind set, verbatim), or `sweep` (redo + every miss, nothing new). Numbers a `gate` or `problems` entry names are + `RESERVED`: the drill and gate samplers never draw them. - 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 rows are never written. SRS stage and first-attempt result stay in D1 only — @@ -47,7 +51,10 @@ raw `Date` math. Blind drills draw only from topics **already learned**: scheduled in an earlier week (the current week's optional pool is reserved for Saturday's gate) and with at least one core problem on the ladder — a skipped learning -day never feeds drills just because its calendar week lapsed. +day never feeds drills just because its calendar week lapsed. A Saturday +whose plan fixes the `gate` set uses that list as-is; every number must be a +catalog row (a `set:optional` sub-issue of its topic), or the review issue is +not created — a `/done` on an uncatalogued number could never be logged. ## Routes diff --git a/apps/api/data/schedule.json b/apps/api/data/schedule.json index 49fcd85..3489fec 100644 --- a/apps/api/data/schedule.json +++ b/apps/api/data/schedule.json @@ -1,26 +1,28 @@ { - "2026-08-17": 3, - "2026-08-18": 4, - "2026-08-24": 5, - "2026-08-25": 6, - "2026-08-26": 7, - "2026-08-27": 8, - "2026-08-28": 9, - "2026-08-31": 10, - "2026-09-01": 11, - "2026-09-02": 12, - "2026-09-03": 13, - "2026-09-04": 14, - "2026-09-07": 15, - "2026-09-08": 16, - "2026-09-09": 17, - "2026-09-10": 18, - "2026-09-11": 19, - "2026-09-14": 20, - "2026-09-15": 21, - "2026-09-16": 22, - "2026-09-17": 23, - "2026-09-18": 24, - "2026-09-21": 25, - "2026-09-22": 26 + "2026-08-17": { "topics": [3] }, + "2026-08-18": { "topics": [4] }, + "2026-08-24": { "topics": [5] }, + "2026-08-25": { "topics": [6] }, + "2026-08-26": { "topics": [7] }, + "2026-08-27": { "topics": [8] }, + "2026-08-28": { "topics": [9] }, + "2026-08-31": { "topics": [10] }, + "2026-09-01": { "topics": [11] }, + "2026-09-02": { "topics": [12] }, + "2026-09-03": { "topics": [13] }, + "2026-09-04": { "topics": [14] }, + "2026-09-07": { "topics": [15] }, + "2026-09-08": { "topics": [16] }, + "2026-09-21": { "topics": [17, 18] }, + "2026-09-22": { "topics": [19, 20] }, + "2026-09-23": { "topics": [21] }, + "2026-09-24": { "topics": [22] }, + "2026-09-25": { "topics": [23] }, + "2026-09-26": { "gate": [648, 1091, 802, 547, 90] }, + "2026-09-28": { "topics": [24] }, + "2026-09-29": { "topics": [25] }, + "2026-09-30": { "topics": [26] }, + "2026-10-01": { "problems": [399, 261, 909, 77, 52] }, + "2026-10-02": { "sweep": true }, + "2026-10-03": { "gate": [347, 1254, 1971, 131, 494, 986] } } diff --git a/apps/api/src/digest.ts b/apps/api/src/digest.ts index d904447..0cc20a3 100644 --- a/apps/api/src/digest.ts +++ b/apps/api/src/digest.ts @@ -5,14 +5,16 @@ * the HTML and the plain-text alternative from that single tree (React Email * `render`), so the two can never drift. * - * Retrieval rules: review and drill rows carry number + difficulty only — - * never the topic, never a solution link. `DigestRow` has no field for - * either, so the rule holds by construction. The learning day's core list is - * the only labeled section. At most REVIEW_CAP (3) reviews per day; drills - * fill toward DAILY_CAP (6) only when the due queue is light. Overflow is - * levelled, not stacked: sendDigest gives every review past the cap a - * concrete future date, ≤ REVIEW_CAP per day, oldest first. Sunday is a - * two-line rest note. + * Retrieval rules: review, drill and sweep rows carry number + difficulty + * 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 + * learning day's topics (core, then the optional "if time" list) and a set + * day's problems — 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 + * is light, and never on a sweep day, which assigns every open miss and + * nothing new. Overflow is levelled, not stacked: sendDigest gives every + * review past the cap a concrete future date, ≤ REVIEW_CAP per day, oldest + * first. Sunday is a two-line rest note. * * Idempotency: email_log keys sends by ET date — a same-day re-send is a * no-op unless forced; the body itself is deterministic (drill picks are @@ -22,20 +24,23 @@ import { CAMPAIGN_DAYS, type ProblemRow, + RESERVED, REVIEW_CAP, SCHEDULE, + type ScheduledDay, addDays, campaignDay, campaignWeek, dueReviews, isoWeek, levelReviews, + lookupProblems, pickDrills, prettyDate, streak, weekdayOf, } from "./srs.ts"; -import { type DigestData, type DigestRow, renderDigest } from "./email.tsx"; +import { type DigestData, type DigestRow, type TopicRow, renderDigest } from "./email.tsx"; import { signLink } from "./links.ts"; /** Total daily ceiling: drills only fill what the whole due queue leaves. */ @@ -63,23 +68,50 @@ async function tapRow(env: Env, p: ProblemRow, date: string, staged: boolean): P }; } +/** A plan-named problem: linked to its issue, ticked once it has entered the ladder. */ +function topicRow(env: Env, p: ProblemRow): TopicRow { + return { + lc: p.lc_number, + difficulty: p.difficulty, + url: `https://github.com/${env.REPO}/issues/${p.issue}`, + solved: p.stage !== "new", + }; +} + +/** Problems whose most recent attempt failed — the miss log a sweep day works through. */ +async function openMisses(db: D1Database): Promise { + const { results } = await db + .prepare( + `SELECT p.* FROM problems p + JOIN attempts a ON a.lc_number = p.lc_number + WHERE a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number) + AND a.result = 'fail' AND p.stage != 'retired' + ORDER BY a.date, p.lc_number`, + ) + .all(); + return results; +} + /** Everything the email needs, read straight out of D1 and the schedule. */ export async function collectDigest(env: Env, date: string): Promise { const db = env.DB; const rest = weekdayOf(date) === 0; + const plan: ScheduledDay = rest ? {} : (SCHEDULE[date] ?? {}); // Reviews surface at most REVIEW_CAP; drills fill toward DAILY_CAP only // when the WHOLE due queue is light — a heavy backlog must never trade - // review slots for brand-new drill problems. + // review slots for brand-new drill problems — and never on a sweep day. const due = rest ? [] : await dueReviews(db, date); const capped = due.slice(0, REVIEW_CAP); - const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP)); + const drills = + rest || plan.sweep ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP)); const data: DigestData = { day: prettyDate(date), progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`, streak: await streak(db, date), rest, + topics: [], reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))), carried: due.length - capped.length, drills: await Promise.all(drills.map((p) => tapRow(env, p, date, false))), @@ -88,28 +120,42 @@ export async function collectDigest(env: Env, date: string): Promise progressUrl: `${env.DOCS_URL}/progress`, }; - // New topic — the only section allowed to name problems and link them. - const topicIssue = rest ? undefined : SCHEDULE[date]; - if (topicIssue !== undefined) { + // New topics — the plan's sections, the only ones allowed to name problems + // and link them. Core first; the optional set is "if time", minus anything + // the plan has reserved for a gate or a set day. + for (const topicIssue of plan.topics ?? []) { const topic = await db .prepare("SELECT name FROM topics WHERE issue = ?") .bind(topicIssue) .first<{ name: string }>(); - const { results: core } = await db + const { results } = await db .prepare( - "SELECT * FROM problems WHERE topic_issue = ? AND set_label = 'core' ORDER BY lc_number", + "SELECT * FROM problems WHERE topic_issue = ? AND set_label IN ('core', 'optional') ORDER BY lc_number", ) .bind(topicIssue) .all(); - data.topic = { + data.topics.push({ name: topic?.name ?? `#${topicIssue}`, - core: core.map((p) => ({ - lc: p.lc_number, - difficulty: p.difficulty, - url: `https://github.com/${env.REPO}/issues/${p.issue}`, - solved: p.stage !== "new", - })), - }; + core: results.filter((p) => p.set_label === "core").map((p) => topicRow(env, p)), + ifTime: results + .filter((p) => p.set_label === "optional" && !RESERVED.has(p.lc_number)) + .map((p) => topicRow(env, p)), + }); + } + + // A set day assigns problems by number rather than by topic. + if (plan.problems) { + const { rows, missing } = await lookupProblems(db, plan.problems); + if (missing.length) console.warn(`digest ${date}: planned problems not in the catalog: ${missing.join(", ")}`); + data.assigned = rows.map((p) => topicRow(env, p)); + } + + // A sweep day works the miss log: every problem still standing on a + // failure, minus the ones today's review list already carries. + if (plan.sweep) { + const reviewed = new Set(capped.map((p) => p.lc_number)); + const misses = (await openMisses(db)).filter((p) => !reviewed.has(p.lc_number)); + data.sweep = await Promise.all(misses.map((p) => tapRow(env, p, date, true))); } // Saturday: the review issue already exists (created at midnight ET). diff --git a/apps/api/src/email.tsx b/apps/api/src/email.tsx index c8f5451..81e2b35 100644 --- a/apps/api/src/email.tsx +++ b/apps/api/src/email.tsx @@ -83,7 +83,7 @@ export interface DigestRow { failUrl: string; } -/** A learning-day core problem — the only rows allowed a link. */ +/** A plan-named problem — the only rows allowed a link. */ export interface TopicRow { lc: number; difficulty: string; @@ -91,6 +91,13 @@ export interface TopicRow { solved: boolean; } +/** One learning-day topic: the core list, then the optional "if time" list. */ +export interface TopicSection { + name: string; + core: TopicRow[]; + ifTime: TopicRow[]; +} + export interface DigestData { /** "Tuesday, August 25" */ day: string; @@ -98,11 +105,16 @@ export interface DigestData { progress: string; streak: number; rest: boolean; - topic?: { name: string; core: TopicRow[] }; + /** Topics taught today, in plan order — empty on every other day. */ + topics: TopicSection[]; + /** A set day's problems, assigned by number rather than by topic. */ + assigned?: TopicRow[]; reviews: DigestRow[]; /** Reviews past the daily cap; levelled onto the coming days. */ carried: number; drills: DigestRow[]; + /** A sweep day's miss log: present (even empty) only on a sweep day. */ + sweep?: DigestRow[]; gate?: { week: number; url: string }; yesterday: { total: number; failed: number[] }; gateRate?: number; @@ -278,10 +290,15 @@ function Block({ // ── email ──────────────────────────────────────────────────────── function DigestEmail({ data }: { data: DigestData }) { - const load = data.reviews.length + data.drills.length; - const preview = data.rest - ? "Rest day — nothing to do but rest." - : `${load} to work through today${data.topic ? `, starting with ${data.topic.name}` : ""}.`; + const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0); + const lead = data.topics[0] + ? `, starting with ${data.topics[0].name}` + : data.assigned + ? ", 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 passed = data.yesterday.total - data.yesterday.failed.length; const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", "); @@ -310,15 +327,47 @@ function DigestEmail({ data }: { data: DigestData }) { {data.rest ? null : ( <> - {data.topic ? ( -
- Something new today + {data.topics.map((topic, i) => ( +
+ {i === 0 ? ( + + {data.topics.length === 1 ? "Something new today" : "Two new things today"} + + ) : null} - {data.topic.name} + {topic.name} Work through these from the top — a tick means it is already solved. - + + {topic.ifTime.length > 0 ? ( + <> + + If time — only once the core is done inside the clock. + + + + ) : null}
+ ))} + + {data.assigned ? ( +
+ Today's set + + Picked by number, not by topic — all of them, in order, inside the clock. + + +
+ ) : null} + + {data.sweep ? ( + ) : null} + ` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`; + if (!data.rest) { - if (data.topic) { + for (const topic of data.topics) { out.push( "", - `SOMETHING NEW TODAY — ${data.topic.name}`, + `SOMETHING NEW TODAY — ${topic.name}`, "Work through these from the top; the ones marked done you have already solved.", - ...data.topic.core.map( - (p) => - ` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`, - ), + ...topic.core.map(linked), + ); + if (topic.ifTime.length > 0) { + out.push(" If time — only once the core is done inside the clock:", ...topic.ifTime.map(linked)); + } + } + if (data.assigned) { + out.push( + "", + "TODAY'S SET", + "Picked by number, not by topic — all of them, in order, inside the clock.", + ...data.assigned.map(linked), ); } @@ -436,6 +496,14 @@ function plainDigest(data: DigestData): string { "No cold starts today.", ], ]; + if (data.sweep) { + lists.unshift([ + "SWEEP — REDO EVERY RED", + "Every problem still standing on a miss. Nothing new today; clear these first.", + data.sweep, + "The miss log is clear — nothing is standing on a fail.", + ]); + } for (const [title, note, rows, empty] of lists) { out.push("", title); if (rows.length === 0) { diff --git a/apps/api/src/gate.ts b/apps/api/src/gate.ts index a305c83..c56030f 100644 --- a/apps/api/src/gate.ts +++ b/apps/api/src/gate.ts @@ -2,21 +2,27 @@ * Saturday review issue: created at midnight ET so the 8 AM digest can link * to it; scored when the issue closes (webhook). * - * The gate half is a topic-blind quiz — one unsolved optional problem per - * topic scheduled this week (which is why daily drills only draw from - * earlier weeks). The recap half is generated data, deliberately NOT a task - * list: the week's solved problems are already scheduled by the +3/+7 ladder, - * and re-assigning them on Saturday would be massed practice. + * 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 + * 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 + * is sampled: one unsolved optional problem per topic scheduled this week + * (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 + * task list: the week's solved problems are already scheduled by the +3/+7 + * ladder, and re-assigning them on Saturday would be massed practice. * * Label is `review` (the retired Actions system owned `gate`). Creation is - * idempotent by week; deferred Hards never appear. + * idempotent by week; deferred Hards never appear in a sampled set. */ import { type ProblemRow, + RESERVED, type Result, SCHEDULE, campaignWeek, isoWeek, + lookupProblems, rng, sample, streak, @@ -32,7 +38,7 @@ function capitalized(difficulty: string): string { return difficulty[0]!.toUpperCase() + difficulty.slice(1); } -/** Unsolved problems of a topic in a set — never attempted, never drilled. */ +/** Unsolved problems of a topic in a set — never attempted, drilled, or reserved. */ async function freshPool(db: D1Database, topic: number, set: string): Promise { const { results } = await db .prepare( @@ -44,7 +50,7 @@ async function freshPool(db: D1Database, topic: number, set: string): Promise(); - return results; + return results.filter((p) => !RESERVED.has(p.lc_number)); } export interface CreateReport { @@ -67,40 +73,52 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` }; } - // Topics scheduled this week; review-only weeks (Sep 23 onward) sample - // across every topic that still has unsolved optional problems. - let topics = Object.entries(SCHEDULE) - .filter(([d]) => campaignWeek(d) === week) - .sort(([a], [b]) => (a < b ? -1 : 1)) - .map(([, t]) => t); - if (topics.length === 0) { - const { results } = await db - .prepare( - `SELECT DISTINCT topic_issue AS t FROM problems - WHERE set_label = 'optional' AND stage = 'new' - AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used) - ORDER BY topic_issue`, - ) - .all<{ t: number }>(); - topics = sample(results.map((r) => r.t), MAX_GATE_PROBLEMS, rng(`gate-topics-${iso}`)).sort( - (a, b) => a - b, - ); - } - if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" }; - - const random = rng(`gate-${iso}`); + // The plan's fixed set wins; otherwise sample this week's topics, and in a + // week without topics, across every topic that still has an unsolved pool. const picks: { p: ProblemRow; fallback: boolean }[] = []; - for (const topic of topics) { - const optional = await freshPool(db, topic, "optional"); - if (optional.length > 0) { - picks.push({ p: sample(optional, 1, random)[0]!, fallback: false }); - continue; + const planned = SCHEDULE[date]?.gate; + if (planned) { + const { rows, missing } = await lookupProblems(db, planned); + if (missing.length) { + return { + created: false, + reason: `planned gate names problems outside the catalog: LC ${missing.join(", LC ")}`, + }; } - const core = await freshPool(db, topic, "core"); - if (core.length > 0) picks.push({ p: sample(core, 1, random)[0]!, fallback: true }); - // Both pools exhausted: the topic is fully solved; nothing to quiz. + picks.push(...rows.map((p) => ({ p, fallback: false }))); + } else { + let topics = Object.entries(SCHEDULE) + .filter(([d]) => campaignWeek(d) === week) + .sort(([a], [b]) => (a < b ? -1 : 1)) + .flatMap(([, day]) => day.topics ?? []); + if (topics.length === 0) { + const { results } = await db + .prepare( + `SELECT DISTINCT topic_issue AS t FROM problems + WHERE set_label = 'optional' AND stage = 'new' + AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used) + ORDER BY topic_issue`, + ) + .all<{ t: number }>(); + topics = sample(results.map((r) => r.t), MAX_GATE_PROBLEMS, rng(`gate-topics-${iso}`)).sort( + (a, b) => a - b, + ); + } + if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" }; + + const random = rng(`gate-${iso}`); + for (const topic of topics) { + const optional = await freshPool(db, topic, "optional"); + if (optional.length > 0) { + picks.push({ p: sample(optional, 1, random)[0]!, fallback: false }); + continue; + } + const core = await freshPool(db, topic, "core"); + if (core.length > 0) picks.push({ p: sample(core, 1, random)[0]!, fallback: true }); + // Both pools exhausted: the topic is fully solved; nothing to quiz. + } + if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` }; } - if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` }; // Recap: everything attempted this week, plus streak — data, not tasks. const monday = weekMonday(date); @@ -154,7 +172,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro const milestone = await db .prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL") - .bind(topics[0]) + .bind(picks[0]!.p.topic_issue) .first<{ milestone: number }>(); const created = (await gh.rest(`/repos/${gh.repo}/issues`, { diff --git a/apps/api/src/srs.test.ts b/apps/api/src/srs.test.ts index 4f1b18d..c0406fd 100644 --- a/apps/api/src/srs.test.ts +++ b/apps/api/src/srs.test.ts @@ -8,6 +8,8 @@ import { describe, expect, test } from "bun:test"; import { INTERVAL, + RESERVED, + SCHEDULE, STAGES, WINDOWS, addDays, @@ -21,6 +23,7 @@ import { sample, TEMPERATURES, temperatureOf, + topicDate, weekdayOf, workingDay, } from "./srs.ts"; @@ -125,6 +128,38 @@ describe("the rest day", () => { }); }); +describe("the plan", () => { + test("never books the rest day, and gates only on Saturday", () => { + for (const [date, day] of Object.entries(SCHEDULE)) { + expect(isRestDay(date)).toBe(false); + if (day.gate) expect(weekdayOf(date)).toBe(6); + if (day.topics) expect(weekdayOf(date)).toBeLessThan(6); + } + }); + + test("a topic is taught once; a doubled day answers for both", () => { + const seen = new Set(); + for (const day of Object.values(SCHEDULE)) { + for (const topic of day.topics ?? []) { + expect(seen.has(topic)).toBe(false); + seen.add(topic); + } + } + expect(topicDate(17)).toBe("2026-09-21"); + expect(topicDate(18)).toBe("2026-09-21"); + expect(topicDate(999)).toBeUndefined(); + }); + + test("every gate and set problem is reserved from the samplers", () => { + for (const day of Object.values(SCHEDULE)) { + for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) { + expect(RESERVED.has(lc)).toBe(true); + } + } + expect(RESERVED.size).toBeGreaterThan(0); + }); +}); + describe("the ladder", () => { test("two rungs, named after the windows", () => { expect(STAGES).toEqual(["new", "+3", "+7", "retired"]); diff --git a/apps/api/src/srs.ts b/apps/api/src/srs.ts index 1a67718..dda0130 100644 --- a/apps/api/src/srs.ts +++ b/apps/api/src/srs.ts @@ -126,12 +126,30 @@ export function prettyDate(date: string): string { // ── the schedule (bundled; humans edit apps/api/data/schedule.json) ─── -export const SCHEDULE: Record = scheduleJson; +/** + * 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 + * 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: + * the ladder still runs, the digest just has nothing new to say. + */ +export interface ScheduledDay { + /** Topic issues taught that day, in the order the digest presents them. */ + topics?: number[]; + /** Problems assigned by number instead of by topic. */ + problems?: number[]; + /** Saturday: the blind set, fixed by the plan rather than sampled. */ + gate?: number[]; + /** Miss-log sweep: every problem whose last attempt failed, and no new ones. */ + sweep?: boolean; +} + +export const SCHEDULE: Record = scheduleJson; /** Date the schedule teaches a topic on, if it teaches it at all. */ export function topicDate(topic: number): string | undefined { - for (const [date, t] of Object.entries(SCHEDULE)) { - if (t === topic) return date; + for (const [date, day] of Object.entries(SCHEDULE)) { + if (day.topics?.includes(topic)) return date; } return undefined; } @@ -142,6 +160,16 @@ export function topicWeek(topic: number): number | undefined { return date === undefined ? undefined : campaignWeek(date); } +/** + * Every problem the plan names outright — a gate's blind set or a set day's + * list. The plan owns these: the samplers (daily drills, a sampled gate) + * never draw them, so a gate problem stays unseen until its Saturday and a + * set day's problems wait for their day. + */ +export const RESERVED: ReadonlySet = new Set( + Object.values(SCHEDULE).flatMap((day) => [...(day.gate ?? []), ...(day.problems ?? [])]), +); + // ── deterministic sampling ─────────────────────────────────────── /** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */ @@ -248,6 +276,31 @@ export async function getProblem(db: D1Database, lc: number): Promise(); } +/** + * Catalog rows for a list of numbers the plan names, in the plan's order, + * plus the numbers the catalog does not know — a planned problem without an + * issue can never be logged, so callers decide whether that is fatal. + */ +export async function lookupProblems( + db: D1Database, + lcs: number[], +): Promise<{ rows: ProblemRow[]; missing: number[] }> { + if (lcs.length === 0) return { rows: [], missing: [] }; + const { results } = await db + .prepare(`SELECT * FROM problems WHERE lc_number IN (${lcs.map(() => "?").join(",")})`) + .bind(...lcs) + .all(); + const byNumber = new Map(results.map((p) => [p.lc_number, p])); + const rows: ProblemRow[] = []; + const missing: number[] = []; + for (const lc of lcs) { + const row = byNumber.get(lc); + if (row) rows.push(row); + else missing.push(lc); + } + return { rows, missing }; +} + /** * What "due" means, in one place: not retired, scheduled on/before ?1, and * past its deferral if it has one. Every caller that asks "what is open on @@ -358,8 +411,9 @@ export async function levelReviews(db: D1Database, date: string): Promise(); const pool = results.filter((p) => { + if (RESERVED.has(p.lc_number)) return false; const w = topicWeek(p.topic_issue); return w !== undefined && w < week; });