Files
leetcode/apps/api/src/stats.ts
T

231 lines
8.3 KiB
TypeScript

/**
* 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<number, string> = {
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;
}
async function buildTopics(db: D1Database, today: string): Promise<TopicStat[]> {
// 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<number, string>();
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<number, number>();
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<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 }>();
// 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]));
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 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,
heat,
};
}