/** * GET /api/stats — the one JSON document behind the docs progress page and the * `bun run stats` terminal dashboard. Read-only aggregation over D1; the shape * is both readers' contract, so change all three together. A problem counts as * "done" once it entered the ladder (stage != 'new'). * * The temperature block is the one figure here that is not a count of rows: * srs.ts turns a topic's days-since-last-attempt into a band, so "which * concepts have gone cold" has exactly one definition and the dashboard is * free to colour it however it likes. * * `recent`, `attempting` and `year` feed the docs page's LeetCode-style cards * (recently solved, the solved ring, the heatmap and the submissions chart). * They are additive: the terminal dashboard ignores them. */ import { CAMPAIGN_DAYS, CAMPAIGN_START, DIFFICULTIES, DUE_WHERE, MISS_WHERE, addDays, campaignDay, campaignWeek, daysBetween, isoWeek, STAGES, streak, temperatureOf, topicDate, type Temperature, } from "./srs.ts"; const PHASE_NAMES: Record = { 1: "I — Linear", 2: "II — Nodal & Grid", 3: "III — Hierarchical", 4: "IV — Relational", 5: "V — Decision Space", }; /** One concept: catalog coverage plus how long since it was last exercised. */ interface TopicStat { issue: number; name: string; milestone: number | null; /** Campaign week the schedule teaches it in; null when unscheduled. */ week: number | null; /** False while the schedule has not reached it — temperature is moot. */ taught: boolean; total: number; done: number; retired: number; due: number; /** ET date of the most recent attempt on any of its problems. */ last: string | null; /** Days since `last`; null when never attempted. */ age: number | null; temperature: Temperature; } /** Days in `year`: the 365 ending today, which always covers the current calendar year to date. */ const YEAR_DAYS = 365; /** How many problems `recent` lists. */ const RECENT_LIMIT = 10; async function buildTopics(db: D1Database, today: string): Promise { // Three shapes, three statements: batch() carries one row type, and casting // its results apart would cost more than the round trips it saves. const { results: coverage } = await db .prepare( `SELECT t.issue AS issue, t.name AS name, t.milestone AS milestone, COUNT(p.lc_number) AS total, SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done, SUM(CASE WHEN p.stage = 'retired' THEN 1 ELSE 0 END) AS retired FROM topics t LEFT JOIN problems p ON p.topic_issue = t.issue GROUP BY t.issue ORDER BY t.issue`, ) .all<{ issue: number; name: string; milestone: number | null; total: number; done: number | null; retired: number | null; }>(); const { results: activity } = await db .prepare( `SELECT p.topic_issue AS issue, MAX(a.date) AS last FROM attempts a JOIN problems p ON p.lc_number = a.lc_number GROUP BY p.topic_issue`, ) .all<{ issue: number; last: string | null }>(); const lastByTopic = new Map(); for (const row of activity) { if (row.last) lastByTopic.set(row.issue, row.last); } const { results: dueRows } = await db .prepare( `SELECT topic_issue AS issue, COUNT(*) AS n FROM problems WHERE ${DUE_WHERE} GROUP BY topic_issue`, ) .bind(today) .all<{ issue: number; n: number }>(); const dueByTopic = new Map(); for (const row of dueRows) dueByTopic.set(row.issue, row.n); return coverage.map((row) => { const taughtOn = topicDate(row.issue); const last = lastByTopic.get(row.issue) ?? null; const age = last === null ? null : daysBetween(last, today); return { issue: row.issue, name: row.name, milestone: row.milestone, week: taughtOn === undefined ? null : campaignWeek(taughtOn), taught: taughtOn !== undefined && taughtOn <= today, total: row.total, done: row.done ?? 0, retired: row.retired ?? 0, due: dueByTopic.get(row.issue) ?? 0, last, age, temperature: temperatureOf(age), }; }); } export async function buildStats(db: D1Database, today: string): Promise { const { results: phaseRows } = await db .prepare( `SELECT t.milestone AS milestone, p.set_label AS set_label, COUNT(*) AS total, SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done FROM problems p JOIN topics t ON t.issue = p.topic_issue WHERE t.milestone IS NOT NULL GROUP BY t.milestone, p.set_label ORDER BY t.milestone`, ) .all<{ milestone: number; set_label: string; total: number; done: number }>(); const phases = new Map< number, { milestone: number; name: string } & Record >(); for (const row of phaseRows) { const phase = phases.get(row.milestone) ?? ({ milestone: row.milestone, name: PHASE_NAMES[row.milestone] ?? `Phase ${row.milestone}`, core_done: 0, core_total: 0, optional_done: 0, optional_total: 0, deferred_done: 0, deferred_total: 0, } as { milestone: number; name: string } & Record); phase[`${row.set_label}_done`] = row.done; phase[`${row.set_label}_total`] = row.total; phases.set(row.milestone, phase); } const { results: ladderRows } = await db .prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage") .all<{ stage: string; n: number }>(); // Every rung is present even at zero: the page's bar list is this object. const ladder: Record = Object.fromEntries(STAGES.map((s) => [s, 0])); for (const row of ladderRows) ladder[row.stage] = row.n; // Same shape as `ladder`: every band present, zeros included, in DIFFICULTIES // order so a reader can index it without sorting. const { results: diffRows } = await db .prepare( `SELECT difficulty, COUNT(*) AS total, SUM(CASE WHEN stage != 'new' THEN 1 ELSE 0 END) AS done FROM problems GROUP BY difficulty`, ) .all<{ difficulty: string; total: number; done: number }>(); const difficulty = DIFFICULTIES.map((name) => { const row = diffRows.find((r) => r.difficulty === name); return { difficulty: name, total: row?.total ?? 0, done: row?.done ?? 0 }; }); // gates rows are keyed by ISO week (that's the digest/gate contract), but // the page speaks campaign weeks. The campaign never crosses a year // boundary, so a plain offset converts safely. const isoWeekOffset = isoWeek(CAMPAIGN_START) - 1; const { results: gateRows } = await db .prepare("SELECT week, issue, pass_rate, closed_on FROM gates ORDER BY week") .all<{ week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }>(); const gates = gateRows.map((g) => ({ ...g, week: g.week - isoWeekOffset })); // Review-queue depth for the next 14 days: everything due by that day. One // statement per day, but DUE_WHERE stays the only definition of "due" — the // batch is a single round trip, not fourteen. const dates = Array.from({ length: 14 }, (_, i) => addDays(today, i)); const queueRows = await db.batch<{ n: number }>( dates.map((date) => db.prepare(`SELECT COUNT(*) AS n FROM problems WHERE ${DUE_WHERE}`).bind(date), ), ); const queue = dates.map((date, i) => ({ date, due: queueRows[i]?.results[0]?.n ?? 0 })); // Attempts per campaign day, every day present. This is the README heatmap's // data model — the terminal dashboard lays it out by campaign week and reads // its tail for "last 14 days" rather than asking a second question. const end = addDays(CAMPAIGN_START, CAMPAIGN_DAYS - 1); const { results: heatRows } = await db .prepare( `SELECT date, COUNT(*) AS attempts, SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes FROM attempts WHERE date BETWEEN ?1 AND ?2 GROUP BY date`, ) .bind(CAMPAIGN_START, end) .all<{ date: string; attempts: number; passes: number | null }>(); const byDate = new Map(heatRows.map((r) => [r.date, r])); const heat = Array.from({ length: CAMPAIGN_DAYS }, (_, i) => { const date = addDays(CAMPAIGN_START, i); const row = byDate.get(date); return { date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 }; }); // The ten problems most recently solved, dated by each one's FIRST passing // attempt — when it was completed, not when it was last re-solved (LeetCode's // "Recent AC"). Same-day ties fall back to log order. const { results: recentRows } = await db .prepare( `SELECT p.lc_number AS lc, p.title AS title, p.difficulty AS difficulty, MIN(a.date) AS date, MIN(a.id) AS seq FROM attempts a JOIN problems p ON p.lc_number = a.lc_number WHERE a.result = 'pass' GROUP BY p.lc_number ORDER BY date DESC, seq DESC LIMIT ?1`, ) .bind(RECENT_LIMIT) .all<{ lc: number; title: string; difficulty: string; date: string; seq: number }>(); const recent = recentRows.map(({ lc, title, difficulty, date }) => ({ lc, title, difficulty, date })); // Problems still standing on a failure — the sweep day's miss log, counted. const missRow = await db .prepare( `SELECT COUNT(*) AS n FROM problems p JOIN attempts a ON a.lc_number = p.lc_number WHERE ${MISS_WHERE}`, ) .first<{ n: number }>(); // The past year, one entry per day (zeros included), split by difficulty two // ways: every logged attempt, and every first solve — the same first-pass // date `recent` uses. `heat` stays campaign-scoped (the terminal dashboard // lays it out by campaign week); this is the calendar-scoped view the docs // heatmap and submissions chart window and bucket for themselves. const yearStart = addDays(today, -(YEAR_DAYS - 1)); const { results: attemptRows } = await db .prepare( `SELECT a.date AS date, p.difficulty AS difficulty, COUNT(*) AS n, SUM(CASE WHEN a.result = 'pass' THEN 1 ELSE 0 END) AS passes FROM attempts a JOIN problems p ON p.lc_number = a.lc_number WHERE a.date BETWEEN ?1 AND ?2 GROUP BY a.date, p.difficulty`, ) .bind(yearStart, today) .all<{ date: string; difficulty: string; n: number; passes: number | null }>(); const { results: solvedRows } = await db .prepare( `SELECT date, difficulty, COUNT(*) AS n FROM (SELECT MIN(a.date) AS date, p.difficulty AS difficulty FROM attempts a JOIN problems p ON p.lc_number = a.lc_number WHERE a.result = 'pass' GROUP BY p.lc_number) WHERE date BETWEEN ?1 AND ?2 GROUP BY date, difficulty`, ) .bind(yearStart, today) .all<{ date: string; difficulty: string; n: number }>(); const year = Array.from({ length: YEAR_DAYS }, (_, i) => ({ date: addDays(yearStart, i), passes: 0, attempts: Object.fromEntries(DIFFICULTIES.map((d) => [d, 0])), solved: Object.fromEntries(DIFFICULTIES.map((d) => [d, 0])), })); const yearIndex = new Map(year.map((d, i) => [d.date, i])); for (const row of attemptRows) { const day = year[yearIndex.get(row.date) ?? -1]; if (!day || !(row.difficulty in day.attempts)) continue; day.attempts[row.difficulty] = row.n; day.passes += row.passes ?? 0; } for (const row of solvedRows) { const day = year[yearIndex.get(row.date) ?? -1]; if (day && row.difficulty in day.solved) day.solved[row.difficulty] = row.n; } return { generated: new Date().toISOString(), campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS }, phases: [...phases.values()], ladder, difficulty, attempting: missRow?.n ?? 0, topics: await buildTopics(db, today), gates, streak: await streak(db, today), queue, heat, recent, year, }; }