/** * SRS domain: ET dates, the interval ladder, deterministic sampling, and the * one write path for attempts — ported from scripts/srs.ts, re-homed on D1. * * D1 is the single source of truth. The stage names the review a problem must * pass NEXT (`+2` = due 2 days after last clean solve). Passing advances * new → +2 → +5 → +10 → retired; any failure resets to +2. A problem's * FIRST-ever log enters the ladder at +2 regardless of result: a pass earns * a +2 review, a fail must be re-solved just as soon. * * All dates are America/New_York calendar strings (YYYY-MM-DD); arithmetic is * anchored at noon UTC so DST edges cannot shift a date. Never raw Date math. */ import scheduleJson from "../data/schedule.json"; // ── dates (America/New_York) ───────────────────────────────────── export const CAMPAIGN_START = "2026-08-17"; export const CAMPAIGN_DAYS = 56; const ET_DATE = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York", dateStyle: "short", }); const ET_HOUR = new Intl.DateTimeFormat("en-US", { timeZone: "America/New_York", hour: "numeric", hourCycle: "h23", }); /** ET calendar date of an instant. */ export function etDate(now: Date): string { return ET_DATE.format(now); } /** ET hour 0–23 of an instant — the DST-proof cron guard. */ export function etHour(now: Date): number { return Number(ET_HOUR.format(now)); } /** Noon-UTC anchor: date-only arithmetic immune to DST edges. */ function atNoon(date: string): Date { return new Date(`${date}T12:00:00Z`); } export function addDays(date: string, days: number): string { return new Date(atNoon(date).getTime() + days * 86_400_000).toISOString().slice(0, 10); } export function daysBetween(from: string, to: string): number { return Math.round((atNoon(to).getTime() - atNoon(from).getTime()) / 86_400_000); } /** 0 = Sunday … 6 = Saturday. */ export function weekdayOf(date: string): number { return atNoon(date).getUTCDay(); } export function campaignDay(date: string): number { return daysBetween(CAMPAIGN_START, date) + 1; } /** 1-based campaign week (Mon–Sun), aligned to CAMPAIGN_START. */ export function campaignWeek(date: string): number { return Math.floor(daysBetween(CAMPAIGN_START, date) / 7) + 1; } /** Monday of the date's campaign week. */ export function weekMonday(date: string): string { return addDays(CAMPAIGN_START, (campaignWeek(date) - 1) * 7); } export function isoWeek(date: string): number { const d = atNoon(date); d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); const jan1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return Math.ceil(((d.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7); } export function prettyDate(date: string): string { return new Intl.DateTimeFormat("en-US", { timeZone: "UTC", weekday: "long", month: "long", day: "numeric", }).format(atNoon(date)); } // ── the schedule (bundled; humans edit apps/api/data/schedule.json) ─── export const SCHEDULE: Record = scheduleJson; /** Week in which a topic was (or will be) taught. */ export function topicWeek(topic: number): number | undefined { for (const [date, t] of Object.entries(SCHEDULE)) { if (t === topic) return campaignWeek(date); } return undefined; } // ── deterministic sampling ─────────────────────────────────────── /** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */ export function rng(seed: string): () => number { let h = 0x811c9dc5; for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 0x01000193); } return () => { h = Math.imul(h ^ (h >>> 15), h | 1); h ^= h + Math.imul(h ^ (h >>> 7), h | 61); return ((h ^ (h >>> 14)) >>> 0) / 4294967296; }; } /** Up to n elements, Fisher–Yates order driven by the seeded PRNG. */ export function sample(pool: T[], n: number, random: () => number): T[] { const copy = [...pool]; for (let i = copy.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [copy[i], copy[j]] = [copy[j]!, copy[i]!]; } return copy.slice(0, n); } // ── rows ───────────────────────────────────────────────────────── export type Stage = "new" | "+2" | "+5" | "+10" | "retired"; export type Result = "pass" | "fail"; export type Kind = "first" | "review" | "drill" | "gate"; export interface ProblemRow { lc_number: number; issue: number; topic_issue: number; title: string; difficulty: string; set_label: string; stage: Stage; next_review: string | null; defer_until: string | null; } export const INTERVAL: Record = { "+2": 2, "+5": 5, "+10": 10 }; const NEXT_STAGE: Record = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" }; // ── queries ────────────────────────────────────────────────────── export async function getProblem(db: D1Database, lc: number): Promise { return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first(); } /** Reviews due on/before `date`, oldest first — the overflow carry order. */ export async function dueReviews(db: D1Database, date: string): Promise { const { results } = await db .prepare( `SELECT * FROM problems WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1 AND (defer_until IS NULL OR defer_until <= ?1) ORDER BY next_review, lc_number`, ) .bind(date) .all(); return results; } /** * Blind drills: unsolved optional problems from topics ALREADY LEARNED — * 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. */ export async function pickDrills( db: D1Database, date: string, budget: number, ): Promise { if (budget <= 0) return []; const week = campaignWeek(date); const { results } = await db .prepare( `SELECT p.*, t.boost AS boost FROM problems p JOIN topics t ON t.issue = p.topic_issue WHERE p.set_label = 'optional' AND p.stage = 'new' AND p.lc_number NOT IN (SELECT lc_number FROM drill_pool_used) AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = p.lc_number) AND EXISTS (SELECT 1 FROM problems c WHERE c.topic_issue = p.topic_issue AND c.set_label = 'core' AND c.stage != 'new') ORDER BY p.lc_number`, ) .all(); const pool = results.filter((p) => { const w = topicWeek(p.topic_issue); return w !== undefined && w < week; }); const boosted = pool.filter((p) => p.boost === 1); const regular = pool.filter((p) => p.boost !== 1); const boostPicks = sample(boosted, Math.min(2, budget), rng(`boost-${date}`)); const regularPicks = sample( regular, Math.min(2, Math.max(0, budget - boostPicks.length)), rng(`drill-${date}`), ); return [...boostPicks, ...regularPicks].slice(0, budget); } /** Consecutive days with ≥1 attempt, ending today or yesterday. */ export async function streak(db: D1Database, today: string): Promise { const { results } = await db .prepare("SELECT DISTINCT date FROM attempts ORDER BY date DESC LIMIT 90") .all<{ date: string }>(); const days = new Set(results.map((r) => r.date)); let cursor = days.has(today) ? today : addDays(today, -1); let n = 0; while (days.has(cursor)) { n++; cursor = addDays(cursor, -1); } return n; } // ── the one write path ─────────────────────────────────────────── export interface LogOutcome { lc: number; title: string; kind: Kind; result: Result; stage: Stage; next_review: string | null; /** First-ever attempt — caller closes the sub-issue on pass. */ first: boolean; issue: number; /** Email one-tap replay: nothing changed. */ duplicate: boolean; error?: string; } /** * Record an attempt and move the ladder. Ladder semantics live here and only * here — email one-taps, webhook /done lines, gate scoring, and solutions * landing in work/ all converge. * * Email idempotency comes from the partial unique index on * (lc_number, date, kind) WHERE source='email': a replayed link inserts * nothing and must not touch the ladder. Webhook corrections (pass then fail * on the same day) remain legal — every webhook attempt appends. Commit * idempotency needs no index: a commit records a FIRST solve only, so the * stage check below turns every re-push into a no-op. */ export async function logAttempt( db: D1Database, opts: { lc: number; date: string; result: Result; source: "email" | "webhook" | "commit"; gate?: boolean; }, ): Promise { const p = await getProblem(db, opts.lc); const nothing: LogOutcome = { lc: opts.lc, title: "", kind: "review", result: opts.result, stage: "new", next_review: null, first: false, issue: 0, duplicate: false, }; if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` }; // A commit only ever reports a first solve: pushing the file again — or the // reconciler re-sending the whole solved set — must never move the ladder, // and must never wake a retired problem. Reviews come from the digest tap, // a /done comment, or a gate. if (opts.source === "commit" && p.stage !== "new") { return { ...nothing, title: p.title, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true, }; } if (p.stage === "retired") { return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` }; } const attempted = await db .prepare("SELECT 1 AS x FROM attempts WHERE lc_number = ? LIMIT 1") .bind(opts.lc) .first(); const first = !attempted && p.stage === "new"; const kind: Kind = opts.gate ? "gate" : first ? (p.set_label === "optional" ? "drill" : "first") : "review"; const inserted = await db .prepare( `INSERT INTO attempts (lc_number, date, kind, result, source) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`, ) .bind(opts.lc, opts.date, kind, opts.result, opts.source) .run(); if (opts.source === "email" && inserted.meta.changes === 0) { return { ...nothing, title: p.title, kind, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true }; } // Ladder move. First-ever logs enter at +2 for pass AND fail. let stage: Stage; if (first) { stage = "+2"; } else if (opts.result === "pass") { stage = NEXT_STAGE[p.stage] ?? "retired"; } else { stage = "+2"; } const next = stage === "retired" ? null : addDays(opts.date, INTERVAL[stage]!); const writes = [ db.prepare( "UPDATE problems SET stage = ?, next_review = ?, defer_until = NULL WHERE lc_number = ?", ).bind(stage, next, opts.lc), ]; if (first && kind === "drill") { writes.push( db.prepare("INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (?)").bind(opts.lc), ); if (opts.result === "fail") { writes.push( db.prepare("UPDATE topics SET misses = misses + 1 WHERE issue = ?").bind(p.topic_issue), ); } } await db.batch(writes); return { lc: opts.lc, title: p.title, kind, result: opts.result, stage, next_review: next, first, issue: p.issue, duplicate: false, }; }