mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
231 lines
8.3 KiB
TypeScript
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,
|
|
};
|
|
}
|