From 7bae7b292069c56a2acc2e6586dc4a9a319b615c Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Mon, 31 Aug 2026 18:56:40 -0400 Subject: [PATCH] feat(api): add concept temperature bands and expose in stats --- apps/api/src/catalog.ts | 5 +- apps/api/src/srs.test.ts | 27 ++++++ apps/api/src/srs.ts | 55 +++++++++++-- apps/api/src/stats.ts | 174 ++++++++++++++++++++++++++++++++------- 4 files changed, 224 insertions(+), 37 deletions(-) diff --git a/apps/api/src/catalog.ts b/apps/api/src/catalog.ts index 4bf50a3..de46a81 100644 --- a/apps/api/src/catalog.ts +++ b/apps/api/src/catalog.ts @@ -10,14 +10,11 @@ * so relabelling a problem is enough to move it and titles stay readable. * The `problem` label is what marks a sub-issue as curriculum. */ -import { workingDay } from "./srs.ts"; +import { DIFFICULTIES, SETS, workingDay } from "./srs.ts"; import type { GitHub } from "./github.ts"; const TITLE_RE = /^LC (\d+) · (.+)$/; -const DIFFICULTIES = ["easy", "medium", "hard"] as const; -const SETS = ["core", "optional", "deferred"] as const; - /** Deferred Hards enter the queue from this date, two per working day. */ const DEFER_FROM = "2026-09-28"; diff --git a/apps/api/src/srs.test.ts b/apps/api/src/srs.test.ts index 9198e64..4f1b18d 100644 --- a/apps/api/src/srs.test.ts +++ b/apps/api/src/srs.test.ts @@ -19,6 +19,8 @@ import { isoWeek, rng, sample, + TEMPERATURES, + temperatureOf, weekdayOf, workingDay, } from "./srs.ts"; @@ -132,6 +134,31 @@ describe("the ladder", () => { }); }); +describe("concept temperature", () => { + test("the bands are the ladder's own windows", () => { + // Boundaries are inclusive, and the bands tile the whole age axis with no + // gap: whatever the windows become, every age still lands in exactly one. + expect(TEMPERATURES).toEqual(["hot", "fresh", "fading", "cold"]); + expect(temperatureOf(0)).toBe("hot"); + expect(temperatureOf(WINDOWS[0])).toBe("hot"); + expect(temperatureOf(WINDOWS[0] + 1)).toBe("fresh"); + expect(temperatureOf(WINDOWS[1])).toBe("fresh"); + expect(temperatureOf(WINDOWS[1] + 1)).toBe("fading"); + expect(temperatureOf(WINDOWS[1] * 2)).toBe("fading"); + expect(temperatureOf(WINDOWS[1] * 2 + 1)).toBe("cold"); + }); + + test("never attempted is cold, not unknown", () => { + expect(temperatureOf(null)).toBe("cold"); + }); + + test("every age of the campaign has exactly one band", () => { + for (let age = 0; age <= CALENDAR.length; age++) { + expect(TEMPERATURES).toContain(temperatureOf(age)); + } + }); +}); + describe("deterministic sampling", () => { test("same seed, same picks", () => { const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; diff --git a/apps/api/src/srs.ts b/apps/api/src/srs.ts index b45bfaf..1a67718 100644 --- a/apps/api/src/srs.ts +++ b/apps/api/src/srs.ts @@ -1,6 +1,6 @@ /** - * SRS domain: ET dates, the work week, the interval ladder, deterministic - * sampling, and the one write path for attempts. + * 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 @@ -128,14 +128,20 @@ export function prettyDate(date: string): string { export const SCHEDULE: Record = scheduleJson; -/** Week in which a topic was (or will be) taught. */ -export function topicWeek(topic: number): number | undefined { +/** 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 campaignWeek(date); + 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. */ @@ -182,6 +188,45 @@ 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; diff --git a/apps/api/src/stats.ts b/apps/api/src/stats.ts index 4ac0dfb..78125eb 100644 --- a/apps/api/src/stats.ts +++ b/apps/api/src/stats.ts @@ -1,19 +1,29 @@ /** - * GET /api/stats — the one JSON document behind the docs progress page. - * Read-only aggregation over D1; shape is the page's contract, change both - * together. A problem counts as "done" once it entered the ladder - * (stage != 'new'). + * 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. */ import { CAMPAIGN_DAYS, CAMPAIGN_START, + DIFFICULTIES, DUE_WHERE, addDays, campaignDay, campaignWeek, + daysBetween, isoWeek, STAGES, streak, + temperatureOf, + topicDate, + type Temperature, } from "./srs.ts"; const PHASE_NAMES: Record = { @@ -24,6 +34,91 @@ const PHASE_NAMES: Record = { 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; +} + +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( @@ -65,6 +160,21 @@ export async function buildStats(db: D1Database, today: string): Promise 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. @@ -74,39 +184,47 @@ export async function buildStats(db: D1Database, today: string): Promise .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. - const queue: { date: string; due: number }[] = []; - for (let i = 0; i < 14; i++) { - const date = addDays(today, i); - const row = await db - .prepare(`SELECT COUNT(*) AS n FROM problems WHERE ${DUE_WHERE}`) - .bind(date) - .first<{ n: number }>(); - queue.push({ date, due: row?.n ?? 0 }); - } + // 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 })); - const recent: { date: string; attempts: number; passes: number }[] = []; - for (let i = 6; i >= 0; i--) { - const date = addDays(today, -i); - const row = await db - .prepare( - `SELECT COUNT(*) AS attempts, - SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes - FROM attempts WHERE date = ?`, - ) - .bind(date) - .first<{ attempts: number; passes: number | null }>(); - recent.push({ date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 }); - } + // Attempts per campaign day, every day present. This is the heatmap's data + // model — the docs page reads the tail of it for "last 7 days" rather than + // asking a second question that could answer differently. + 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 }; + }); return { generated: new Date().toISOString(), campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS }, phases: [...phases.values()], ladder, + difficulty, + topics: await buildTopics(db, today), gates, streak: await streak(db, today), queue, - recent, + heat, }; }