mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
feat(api): add concept temperature bands and expose in stats
This commit is contained in:
@@ -10,14 +10,11 @@
|
|||||||
* so relabelling a problem is enough to move it and titles stay readable.
|
* so relabelling a problem is enough to move it and titles stay readable.
|
||||||
* The `problem` label is what marks a sub-issue as curriculum.
|
* The `problem` label is what marks a sub-issue as curriculum.
|
||||||
*/
|
*/
|
||||||
import { workingDay } from "./srs.ts";
|
import { DIFFICULTIES, SETS, workingDay } from "./srs.ts";
|
||||||
import type { GitHub } from "./github.ts";
|
import type { GitHub } from "./github.ts";
|
||||||
|
|
||||||
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
||||||
|
|
||||||
const DIFFICULTIES = ["easy", "medium", "hard"] as const;
|
|
||||||
const SETS = ["core", "optional", "deferred"] as const;
|
|
||||||
|
|
||||||
/** Deferred Hards enter the queue from this date, two per working day. */
|
/** Deferred Hards enter the queue from this date, two per working day. */
|
||||||
const DEFER_FROM = "2026-09-28";
|
const DEFER_FROM = "2026-09-28";
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
isoWeek,
|
isoWeek,
|
||||||
rng,
|
rng,
|
||||||
sample,
|
sample,
|
||||||
|
TEMPERATURES,
|
||||||
|
temperatureOf,
|
||||||
weekdayOf,
|
weekdayOf,
|
||||||
workingDay,
|
workingDay,
|
||||||
} from "./srs.ts";
|
} from "./srs.ts";
|
||||||
@@ -132,6 +134,31 @@ describe("the ladder", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("concept temperature", () => {
|
||||||
|
test("the bands are the ladder's own windows", () => {
|
||||||
|
// Boundaries are inclusive, and the bands tile the whole age axis with no
|
||||||
|
// gap: whatever the windows become, every age still lands in exactly one.
|
||||||
|
expect(TEMPERATURES).toEqual(["hot", "fresh", "fading", "cold"]);
|
||||||
|
expect(temperatureOf(0)).toBe("hot");
|
||||||
|
expect(temperatureOf(WINDOWS[0])).toBe("hot");
|
||||||
|
expect(temperatureOf(WINDOWS[0] + 1)).toBe("fresh");
|
||||||
|
expect(temperatureOf(WINDOWS[1])).toBe("fresh");
|
||||||
|
expect(temperatureOf(WINDOWS[1] + 1)).toBe("fading");
|
||||||
|
expect(temperatureOf(WINDOWS[1] * 2)).toBe("fading");
|
||||||
|
expect(temperatureOf(WINDOWS[1] * 2 + 1)).toBe("cold");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never attempted is cold, not unknown", () => {
|
||||||
|
expect(temperatureOf(null)).toBe("cold");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every age of the campaign has exactly one band", () => {
|
||||||
|
for (let age = 0; age <= CALENDAR.length; age++) {
|
||||||
|
expect(TEMPERATURES).toContain(temperatureOf(age));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("deterministic sampling", () => {
|
describe("deterministic sampling", () => {
|
||||||
test("same seed, same picks", () => {
|
test("same seed, same picks", () => {
|
||||||
const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||||
|
|||||||
+50
-5
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* SRS domain: ET dates, the work week, the interval ladder, deterministic
|
* SRS domain: ET dates, the work week, the interval ladder, concept
|
||||||
* sampling, and the one write path for attempts.
|
* temperature, deterministic sampling, and the one write path for attempts.
|
||||||
*
|
*
|
||||||
* D1 is the single source of truth. The stage names the review a problem must
|
* D1 is the single source of truth. The stage names the review a problem must
|
||||||
* pass NEXT (`+3` = due 3 working days after the last clean solve). Passing
|
* pass NEXT (`+3` = due 3 working days after the last clean solve). Passing
|
||||||
@@ -128,14 +128,20 @@ export function prettyDate(date: string): string {
|
|||||||
|
|
||||||
export const SCHEDULE: Record<string, number> = scheduleJson;
|
export const SCHEDULE: Record<string, number> = scheduleJson;
|
||||||
|
|
||||||
/** Week in which a topic was (or will be) taught. */
|
/** Date the schedule teaches a topic on, if it teaches it at all. */
|
||||||
export function topicWeek(topic: number): number | undefined {
|
export function topicDate(topic: number): string | undefined {
|
||||||
for (const [date, t] of Object.entries(SCHEDULE)) {
|
for (const [date, t] of Object.entries(SCHEDULE)) {
|
||||||
if (t === topic) return campaignWeek(date);
|
if (t === topic) return date;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Week in which a topic was (or will be) taught. */
|
||||||
|
export function topicWeek(topic: number): number | undefined {
|
||||||
|
const date = topicDate(topic);
|
||||||
|
return date === undefined ? undefined : campaignWeek(date);
|
||||||
|
}
|
||||||
|
|
||||||
// ── deterministic sampling ───────────────────────────────────────
|
// ── deterministic sampling ───────────────────────────────────────
|
||||||
|
|
||||||
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
|
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
|
||||||
@@ -182,6 +188,45 @@ export const STAGES: Stage[] = ["new", "+3", "+7", "retired"];
|
|||||||
export type Result = "pass" | "fail";
|
export type Result = "pass" | "fail";
|
||||||
export type Kind = "first" | "review" | "drill" | "gate";
|
export type Kind = "first" | "review" | "drill" | "gate";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalog vocabulary, carried by the `diff:*` / `set:*` issue labels and
|
||||||
|
* stored verbatim in `problems`. Listed low to high / most to least required,
|
||||||
|
* which is the order every chart, table and digest section prints them in.
|
||||||
|
*/
|
||||||
|
export const DIFFICULTIES = ["easy", "medium", "hard"] as const;
|
||||||
|
export type Difficulty = (typeof DIFFICULTIES)[number];
|
||||||
|
export const SETS = ["core", "optional", "deferred"] as const;
|
||||||
|
export type SetLabel = (typeof SETS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How warm a concept is: the age of a topic's most recent attempt, bucketed by
|
||||||
|
* the ladder's own windows so the two cannot drift. Touched inside the first
|
||||||
|
* window it is `hot`, inside the second `fresh`, inside one more full second
|
||||||
|
* window `fading`; older than that — or never attempted — it is `cold`.
|
||||||
|
*
|
||||||
|
* A read-side label only: nothing is scheduled off it. It answers the question
|
||||||
|
* the ladder cannot, because the ladder tracks problems and a topic can go
|
||||||
|
* quiet for a fortnight while not one of its problems comes due.
|
||||||
|
*/
|
||||||
|
export const TEMPERATURES = ["hot", "fresh", "fading", "cold"] as const;
|
||||||
|
export type Temperature = (typeof TEMPERATURES)[number];
|
||||||
|
|
||||||
|
/** Warmest band first, with the age it tolerates. Past the last one is cold. */
|
||||||
|
const TEMPERATURE_MAX_AGE: [Temperature, number][] = [
|
||||||
|
["hot", WINDOWS[0]],
|
||||||
|
["fresh", WINDOWS[1]],
|
||||||
|
["fading", WINDOWS[1] * 2],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Days since a topic's last attempt → its band; null (never) is cold. */
|
||||||
|
export function temperatureOf(age: number | null): Temperature {
|
||||||
|
if (age === null) return "cold";
|
||||||
|
for (const [temperature, max] of TEMPERATURE_MAX_AGE) {
|
||||||
|
if (age <= max) return temperature;
|
||||||
|
}
|
||||||
|
return "cold";
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProblemRow {
|
export interface ProblemRow {
|
||||||
lc_number: number;
|
lc_number: number;
|
||||||
issue: number;
|
issue: number;
|
||||||
|
|||||||
+143
-25
@@ -1,19 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* GET /api/stats — the one JSON document behind the docs progress page.
|
* GET /api/stats — the one JSON document behind the docs progress page and the
|
||||||
* Read-only aggregation over D1; shape is the page's contract, change both
|
* `bun run stats` terminal dashboard. Read-only aggregation over D1; the shape
|
||||||
* together. A problem counts as "done" once it entered the ladder
|
* is both readers' contract, so change all three together. A problem counts as
|
||||||
* (stage != 'new').
|
* "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 {
|
import {
|
||||||
CAMPAIGN_DAYS,
|
CAMPAIGN_DAYS,
|
||||||
CAMPAIGN_START,
|
CAMPAIGN_START,
|
||||||
|
DIFFICULTIES,
|
||||||
DUE_WHERE,
|
DUE_WHERE,
|
||||||
addDays,
|
addDays,
|
||||||
campaignDay,
|
campaignDay,
|
||||||
campaignWeek,
|
campaignWeek,
|
||||||
|
daysBetween,
|
||||||
isoWeek,
|
isoWeek,
|
||||||
STAGES,
|
STAGES,
|
||||||
streak,
|
streak,
|
||||||
|
temperatureOf,
|
||||||
|
topicDate,
|
||||||
|
type Temperature,
|
||||||
} from "./srs.ts";
|
} from "./srs.ts";
|
||||||
|
|
||||||
const PHASE_NAMES: Record<number, string> = {
|
const PHASE_NAMES: Record<number, string> = {
|
||||||
@@ -24,6 +34,91 @@ const PHASE_NAMES: Record<number, string> = {
|
|||||||
5: "V — Decision Space",
|
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> {
|
export async function buildStats(db: D1Database, today: string): Promise<object> {
|
||||||
const { results: phaseRows } = await db
|
const { results: phaseRows } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -65,6 +160,21 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
|||||||
const ladder: Record<string, number> = Object.fromEntries(STAGES.map((s) => [s, 0]));
|
const ladder: Record<string, number> = Object.fromEntries(STAGES.map((s) => [s, 0]));
|
||||||
for (const row of ladderRows) ladder[row.stage] = row.n;
|
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
|
// 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
|
// the page speaks campaign weeks. The campaign never crosses a year
|
||||||
// boundary, so a plain offset converts safely.
|
// boundary, so a plain offset converts safely.
|
||||||
@@ -74,39 +184,47 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
|||||||
.all<{ week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }>();
|
.all<{ week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }>();
|
||||||
const gates = gateRows.map((g) => ({ ...g, week: g.week - isoWeekOffset }));
|
const gates = gateRows.map((g) => ({ ...g, week: g.week - isoWeekOffset }));
|
||||||
|
|
||||||
// Review-queue depth for the next 14 days: everything due by that day.
|
// Review-queue depth for the next 14 days: everything due by that day. One
|
||||||
const queue: { date: string; due: number }[] = [];
|
// statement per day, but DUE_WHERE stays the only definition of "due" — the
|
||||||
for (let i = 0; i < 14; i++) {
|
// batch is a single round trip, not fourteen.
|
||||||
const date = addDays(today, i);
|
const dates = Array.from({ length: 14 }, (_, i) => addDays(today, i));
|
||||||
const row = await db
|
const queueRows = await db.batch<{ n: number }>(
|
||||||
.prepare(`SELECT COUNT(*) AS n FROM problems WHERE ${DUE_WHERE}`)
|
dates.map((date) =>
|
||||||
.bind(date)
|
db.prepare(`SELECT COUNT(*) AS n FROM problems WHERE ${DUE_WHERE}`).bind(date),
|
||||||
.first<{ n: number }>();
|
),
|
||||||
queue.push({ date, due: row?.n ?? 0 });
|
);
|
||||||
}
|
const queue = dates.map((date, i) => ({ date, due: queueRows[i]?.results[0]?.n ?? 0 }));
|
||||||
|
|
||||||
const recent: { date: string; attempts: number; passes: number }[] = [];
|
// Attempts per campaign day, every day present. This is the heatmap's data
|
||||||
for (let i = 6; i >= 0; i--) {
|
// model — the docs page reads the tail of it for "last 7 days" rather than
|
||||||
const date = addDays(today, -i);
|
// asking a second question that could answer differently.
|
||||||
const row = await db
|
const end = addDays(CAMPAIGN_START, CAMPAIGN_DAYS - 1);
|
||||||
|
const { results: heatRows } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT COUNT(*) AS attempts,
|
`SELECT date,
|
||||||
|
COUNT(*) AS attempts,
|
||||||
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes
|
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes
|
||||||
FROM attempts WHERE date = ?`,
|
FROM attempts WHERE date BETWEEN ?1 AND ?2 GROUP BY date`,
|
||||||
)
|
)
|
||||||
.bind(date)
|
.bind(CAMPAIGN_START, end)
|
||||||
.first<{ attempts: number; passes: number | null }>();
|
.all<{ date: string; attempts: number; passes: number | null }>();
|
||||||
recent.push({ date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 });
|
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 {
|
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,
|
||||||
|
topics: await buildTopics(db, today),
|
||||||
gates,
|
gates,
|
||||||
streak: await streak(db, today),
|
streak: await streak(db, today),
|
||||||
queue,
|
queue,
|
||||||
recent,
|
heat,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user