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
+317 -86

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
`/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
+26 -24
View File
@@ -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] }
}
+70 -24
View File
@@ -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<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. */
export async function collectDigest(env: Env, date: string): Promise<DigestData> {
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<DigestData>
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<ProblemRow>();
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).
+85 -17
View File
@@ -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 ? (
<Section style={{ marginTop: "28px" }}>
<Text style={label}>Something new today</Text>
{data.topics.map((topic, i) => (
<Section key={topic.name} style={{ marginTop: i === 0 ? "28px" : "20px" }}>
{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" }}>
{data.topic.name}
{topic.name}
</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>
))}
{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}
<Block
@@ -409,16 +458,27 @@ function plainDigest(data: DigestData): string {
: ` · ${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.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) {
+31 -13
View File
@@ -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<ProblemRow[]> {
const { results } = await db
.prepare(
@@ -44,7 +50,7 @@ async function freshPool(db: D1Database, topic: number, set: string): Promise<Pr
)
.bind(topic, set)
.all<ProblemRow>();
return results;
return results.filter((p) => !RESERVED.has(p.lc_number));
}
export interface CreateReport {
@@ -67,12 +73,24 @@ 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.
// 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 }[] = [];
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 ")}`,
};
}
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))
.map(([, t]) => t);
.flatMap(([, day]) => day.topics ?? []);
if (topics.length === 0) {
const { results } = await db
.prepare(
@@ -89,7 +107,6 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
const random = rng(`gate-${iso}`);
const picks: { p: ProblemRow; fallback: boolean }[] = [];
for (const topic of topics) {
const optional = await freshPool(db, topic, "optional");
if (optional.length > 0) {
@@ -101,6 +118,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
// 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` };
}
// 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`, {
+35
View File
@@ -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<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", () => {
test("two rungs, named after the windows", () => {
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) ───
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. */
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<number> = 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<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
* 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
* 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
* feeds drills just because its calendar week lapsed. Never repeated,
* seeded by date. Boosted topics contribute up to 2 extra.
* feeds drills just because its calendar week lapsed. Problems the plan names
* outright (RESERVED) are never drawn. Never repeated, seeded by date.
* Boosted topics contribute up to 2 extra.
*/
export async function pickDrills(
db: D1Database,
@@ -382,6 +436,7 @@ export async function pickDrills(
)
.all<ProblemRow & { boost: number }>();
const pool = results.filter((p) => {
if (RESERVED.has(p.lc_number)) return false;
const w = topicWeek(p.topic_issue);
return w !== undefined && w < week;
});