diff --git a/apps/api/src/digest.ts b/apps/api/src/digest.ts index 0cc20a3..0acef5a 100644 --- a/apps/api/src/digest.ts +++ b/apps/api/src/digest.ts @@ -23,6 +23,7 @@ */ import { CAMPAIGN_DAYS, + MISS_WHERE, type ProblemRow, RESERVED, REVIEW_CAP, @@ -84,8 +85,7 @@ async function openMisses(db: D1Database): Promise { .prepare( `SELECT p.* FROM problems p JOIN attempts a ON a.lc_number = p.lc_number - WHERE a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number) - AND a.result = 'fail' AND p.stage != 'retired' + WHERE ${MISS_WHERE} ORDER BY a.date, p.lc_number`, ) .all(); diff --git a/apps/api/src/srs.ts b/apps/api/src/srs.ts index dda0130..aa222cd 100644 --- a/apps/api/src/srs.ts +++ b/apps/api/src/srs.ts @@ -310,6 +310,15 @@ export async function lookupProblems( 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)`; +/** + * 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. */ export async function dueReviews(db: D1Database, date: string): Promise { const { results } = await db diff --git a/apps/api/src/stats.ts b/apps/api/src/stats.ts index 78125eb..c3bbc8e 100644 --- a/apps/api/src/stats.ts +++ b/apps/api/src/stats.ts @@ -8,12 +8,17 @@ * 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. + * + * `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 { CAMPAIGN_DAYS, CAMPAIGN_START, DIFFICULTIES, DUE_WHERE, + MISS_WHERE, addDays, campaignDay, campaignWeek, @@ -54,6 +59,12 @@ interface TopicStat { 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 { // Three shapes, three statements: batch() carries one row type, and casting // 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 ); 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. + // Attempts per campaign day, every day present. This is the README heatmap's + // data model — the terminal dashboard lays it out by campaign week and reads + // its tail for "last 14 days" rather than asking a second question. const end = addDays(CAMPAIGN_START, CAMPAIGN_DAYS - 1); const { results: heatRows } = await db .prepare( @@ -215,16 +226,78 @@ export async function buildStats(db: D1Database, today: string): Promise 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 { generated: new Date().toISOString(), campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS }, phases: [...phases.values()], ladder, difficulty, + attempting: missRow?.n ?? 0, topics: await buildTopics(db, today), gates, streak: await streak(db, today), queue, heat, + recent, + year, }; }