feat(api): add structured schedule plan with topics, problems, gate, sweep and reserved problem handling

This commit is contained in:
Prad Nukala committed 2026-09-21 12:14:25 -04:00
1 parent 00f44a1c47
commit 86107d5ef7
7 files changed
+344 -113

No files matched your search

+10 -3
View File
@@ -14,8 +14,12 @@ Live at `https://srs-api.prdlk.workers.dev`.
labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via
`/admin/reconcile`); it recomputes from scratch, never invents rows, and `/admin/reconcile`); it recomputes from scratch, never invents rows, and
never overwrites SRS-owned columns (`stage`, `next_review`). never overwrites SRS-owned columns (`stage`, `next_review`).
- **The repo owns the schedule** — `data/schedule.json`, human-edited, - **The repo owns the plan** — `data/schedule.json`, human-edited,
bundled at deploy. Git history is its audit log. 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 - 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 —
@@ -47,7 +51,10 @@ raw `Date` math.
Blind drills draw only from topics **already learned**: scheduled in an Blind drills draw only from topics **already learned**: scheduled in an
earlier week (the current week's optional pool is reserved for Saturday's 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 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 ## Routes
+26 -24
View File
@@ -1,26 +1,28 @@
{ {
"2026-08-17": 3, "2026-08-17": { "topics": [3] },
"2026-08-18": 4, "2026-08-18": { "topics": [4] },
"2026-08-24": 5, "2026-08-24": { "topics": [5] },
"2026-08-25": 6, "2026-08-25": { "topics": [6] },
"2026-08-26": 7, "2026-08-26": { "topics": [7] },
"2026-08-27": 8, "2026-08-27": { "topics": [8] },
"2026-08-28": 9, "2026-08-28": { "topics": [9] },
"2026-08-31": 10, "2026-08-31": { "topics": [10] },
"2026-09-01": 11, "2026-09-01": { "topics": [11] },
"2026-09-02": 12, "2026-09-02": { "topics": [12] },
"2026-09-03": 13, "2026-09-03": { "topics": [13] },
"2026-09-04": 14, "2026-09-04": { "topics": [14] },
"2026-09-07": 15, "2026-09-07": { "topics": [15] },
"2026-09-08": 16, "2026-09-08": { "topics": [16] },
"2026-09-09": 17, "2026-09-21": { "topics": [17, 18] },
"2026-09-10": 18, "2026-09-22": { "topics": [19, 20] },
"2026-09-11": 19, "2026-09-23": { "topics": [21] },
"2026-09-14": 20, "2026-09-24": { "topics": [22] },
"2026-09-15": 21, "2026-09-25": { "topics": [23] },
"2026-09-16": 22, "2026-09-26": { "gate": [648, 1091, 802, 547, 90] },
"2026-09-17": 23, "2026-09-28": { "topics": [24] },
"2026-09-18": 24, "2026-09-29": { "topics": [25] },
"2026-09-21": 25, "2026-09-30": { "topics": [26] },
"2026-09-22": 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] }
} }
+70 -24
View File
@@ -5,14 +5,16 @@
* the HTML and the plain-text alternative from that single tree (React Email * the HTML and the plain-text alternative from that single tree (React Email
* `render`), so the two can never drift. * `render`), so the two can never drift.
* *
* Retrieval rules: review and drill rows carry number + difficulty only — * Retrieval rules: review, drill and sweep rows carry number + difficulty
* 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 learning day's core list is * either, so the rule holds by construction. The plan's own sections — a
* the only labeled section. At most REVIEW_CAP (3) reviews per day; drills * learning day's topics (core, then the optional "if time" list) and a set
* fill toward DAILY_CAP (6) only when the due queue is light. Overflow is * day's problems — are the only labeled, linked ones. At most REVIEW_CAP (3)
* levelled, not stacked: sendDigest gives every review past the cap a * reviews per day; drills fill toward DAILY_CAP (6) only when the due queue
* concrete future date, ≤ REVIEW_CAP per day, oldest first. Sunday is a * is light, and never on a sweep day, which assigns every open miss and
* two-line rest note. * 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 * 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 * no-op unless forced; the body itself is deterministic (drill picks are
@@ -22,20 +24,23 @@
import { import {
CAMPAIGN_DAYS, CAMPAIGN_DAYS,
type ProblemRow, type ProblemRow,
RESERVED,
REVIEW_CAP, REVIEW_CAP,
SCHEDULE, SCHEDULE,
type ScheduledDay,
addDays, addDays,
campaignDay, campaignDay,
campaignWeek, campaignWeek,
dueReviews, dueReviews,
isoWeek, isoWeek,
levelReviews, levelReviews,
lookupProblems,
pickDrills, pickDrills,
prettyDate, prettyDate,
streak, streak,
weekdayOf, weekdayOf,
} from "./srs.ts"; } 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"; import { signLink } from "./links.ts";
/** Total daily ceiling: drills only fill what the whole due queue leaves. */ /** 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<ProblemRow[]> {
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<ProblemRow>();
return results;
}
/** Everything the email needs, read straight out of D1 and the schedule. */ /** Everything the email needs, read straight out of D1 and the schedule. */
export async function collectDigest(env: Env, date: string): Promise<DigestData> { export async function collectDigest(env: Env, date: string): Promise<DigestData> {
const db = env.DB; const db = env.DB;
const rest = weekdayOf(date) === 0; const rest = weekdayOf(date) === 0;
const plan: ScheduledDay = rest ? {} : (SCHEDULE[date] ?? {});
// Reviews surface at most REVIEW_CAP; drills fill toward DAILY_CAP only // 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 // 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 due = rest ? [] : await dueReviews(db, date);
const capped = due.slice(0, REVIEW_CAP); 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 = { const data: DigestData = {
day: prettyDate(date), day: prettyDate(date),
progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`, progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`,
streak: await streak(db, date), streak: await streak(db, date),
rest, rest,
topics: [],
reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))), reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))),
carried: due.length - capped.length, carried: due.length - capped.length,
drills: await Promise.all(drills.map((p) => tapRow(env, p, date, false))), 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<DigestData>
progressUrl: `${env.DOCS_URL}/progress`, progressUrl: `${env.DOCS_URL}/progress`,
}; };
// New topic — the only section allowed to name problems and link them. // New topics — the plan's sections, the only ones allowed to name problems
const topicIssue = rest ? undefined : SCHEDULE[date]; // and link them. Core first; the optional set is "if time", minus anything
if (topicIssue !== undefined) { // the plan has reserved for a gate or a set day.
for (const topicIssue of plan.topics ?? []) {
const topic = await db const topic = await db
.prepare("SELECT name FROM topics WHERE issue = ?") .prepare("SELECT name FROM topics WHERE issue = ?")
.bind(topicIssue) .bind(topicIssue)
.first<{ name: string }>(); .first<{ name: string }>();
const { results: core } = await db const { results } = await db
.prepare( .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) .bind(topicIssue)
.all<ProblemRow>(); .all<ProblemRow>();
data.topic = { data.topics.push({
name: topic?.name ?? `#${topicIssue}`, name: topic?.name ?? `#${topicIssue}`,
core: core.map((p) => ({ core: results.filter((p) => p.set_label === "core").map((p) => topicRow(env, p)),
lc: p.lc_number, ifTime: results
difficulty: p.difficulty, .filter((p) => p.set_label === "optional" && !RESERVED.has(p.lc_number))
url: `https://github.com/${env.REPO}/issues/${p.issue}`, .map((p) => topicRow(env, p)),
solved: p.stage !== "new", });
})), }
};
// 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). // Saturday: the review issue already exists (created at midnight ET).
+85 -17
View File
@@ -83,7 +83,7 @@ export interface DigestRow {
failUrl: string; 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 { export interface TopicRow {
lc: number; lc: number;
difficulty: string; difficulty: string;
@@ -91,6 +91,13 @@ export interface TopicRow {
solved: boolean; 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 { export interface DigestData {
/** "Tuesday, August 25" */ /** "Tuesday, August 25" */
day: string; day: string;
@@ -98,11 +105,16 @@ export interface DigestData {
progress: string; progress: string;
streak: number; streak: number;
rest: boolean; 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: 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;
drills: DigestRow[]; drills: DigestRow[];
/** A sweep day's miss log: present (even empty) only on a sweep day. */
sweep?: DigestRow[];
gate?: { week: number; url: string }; gate?: { week: number; url: string };
yesterday: { total: number; failed: number[] }; yesterday: { total: number; failed: number[] };
gateRate?: number; gateRate?: number;
@@ -278,10 +290,15 @@ function Block({
// ── email ──────────────────────────────────────────────────────── // ── email ────────────────────────────────────────────────────────
function DigestEmail({ data }: { data: DigestData }) { function DigestEmail({ data }: { data: DigestData }) {
const load = data.reviews.length + data.drills.length; const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
const preview = data.rest const lead = data.topics[0]
? "Rest day — nothing to do but rest." ? `, starting with ${data.topics[0].name}`
: `${load} to work through today${data.topic ? `, starting with ${data.topic.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 passed = data.yesterday.total - data.yesterday.failed.length;
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", "); const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
@@ -310,15 +327,47 @@ function DigestEmail({ data }: { data: DigestData }) {
{data.rest ? null : ( {data.rest ? null : (
<> <>
{data.topic ? ( {data.topics.map((topic, i) => (
<Section style={{ marginTop: "28px" }}> <Section key={topic.name} style={{ marginTop: i === 0 ? "28px" : "20px" }}>
<Text style={label}>Something new today</Text> {i === 0 ? (
<Text style={label}>
{data.topics.length === 1 ? "Something new today" : "Two new things today"}
</Text>
) : null}
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}> <Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
{data.topic.name} {topic.name}
</Text> </Text>
<Text style={hint}>Work through these from the top — a tick means it is already solved.</Text> <Text style={hint}>Work through these from the top — a tick means it is already solved.</Text>
<TopicTable rows={data.topic.core} /> <TopicTable rows={topic.core} />
{topic.ifTime.length > 0 ? (
<>
<Text style={{ ...hint, margin: "12px 0 8px" }}>
If time — only once the core is done inside the clock.
</Text>
<TopicTable rows={topic.ifTime} />
</>
) : null}
</Section> </Section>
))}
{data.assigned ? (
<Section style={{ marginTop: "28px" }}>
<Text style={label}>Today's set</Text>
<Text style={hint}>
Picked by number, not by topic — all of them, in order, inside the clock.
</Text>
<TopicTable rows={data.assigned} />
</Section>
) : null}
{data.sweep ? (
<Block
title="Sweep — redo every red"
note="Every problem still standing on a miss. Nothing new today; clear these first."
rows={data.sweep}
staged
empty="The miss log is clear — nothing is standing on a fail."
/>
) : null} ) : null}
<Block <Block
@@ -409,16 +458,27 @@ function plainDigest(data: DigestData): string {
: ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`), : ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`),
]; ];
const linked = (p: TopicRow) =>
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`;
if (!data.rest) { if (!data.rest) {
if (data.topic) { for (const topic of data.topics) {
out.push( 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.", "Work through these from the top; the ones marked done you have already solved.",
...data.topic.core.map( ...topic.core.map(linked),
(p) => );
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`, 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.", "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) { for (const [title, note, rows, empty] of lists) {
out.push("", title); out.push("", title);
if (rows.length === 0) { if (rows.length === 0) {
+58 -40
View File
@@ -2,21 +2,27 @@
* Saturday review issue: created at midnight ET so the 8 AM digest can link * Saturday review issue: created at midnight ET so the 8 AM digest can link
* to it; scored when the issue closes (webhook). * to it; scored when the issue closes (webhook).
* *
* The gate half is a topic-blind quiz — one unsolved optional problem per * The gate half is a topic-blind quiz. When the plan fixes the set
* topic scheduled this week (which is why daily drills only draw from * (`gate` in data/schedule.json) that list IS the gate, verbatim — every
* earlier weeks). The recap half is generated data, deliberately NOT a task * number must be a catalog problem, or the issue is not created, because a
* list: the week's solved problems are already scheduled by the +3/+7 ladder, * `/done` on an uncatalogued number can never be logged. Otherwise the set
* and re-assigning them on Saturday would be massed practice. * 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 * 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 { import {
type ProblemRow, type ProblemRow,
RESERVED,
type Result, type Result,
SCHEDULE, SCHEDULE,
campaignWeek, campaignWeek,
isoWeek, isoWeek,
lookupProblems,
rng, rng,
sample, sample,
streak, streak,
@@ -32,7 +38,7 @@ function capitalized(difficulty: string): string {
return difficulty[0]!.toUpperCase() + difficulty.slice(1); 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<ProblemRow[]> { async function freshPool(db: D1Database, topic: number, set: string): Promise<ProblemRow[]> {
const { results } = await db const { results } = await db
.prepare( .prepare(
@@ -44,7 +50,7 @@ async function freshPool(db: D1Database, topic: number, set: string): Promise<Pr
) )
.bind(topic, set) .bind(topic, set)
.all<ProblemRow>(); .all<ProblemRow>();
return results; return results.filter((p) => !RESERVED.has(p.lc_number));
} }
export interface CreateReport { 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})` }; return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` };
} }
// Topics scheduled this week; review-only weeks (Sep 23 onward) sample // The plan's fixed set wins; otherwise sample this week's topics, and in a
// across every topic that still has unsolved optional problems. // week without topics, across every topic that still has an unsolved pool.
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}`);
const picks: { p: ProblemRow; fallback: boolean }[] = []; const picks: { p: ProblemRow; fallback: boolean }[] = [];
for (const topic of topics) { const planned = SCHEDULE[date]?.gate;
const optional = await freshPool(db, topic, "optional"); if (planned) {
if (optional.length > 0) { const { rows, missing } = await lookupProblems(db, planned);
picks.push({ p: sample(optional, 1, random)[0]!, fallback: false }); if (missing.length) {
continue; return {
created: false,
reason: `planned gate names problems outside the catalog: LC ${missing.join(", LC ")}`,
};
} }
const core = await freshPool(db, topic, "core"); picks.push(...rows.map((p) => ({ p, fallback: false })));
if (core.length > 0) picks.push({ p: sample(core, 1, random)[0]!, fallback: true }); } else {
// Both pools exhausted: the topic is fully solved; nothing to quiz. 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. // Recap: everything attempted this week, plus streak — data, not tasks.
const monday = weekMonday(date); const monday = weekMonday(date);
@@ -154,7 +172,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
const milestone = await db const milestone = await db
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL") .prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
.bind(topics[0]) .bind(picks[0]!.p.topic_issue)
.first<{ milestone: number }>(); .first<{ milestone: number }>();
const created = (await gh.rest(`/repos/${gh.repo}/issues`, { const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
+35
View File
@@ -8,6 +8,8 @@ import { describe, expect, test } from "bun:test";
import { import {
INTERVAL, INTERVAL,
RESERVED,
SCHEDULE,
STAGES, STAGES,
WINDOWS, WINDOWS,
addDays, addDays,
@@ -21,6 +23,7 @@ import {
sample, sample,
TEMPERATURES, TEMPERATURES,
temperatureOf, temperatureOf,
topicDate,
weekdayOf, weekdayOf,
workingDay, workingDay,
} from "./srs.ts"; } 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<number>();
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", () => { describe("the ladder", () => {
test("two rungs, named after the windows", () => { test("two rungs, named after the windows", () => {
expect(STAGES).toEqual(["new", "+3", "+7", "retired"]); expect(STAGES).toEqual(["new", "+3", "+7", "retired"]);
+60 -5
View File
@@ -126,12 +126,30 @@ export function prettyDate(date: string): string {
// ── the schedule (bundled; humans edit apps/api/data/schedule.json) ─── // ── the schedule (bundled; humans edit apps/api/data/schedule.json) ───
export const SCHEDULE: Record<string, number> = 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<string, ScheduledDay> = scheduleJson;
/** 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, t] of Object.entries(SCHEDULE)) { for (const [date, day] of Object.entries(SCHEDULE)) {
if (t === topic) return date; if (day.topics?.includes(topic)) return date;
} }
return undefined; return undefined;
} }
@@ -142,6 +160,16 @@ export function topicWeek(topic: number): number | undefined {
return date === undefined ? undefined : campaignWeek(date); 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<number> = new Set(
Object.values(SCHEDULE).flatMap((day) => [...(day.gate ?? []), ...(day.problems ?? [])]),
);
// ── deterministic sampling ─────────────────────────────────────── // ── deterministic sampling ───────────────────────────────────────
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */ /** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
@@ -248,6 +276,31 @@ export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>(); return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
} }
/**
* 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<ProblemRow>();
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 * 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 * 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<number
* scheduled in an earlier week (the current week's optional pool is reserved * scheduled in an earlier week (the current week's optional pool is reserved
* for Saturday's gate) AND showing learning evidence: at least one of the * for Saturday's gate) AND showing learning evidence: at least one of the
* topic's core problems has entered the ladder. A skipped learning day never * topic's core problems has entered the ladder. A skipped learning day never
* feeds drills just because its calendar week lapsed. Never repeated, * feeds drills just because its calendar week lapsed. Problems the plan names
* seeded by date. Boosted topics contribute up to 2 extra. * outright (RESERVED) are never drawn. Never repeated, seeded by date.
* Boosted topics contribute up to 2 extra.
*/ */
export async function pickDrills( export async function pickDrills(
db: D1Database, db: D1Database,
@@ -382,6 +436,7 @@ export async function pickDrills(
) )
.all<ProblemRow & { boost: number }>(); .all<ProblemRow & { boost: number }>();
const pool = results.filter((p) => { const pool = results.filter((p) => {
if (RESERVED.has(p.lc_number)) return false;
const w = topicWeek(p.topic_issue); const w = topicWeek(p.topic_issue);
return w !== undefined && w < week; return w !== undefined && w < week;
}); });