/** * SRS domain: ET dates, the work week, the interval ladder, concept * temperature, deterministic sampling, and the one write path for attempts. * * D1 is the single source of truth. The stage names the review a problem must * pass NEXT (`+3` = due 3 working days after the last clean solve). Passing * advances new → +3 → +7 → retired; any failure resets to +3. A problem's * FIRST-ever log enters the ladder at +3 regardless of result: a pass earns a * 3-day review, a fail must be re-solved just as soon. Two rungs, 3 and 7 — * the same numbers as the `work/3` and `work/7` buckets a re-solve is * scaffolded into, and as the review-issue windows in * apps/cli/spaced-repetition.ts. * * Sunday is off, structurally: every scheduled date comes out of * workingDay(), which never returns one. Nothing "falls on" the rest day and * gets swallowed or carried — it is simply never booked there. * * 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(); } // ── the work week ──────────────────────────────────────────────── /** * Sunday, the one day the campaign never schedules: topics run Mon–Fri * (data/schedule.json), the gate is Saturday, and the review windows below are * picked so a solve comes back on a working day. */ const REST_DAY = 0; export function isRestDay(date: string): boolean { return weekdayOf(date) === REST_DAY; } /** * The `offset`-th working day counting from `from` (0 = `from` itself), with * Sundays skipped — the ONE place a scheduled date is minted. * * Work may slide later, never earlier: pulling a review back to Saturday would * shorten the very interval it exists to test, so a Sunday landing becomes * Monday. With the +3/+7 ladder that only ever happens to a Thursday solve's * 3-day review; +7 lands on the solve's own weekday, which is never a Sunday. */ export function workingDay(from: string, offset = 0): string { let cursor = isRestDay(from) ? addDays(from, 1) : from; for (let i = 0; i < offset; i++) { cursor = addDays(cursor, 1); if (isRestDay(cursor)) cursor = addDays(cursor, 1); } return cursor; } 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; /** 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; } return undefined; } /** Week in which a topic was (or will be) taught. */ export function topicWeek(topic: number): number | undefined { const date = topicDate(topic); return date === undefined ? undefined : campaignWeek(date); } // ── 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 ───────────────────────────────────────────────────────── /** * Review windows in days. One vocabulary for the whole campaign: these are the * ladder rungs, the `work/` buckets the picker scaffolds into, and the * windows the `Spaced Repetition — ` issues are built from. * * 3 and 7 keep the rest day free. +7 returns a solve to its own weekday, and * of the five learning weekdays only Thursday's +3 touches a Sunday — which * workingDay() slides to Monday. */ export const WINDOWS = [3, 7] as const; export type Window = (typeof WINDOWS)[number]; export type Stage = "new" | `+${Window}` | "retired"; /** Chart / stats order, low to high. */ export const STAGES: Stage[] = ["new", "+3", "+7", "retired"]; export type Result = "pass" | "fail"; export type Kind = "first" | "review" | "drill" | "gate"; /** * Catalog vocabulary, carried by the `diff:*` / `set:*` issue labels and * stored verbatim in `problems`. Listed low to high / most to least required, * which is the order every chart, table and digest section prints them in. */ export const DIFFICULTIES = ["easy", "medium", "hard"] as const; export type Difficulty = (typeof DIFFICULTIES)[number]; export const SETS = ["core", "optional", "deferred"] as const; export type SetLabel = (typeof SETS)[number]; /** * How warm a concept is: the age of a topic's most recent attempt, bucketed by * the ladder's own windows so the two cannot drift. Touched inside the first * window it is `hot`, inside the second `fresh`, inside one more full second * window `fading`; older than that — or never attempted — it is `cold`. * * A read-side label only: nothing is scheduled off it. It answers the question * the ladder cannot, because the ladder tracks problems and a topic can go * quiet for a fortnight while not one of its problems comes due. */ export const TEMPERATURES = ["hot", "fresh", "fading", "cold"] as const; export type Temperature = (typeof TEMPERATURES)[number]; /** Warmest band first, with the age it tolerates. Past the last one is cold. */ const TEMPERATURE_MAX_AGE: [Temperature, number][] = [ ["hot", WINDOWS[0]], ["fresh", WINDOWS[1]], ["fading", WINDOWS[1] * 2], ]; /** Days since a topic's last attempt → its band; null (never) is cold. */ export function temperatureOf(age: number | null): Temperature { if (age === null) return "cold"; for (const [temperature, max] of TEMPERATURE_MAX_AGE) { if (age <= max) return temperature; } return "cold"; } 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 = { "+3": 3, "+7": 7 }; const NEXT_STAGE: Record = { new: "+3", "+3": "+7", "+7": "retired" }; // ── queries ────────────────────────────────────────────────────── export async function getProblem(db: D1Database, lc: number): Promise { return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first(); } /** * 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 * this day" — the digest, the review issue, the docs queue chart — shares * this clause so they cannot answer differently. */ export const DUE_WHERE = `stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1 AND (defer_until IS NULL OR defer_until <= ?1)`; /** 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 ${DUE_WHERE} ORDER BY next_review, lc_number`) .bind(date) .all(); return results; } /** One problem the ladder wants re-solved today. */ export interface DueRow { lc: number; issue: number; title: string; difficulty: string; /** The rung being settled: also the `work/` bucket the re-solve goes in. */ stage: Stage; bucket: number; /** Scheduled day; earlier than `date` when a day was missed. */ due: string; /** Days late (0 = due today), so a renderer can say so. */ late: number; /** Last logged attempt, and how long ago — the "last seen" line. */ last_seen: string | null; days_since: number | null; } /** * Everything the ladder wants re-solved on `date`, oldest first. * * The read side of "which problems are open today": the same rows the digest * mails, shaped for whoever renders them (the daily Spaced Repetition issue). * Nothing here writes, so it is safe to call repeatedly, and there is no * second schedule to drift — D1's ladder is the schedule. */ export async function dueToday(db: D1Database, date: string): Promise<{ date: string; due: DueRow[] }> { const { results } = await db .prepare( `SELECT lc_number, issue, title, difficulty, stage, next_review, (SELECT MAX(a.date) FROM attempts a WHERE a.lc_number = problems.lc_number) AS last_seen FROM problems WHERE ${DUE_WHERE} ORDER BY next_review, lc_number`, ) .bind(date) .all<{ lc_number: number; issue: number; title: string; difficulty: string; stage: Stage; next_review: string; last_seen: string | null; }>(); return { date, due: results.map((r) => ({ lc: r.lc_number, issue: r.issue, title: r.title, difficulty: r.difficulty, stage: r.stage, // "+3" -> 3: the rung's number IS its work/ bucket. bucket: Number(r.stage.slice(1)), due: r.next_review, late: daysBetween(r.next_review, date), last_seen: r.last_seen, days_since: r.last_seen === null ? null : daysBetween(r.last_seen, date), })), }; } /** Reviews surfaced per day; the digest levels everything past this forward. */ export const REVIEW_CAP = 3; /** * Load-level an overloaded review queue: everything due beyond today's * REVIEW_CAP is pushed to a concrete future WORKING day — at most REVIEW_CAP * per day, oldest first — instead of piling up as "due today". Runs every * digest morning, so a future day that grows past the cap (spill plus newly * maturing reviews) is simply re-levelled when it arrives. Idempotent within * a date: after one pass at most REVIEW_CAP problems remain due today, so a * second pass moves nothing. The spill starts tomorrow, or Monday when * tomorrow is the rest day. */ export async function levelReviews(db: D1Database, date: string): Promise { const overflow = (await dueReviews(db, date)).slice(REVIEW_CAP); if (overflow.length === 0) return 0; await db.batch( overflow.map((p, i) => db .prepare("UPDATE problems SET next_review = ? WHERE lc_number = ?") .bind(workingDay(addDays(date, 1), Math.floor(i / REVIEW_CAP)), p.lc_number), ), ); return overflow.length; } /** * 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 either. A commit names the rung the file * it landed in settles — `work/1` settles `new`, `work/3` settles `+3`, * `work/7` settles `+7` — and the stage check below writes only when the * ladder is actually standing on that rung. So re-pushing a solution, or the * reconciler re-sending its whole implemented set on every push, moves * nothing: the rung it names has already been left behind. */ export async function logAttempt( db: D1Database, opts: { lc: number; date: string; result: Result; source: "email" | "webhook" | "commit"; /** Commit only: the stage this file settles. Default `new` = first solve. */ rung?: Stage; 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 reports a file, not an event: it may only settle the rung that // file's bucket IS. Any other stage means the push is old news (the ladder // already moved past it) or premature — no write either way, and a retired // problem can never be woken. Fresh reviews otherwise come from the digest // tap, a /done comment, or a gate. if (opts.source === "commit" && p.stage !== (opts.rung ?? "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 +3 for pass AND fail. let stage: Stage; if (first) { stage = "+3"; } else if (opts.result === "pass") { stage = NEXT_STAGE[p.stage] ?? "retired"; } else { stage = "+3"; } // workingDay(), not addDays(): a Thursday solve's +3 would land on the rest // day, and it takes Monday instead. const next = stage === "retired" ? null : workingDay(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, }; }