From 5ea123e46ded4f30d138ce9bbd2b7594461bc950 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Mon, 31 Aug 2026 10:48:24 -0400 Subject: [PATCH] feat(api): add dueToday endpoint, shared DUE_WHERE constant, and commit rung handling --- apps/api/src/index.ts | 49 ++++++++++++++------ apps/api/src/srs.ts | 101 ++++++++++++++++++++++++++++++++++++------ apps/api/src/stats.ts | 7 +-- 3 files changed, 124 insertions(+), 33 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5ab0dcb..21becdf 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -19,7 +19,9 @@ import { type Mirror, projectMirror } from "./mirror.ts"; import { CAMPAIGN_START, type LogOutcome, + type Stage, daysBetween, + dueToday, etDate, etHour, getProblem, @@ -202,43 +204,56 @@ async function adminAuthorized(request: Request, env: Env): Promise { /** * Solutions landing in `work/` are the third way a problem gets solved, after * the digest tap and a /done comment. `close-solved` posts its whole - * implemented set here on every push to main: logAttempt ignores anything - * already on the ladder, so this is a total recompute like the reconcilers - * that call it — a re-run changes nothing. + * implemented set here on every push to main, each entry tagged with the + * BUCKET the file sits in — 1 for the first solve, 3 and 7 for the blind + * re-solves the picker scaffolds. The bucket names the ladder rung the file + * settles, and logAttempt writes only when the problem is standing on that + * rung, so this is a total recompute like the reconcilers that call it: a + * re-run changes nothing, and a pushed re-solve advances +3 → +7 instead of + * being invisible. * * Closing the sub-issue stays with close-solved: it is the side that knows * which files at which commit, so this path mirrors Project fields only. * Charts read D1 live, so a push moves them within the 5-minute cache. */ +const RUNG_OF_BUCKET: Record = { 1: "new", 3: "+3", 7: "+7" }; + async function handleSolved(request: Request, env: Env, date: string): Promise { const body: unknown = await request.json().catch(() => null); - const raw: unknown = body && typeof body === "object" && "lc" in body ? body.lc : null; - if (!Array.isArray(raw)) return new Response('expected {"lc": [, ...]}', { status: 400 }); - const lcs: number[] = []; + const raw: unknown = body && typeof body === "object" && "solved" in body ? body.solved : null; + const shape = 'expected {"solved": [{"lc": , "bucket": 1|3|7}, ...]}'; + if (!Array.isArray(raw)) return new Response(shape, { status: 400 }); + const entries: { lc: number; rung: Stage; bucket: number }[] = []; for (const item of raw) { - const lc: unknown = item; + if (!item || typeof item !== "object") return new Response(shape, { status: 400 }); + const lc: unknown = "lc" in item ? item.lc : null; + const bucket: unknown = "bucket" in item ? item.bucket : null; if (typeof lc !== "number" || !Number.isFinite(lc)) { return new Response(`not an LC number: ${JSON.stringify(lc)}`, { status: 400 }); } - lcs.push(lc); + const rung = typeof bucket === "number" ? RUNG_OF_BUCKET[bucket] : undefined; + if (rung === undefined) return new Response(`not a work/ bucket: ${JSON.stringify(bucket)}`, { status: 400 }); + entries.push({ lc, rung, bucket }); } const dry = new URL(request.url).searchParams.get("dry") === "1"; const logged: string[] = []; const skipped: string[] = []; let mirror: Mirror | undefined; - for (const lc of lcs) { + for (const { lc, rung, bucket } of entries) { if (dry) { const p = await getProblem(env.DB, lc); if (!p) skipped.push(`LC ${lc} is not in the curriculum`); - else if (p.stage !== "new") skipped.push(`LC ${lc}: already at stage ${p.stage}`); - else logged.push(`LC ${lc}: would enter the ladder at +3`); + else if (p.stage !== rung) skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${p.stage}`); + else if (rung === "new") logged.push(`LC ${lc}: would enter the ladder at +3`); + else logged.push(`LC ${lc}: would pass ${rung} and move on`); continue; } - const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit" }); + const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit", rung }); if (outcome.error) skipped.push(outcome.error); - else if (outcome.duplicate) skipped.push(`LC ${lc}: already at stage ${outcome.stage}`); - else { + else if (outcome.duplicate) { + skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${outcome.stage}`); + } else { logged.push(outcomeLine(outcome)); // One mirror for the batch: field IDs resolve once, not per problem. mirror ??= projectMirror(github(env.GH_PAT, env.REPO), env.REPO.split("/")[0]!); @@ -271,6 +286,12 @@ async function handleAdmin(request: Request, env: Env, path: string): Promise(); } +/** + * 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 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`, - ) + .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; @@ -326,9 +391,14 @@ export interface LogOutcome { * 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. + * 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, @@ -337,6 +407,8 @@ export async function logAttempt( date: string; result: Result; source: "email" | "webhook" | "commit"; + /** Commit only: the stage this file settles. Default `new` = first solve. */ + rung?: Stage; gate?: boolean; }, ): Promise { @@ -353,11 +425,12 @@ export async function logAttempt( 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") { + // 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, diff --git a/apps/api/src/stats.ts b/apps/api/src/stats.ts index dbdd7f9..4ac0dfb 100644 --- a/apps/api/src/stats.ts +++ b/apps/api/src/stats.ts @@ -7,6 +7,7 @@ import { CAMPAIGN_DAYS, CAMPAIGN_START, + DUE_WHERE, addDays, campaignDay, campaignWeek, @@ -78,11 +79,7 @@ export async function buildStats(db: D1Database, today: string): Promise for (let i = 0; i < 14; i++) { const date = addDays(today, i); const row = await db - .prepare( - `SELECT COUNT(*) AS n FROM problems - WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1 - AND (defer_until IS NULL OR defer_until <= ?1)`, - ) + .prepare(`SELECT COUNT(*) AS n FROM problems WHERE ${DUE_WHERE}`) .bind(date) .first<{ n: number }>(); queue.push({ date, due: row?.n ?? 0 });