diff --git a/apps/api/src/stats.ts b/apps/api/src/stats.ts index c3bbc8e..f04976b 100644 --- a/apps/api/src/stats.ts +++ b/apps/api/src/stats.ts @@ -10,8 +10,8 @@ * 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. + * (recently solved, the solved ring, the heatmap and the submissions chart). + * They are additive: the terminal dashboard ignores them. */ import { CAMPAIGN_DAYS, @@ -59,7 +59,7 @@ interface TopicStat { temperature: Temperature; } -/** Days in the year heatmap: the 365 ending today, like LeetCode's "past one year". */ +/** Days in `year`: the 365 ending today, which always covers the current calendar year to date. */ const YEAR_DAYS = 365; /** How many problems `recent` lists. */ @@ -252,38 +252,52 @@ export async function buildStats(db: D1Database, today: string): Promise ) .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. + // The past year, one entry per day (zeros included), split by difficulty two + // ways: every logged attempt, and every first solve — the same first-pass + // date `recent` uses. `heat` stays campaign-scoped (the terminal dashboard + // lays it out by campaign week); this is the calendar-scoped view the docs + // heatmap and submissions chart window and bucket for themselves. const yearStart = addDays(today, -(YEAR_DAYS - 1)); - const { results: yearRows } = await db + const { results: attemptRows } = 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`, + `SELECT a.date AS date, p.difficulty AS difficulty, + COUNT(*) AS n, + SUM(CASE WHEN a.result = 'pass' THEN 1 ELSE 0 END) AS passes + FROM attempts a JOIN problems p ON p.lc_number = a.lc_number + WHERE a.date BETWEEN ?1 AND ?2 + GROUP BY a.date, p.difficulty`, ) .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); + .all<{ date: string; difficulty: string; n: number; passes: number | null }>(); + const { results: solvedRows } = await db + .prepare( + `SELECT date, difficulty, COUNT(*) AS n + FROM (SELECT MIN(a.date) AS date, p.difficulty AS difficulty + FROM attempts a JOIN problems p ON p.lc_number = a.lc_number + WHERE a.result = 'pass' + GROUP BY p.lc_number) + WHERE date BETWEEN ?1 AND ?2 + GROUP BY date, difficulty`, + ) + .bind(yearStart, today) + .all<{ date: string; difficulty: string; n: number }>(); + const year = Array.from({ length: YEAR_DAYS }, (_, i) => ({ + date: addDays(yearStart, i), + passes: 0, + attempts: Object.fromEntries(DIFFICULTIES.map((d) => [d, 0])), + solved: Object.fromEntries(DIFFICULTIES.map((d) => [d, 0])), + })); + const yearIndex = new Map(year.map((d, i) => [d.date, i])); + for (const row of attemptRows) { + const day = year[yearIndex.get(row.date) ?? -1]; + if (!day || !(row.difficulty in day.attempts)) continue; + day.attempts[row.difficulty] = row.n; + day.passes += row.passes ?? 0; + } + for (const row of solvedRows) { + const day = year[yearIndex.get(row.date) ?? -1]; + if (day && row.difficulty in day.solved) day.solved[row.difficulty] = row.n; } - 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(),