feat(api): add difficulty‑segmented solved data to yearly stats

This commit is contained in:
Prad Nukala committed 2026-09-29 17:44:54 -04:00
1 parent 6e9c407698
commit dde06daea3
1 file changed
+44 -30
+44 -30
View File
@@ -10,8 +10,8 @@
* 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 * `recent`, `attempting` and `year` feed the docs page's LeetCode-style cards
* (recently solved, the solved ring, the year heatmap). They are additive: the * (recently solved, the solved ring, the heatmap and the submissions chart).
* terminal dashboard ignores them. * They are additive: the terminal dashboard ignores them.
*/ */
import { import {
CAMPAIGN_DAYS, CAMPAIGN_DAYS,
@@ -59,7 +59,7 @@ interface TopicStat {
temperature: Temperature; 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; const YEAR_DAYS = 365;
/** How many problems `recent` lists. */ /** How many problems `recent` lists. */
@@ -252,38 +252,52 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
) )
.first<{ n: number }>(); .first<{ n: number }>();
// Attempts per day over the past year, every day present, plus the three // The past year, one entry per day (zeros included), split by difficulty two
// figures LeetCode prints above its heatmap. `heat` stays campaign-scoped // ways: every logged attempt, and every first solve — the same first-pass
// (the terminal dashboard lays it out by campaign week); this is the // date `recent` uses. `heat` stays campaign-scoped (the terminal dashboard
// calendar-scoped view of the same table. // 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 yearStart = addDays(today, -(YEAR_DAYS - 1));
const { results: yearRows } = await db const { results: attemptRows } = await db
.prepare( .prepare(
`SELECT date, `SELECT a.date AS date, p.difficulty AS difficulty,
COUNT(*) AS attempts, COUNT(*) AS n,
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes SUM(CASE WHEN a.result = 'pass' THEN 1 ELSE 0 END) AS passes
FROM attempts WHERE date BETWEEN ?1 AND ?2 GROUP BY date`, 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) .bind(yearStart, today)
.all<{ date: string; attempts: number; passes: number | null }>(); .all<{ date: string; difficulty: string; n: number; passes: number | null }>();
const yearByDate = new Map(yearRows.map((r) => [r.date, r])); const { results: solvedRows } = await db
const yearDays = Array.from({ length: YEAR_DAYS }, (_, i) => { .prepare(
const date = addDays(yearStart, i); `SELECT date, difficulty, COUNT(*) AS n
const row = yearByDate.get(date); FROM (SELECT MIN(a.date) AS date, p.difficulty AS difficulty
return { date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 }; FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
}); WHERE a.result = 'pass'
let run = 0; GROUP BY p.lc_number)
let maxStreak = 0; WHERE date BETWEEN ?1 AND ?2
for (const d of yearDays) { GROUP BY date, difficulty`,
run = d.attempts > 0 ? run + 1 : 0; )
maxStreak = Math.max(maxStreak, run); .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 { return {
generated: new Date().toISOString(), generated: new Date().toISOString(),