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

+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).