feat(api): add recent solves, yearly stats, and miss count to API

This commit is contained in:
Prad Nukala committed 2026-09-29 17:28:49 -04:00
1 parent f2a2d2d7de
commit f4fc0fd0e7
3 files changed
+87 -5

No files matched your search

+2 -2
View File
@@ -23,6 +23,7 @@
*/ */
import { import {
CAMPAIGN_DAYS, CAMPAIGN_DAYS,
MISS_WHERE,
type ProblemRow, type ProblemRow,
RESERVED, RESERVED,
REVIEW_CAP, REVIEW_CAP,
@@ -84,8 +85,7 @@ async function openMisses(db: D1Database): Promise<ProblemRow[]> {
.prepare( .prepare(
`SELECT p.* FROM problems p `SELECT p.* FROM problems p
JOIN attempts a ON a.lc_number = p.lc_number JOIN attempts a ON a.lc_number = p.lc_number
WHERE a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number) WHERE ${MISS_WHERE}
AND a.result = 'fail' AND p.stage != 'retired'
ORDER BY a.date, p.lc_number`, ORDER BY a.date, p.lc_number`,
) )
.all<ProblemRow>(); .all<ProblemRow>();
+9
View File
@@ -310,6 +310,15 @@ export async function lookupProblems(
export const DUE_WHERE = `stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1 export const DUE_WHERE = `stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
AND (defer_until IS NULL OR defer_until <= ?1)`; AND (defer_until IS NULL OR defer_until <= ?1)`;
/**
* What an open miss means, in one place: a problem `p` whose most recent
* attempt (highest id) failed and that has not retired. The sweep day works
* these and the stats' "attempting" count is how many there are, so the two
* cannot disagree. Expects `problems p` joined to `attempts a` on lc_number.
*/
export const MISS_WHERE = `a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number)
AND a.result = 'fail' AND p.stage != 'retired'`;
/** Reviews due on/before `date`, oldest first — the overflow carry order. */ /** Reviews due on/before `date`, oldest first — the overflow carry order. */
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> { export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
const { results } = await db const { results } = await db
+76 -3
View File
@@ -8,12 +8,17 @@
* srs.ts turns a topic's days-since-last-attempt into a band, so "which * 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 * concepts have gone cold" has exactly one definition and the dashboard is
* free to colour it however it likes. * 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 year heatmap). They are additive: the
* terminal dashboard ignores them.
*/ */
import { import {
CAMPAIGN_DAYS, CAMPAIGN_DAYS,
CAMPAIGN_START, CAMPAIGN_START,
DIFFICULTIES, DIFFICULTIES,
DUE_WHERE, DUE_WHERE,
MISS_WHERE,
addDays, addDays,
campaignDay, campaignDay,
campaignWeek, campaignWeek,
@@ -54,6 +59,12 @@ interface TopicStat {
temperature: Temperature; temperature: Temperature;
} }
/** Days in the year heatmap: the 365 ending today, like LeetCode's "past one year". */
const YEAR_DAYS = 365;
/** How many problems `recent` lists. */
const RECENT_LIMIT = 10;
async function buildTopics(db: D1Database, today: string): Promise<TopicStat[]> { async function buildTopics(db: D1Database, today: string): Promise<TopicStat[]> {
// Three shapes, three statements: batch() carries one row type, and casting // Three shapes, three statements: batch() carries one row type, and casting
// its results apart would cost more than the round trips it saves. // its results apart would cost more than the round trips it saves.
@@ -195,9 +206,9 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
); );
const queue = dates.map((date, i) => ({ date, due: queueRows[i]?.results[0]?.n ?? 0 })); 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 // Attempts per campaign day, every day present. This is the README heatmap's
// model — the docs page reads the tail of it for "last 7 days" rather than // data model — the terminal dashboard lays it out by campaign week and reads
// asking a second question that could answer differently. // its tail for "last 14 days" rather than asking a second question.
const end = addDays(CAMPAIGN_START, CAMPAIGN_DAYS - 1); const end = addDays(CAMPAIGN_START, CAMPAIGN_DAYS - 1);
const { results: heatRows } = await db const { results: heatRows } = await db
.prepare( .prepare(
@@ -215,16 +226,78 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
return { date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 }; 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 }>();
// Attempts per day over the past year, every day present, plus the three
// figures LeetCode prints above its heatmap. `heat` stays campaign-scoped
// (the terminal dashboard lays it out by campaign week); this is the
// calendar-scoped view of the same table.
const yearStart = addDays(today, -(YEAR_DAYS - 1));
const { results: yearRows } = 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(yearStart, today)
.all<{ date: string; attempts: number; passes: number | null }>();
const yearByDate = new Map(yearRows.map((r) => [r.date, r]));
const yearDays = Array.from({ length: YEAR_DAYS }, (_, i) => {
const date = addDays(yearStart, i);
const row = yearByDate.get(date);
return { date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 };
});
let run = 0;
let maxStreak = 0;
for (const d of yearDays) {
run = d.attempts > 0 ? run + 1 : 0;
maxStreak = Math.max(maxStreak, run);
}
const year = {
days: yearDays,
attempts: yearDays.reduce((n, d) => n + d.attempts, 0),
active_days: yearDays.filter((d) => d.attempts > 0).length,
max_streak: maxStreak,
};
return { return {
generated: new Date().toISOString(), generated: new Date().toISOString(),
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS }, campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
phases: [...phases.values()], phases: [...phases.values()],
ladder, ladder,
difficulty, difficulty,
attempting: missRow?.n ?? 0,
topics: await buildTopics(db, today), topics: await buildTopics(db, today),
gates, gates,
streak: await streak(db, today), streak: await streak(db, today),
queue, queue,
heat, heat,
recent,
year,
}; };
} }