diff --git a/apps/api/README.md b/apps/api/README.md index 61182ed..2718e28 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -17,9 +17,12 @@ Live at `https://srs-api.prdlk.workers.dev`. - **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. + fixed numbers), `review` (a `Review — ` issue with its problems + in solve order; Mon/Tue/Thu/Fri only), `gate` (Saturday's blind set, + 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 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 — diff --git a/apps/api/data/schedule.json b/apps/api/data/schedule.json index 3489fec..4970398 100644 --- a/apps/api/data/schedule.json +++ b/apps/api/data/schedule.json @@ -22,7 +22,25 @@ "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] } + "2026-10-01": { "review": { "issue": 225, "title": "Review 1 — Linear Structures", "problems": [205, 974, 209, 34, 42] } }, + "2026-10-02": { "review": { "issue": 226, "title": "Review 2 — Linear Structures", "problems": [290, 525, 189, 1004, 84] }, "sweep": true }, + "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] } } } diff --git a/apps/api/src/catalog.ts b/apps/api/src/catalog.ts index d27dbb2..e85ff89 100644 --- a/apps/api/src/catalog.ts +++ b/apps/api/src/catalog.ts @@ -16,9 +16,9 @@ * problem's row: upserts only, so nothing breaks — its labels just stop * syncing. */ -import { DIFFICULTIES, SETS, workingDay } from "./srs.ts"; +import { DIFFICULTIES, SCHEDULE, SETS, workingDay } from "./srs.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+) · (.+)$/; @@ -28,6 +28,37 @@ const DEFER_FROM = "2026-09-28"; export interface ReconcileReport { topics: 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(); + 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 — " 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. */ @@ -79,7 +110,10 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise` 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 * is light, and never on a sweep day, which assigns every open miss and * nothing new. Overflow is levelled, not stacked: sendDigest gives every @@ -150,6 +151,18 @@ export async function collectDigest(env: Env, date: string): Promise data.assigned = rows.map((p) => topicRow(env, p)); } + // A review day: the `Review — ` 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 // failure, minus the ones today's review list already carries. if (plan.sweep) { diff --git a/apps/api/src/email.tsx b/apps/api/src/email.tsx index 81e2b35..c420691 100644 --- a/apps/api/src/email.tsx +++ b/apps/api/src/email.tsx @@ -71,6 +71,13 @@ const LAST_SEEN: Record = { 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 ─────────────────────────────────────────────────── /** A review or drill line: number + difficulty + one-tap links, nothing else. */ @@ -101,7 +108,7 @@ export interface TopicSection { export interface DigestData { /** "Tuesday, August 25" */ 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; streak: number; rest: boolean; @@ -109,6 +116,8 @@ export interface DigestData { topics: TopicSection[]; /** A set day's problems, assigned by number rather than by topic. */ assigned?: TopicRow[]; + /** A review day: the `Review — ` issue and its problems in solve order. */ + review?: { title: string; url: string; rows: TopicRow[] }; reviews: DigestRow[]; /** Reviews past the daily cap; levelled onto the coming days. */ carried: number; @@ -293,11 +302,13 @@ function DigestEmail({ data }: { data: DigestData }) { 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" - : ""; + : data.review + ? `, starting with ${data.review.title}` + : 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; @@ -350,6 +361,19 @@ function DigestEmail({ data }: { data: DigestData }) { ))} + {data.review ? ( +
+ Today's review + + + {data.review.title} + + + {REVIEW_RULES} + +
+ ) : null} + {data.assigned ? (
Today's set @@ -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. */}
Every day you showed up Attempt heatmap across the eight-week campaign @@ -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)); } } + if (data.review) { + out.push( + "", + `TODAY'S REVIEW — ${data.review.title}`, + ` ${data.review.url}`, + REVIEW_RULES, + ...data.review.rows.map(linked), + ); + } if (data.assigned) { out.push( "", diff --git a/apps/api/src/gate.ts b/apps/api/src/gate.ts index c56030f..3eaa80a 100644 --- a/apps/api/src/gate.ts +++ b/apps/api/src/gate.ts @@ -5,7 +5,8 @@ * 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 + * `/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 * (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 @@ -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. const picks: { p: ProblemRow; fallback: boolean }[] = []; const planned = SCHEDULE[date]?.gate; + if (planned?.length === 0) return { created: false, reason: `the plan skips the week ${week} checkpoint` }; if (planned) { const { rows, missing } = await lookupProblems(db, planned); if (missing.length) { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 49ec6e9..79972fa 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -18,6 +18,7 @@ import { type GitHub, github } from "./github.ts"; import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts"; import { type Mirror, projectMirror } from "./mirror.ts"; import { + CAMPAIGN_DAYS, CAMPAIGN_START, type LogOutcome, type Stage, @@ -333,10 +334,10 @@ export default { return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS }); } 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") { - 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") { return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS }); diff --git a/apps/api/src/srs.test.ts b/apps/api/src/srs.test.ts index c0406fd..7d0c51b 100644 --- a/apps/api/src/srs.test.ts +++ b/apps/api/src/srs.test.ts @@ -134,6 +134,8 @@ describe("the plan", () => { expect(isRestDay(date)).toBe(false); if (day.gate) expect(weekdayOf(date)).toBe(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(); }); + test("a review is booked once, and no problem sits in two reviews", () => { + const issues = new Set(); + const problems = new Set(); + 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", () => { for (const day of Object.values(SCHEDULE)) { for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) { diff --git a/apps/api/src/srs.ts b/apps/api/src/srs.ts index aa222cd..32acac3 100644 --- a/apps/api/src/srs.ts +++ b/apps/api/src/srs.ts @@ -22,8 +22,8 @@ import scheduleJson from "../data/schedule.json"; // ── 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_DAYS = 56; const ET_DATE = new Intl.DateTimeFormat("en-CA", { 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 * 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. + * a transfer-phase review 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. */ + /** + * A `Review — ` 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[]; /** Miss-log sweep: every problem whose last attempt failed, and no new ones. */ sweep?: boolean; @@ -146,6 +157,15 @@ export interface ScheduledDay { export const SCHEDULE: Record = 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. */ export function topicDate(topic: number): string | undefined { 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 - * 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. + * Every problem the plan names outright — a gate's blind set, a set day's + * list, or a review day's. 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 or review day's problems wait for their day. */ export const RESERVED: ReadonlySet = 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 ───────────────────────────────────────