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

+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;
});