2026-08-25 11:19:19 -04:00
|
|
|
/**
|
|
|
|
|
* 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').
|
|
|
|
|
*/
|
|
|
|
|
import {
|
|
|
|
|
CAMPAIGN_DAYS,
|
|
|
|
|
CAMPAIGN_START,
|
|
|
|
|
addDays,
|
|
|
|
|
campaignDay,
|
|
|
|
|
campaignWeek,
|
2026-08-27 11:05:03 -04:00
|
|
|
isoWeek,
|
2026-08-31 10:41:45 -04:00
|
|
|
STAGES,
|
2026-08-25 11:19:19 -04:00
|
|
|
streak,
|
|
|
|
|
} from "./srs.ts";
|
|
|
|
|
|
|
|
|
|
const PHASE_NAMES: Record<number, string> = {
|
|
|
|
|
1: "I — Linear",
|
|
|
|
|
2: "II — Nodal & Grid",
|
|
|
|
|
3: "III — Hierarchical",
|
|
|
|
|
4: "IV — Relational",
|
|
|
|
|
5: "V — Decision Space",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export async function buildStats(db: D1Database, today: string): Promise<object> {
|
|
|
|
|
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<string, number | string>
|
|
|
|
|
>();
|
|
|
|
|
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<string, number | string>);
|
|
|
|
|
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 }>();
|
2026-08-31 10:41:45 -04:00
|
|
|
// Every rung is present even at zero: the page's bar list is this object.
|
|
|
|
|
const ladder: Record<string, number> = Object.fromEntries(STAGES.map((s) => [s, 0]));
|
2026-08-25 11:19:19 -04:00
|
|
|
for (const row of ladderRows) ladder[row.stage] = row.n;
|
|
|
|
|
|
2026-08-27 11:05:03 -04:00
|
|
|
// 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
|
2026-08-25 11:19:19 -04:00
|
|
|
.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 }>();
|
2026-08-27 11:05:03 -04:00
|
|
|
const gates = gateRows.map((g) => ({ ...g, week: g.week - isoWeekOffset }));
|
2026-08-25 11:19:19 -04:00
|
|
|
|
|
|
|
|
// 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 stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
|
|
|
|
AND (defer_until IS NULL OR defer_until <= ?1)`,
|
|
|
|
|
)
|
|
|
|
|
.bind(date)
|
|
|
|
|
.first<{ n: number }>();
|
|
|
|
|
queue.push({ date, due: row?.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 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
generated: new Date().toISOString(),
|
|
|
|
|
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
|
|
|
|
|
phases: [...phases.values()],
|
|
|
|
|
ladder,
|
|
|
|
|
gates,
|
|
|
|
|
streak: await streak(db, today),
|
|
|
|
|
queue,
|
|
|
|
|
recent,
|
|
|
|
|
};
|
|
|
|
|
}
|