mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
feat(api): add review day handling and dynamic campaign length
This commit is contained in:
1 parent
7e9ea8d7c2
commit
239c7013f8
9 files changed
+176
-32
No files matched your search
+6
-3
@@ -17,9 +17,12 @@ Live at `https://srs-api.prdlk.workers.dev`.
|
||||
- **The repo owns the plan** — `data/schedule.json`, human-edited,
|
||||
bundled at deploy. Git history is its audit log. Each date is a
|
||||
`ScheduledDay`: `topics` (one or more topic issues), `problems` (a set day,
|
||||
fixed numbers), `gate` (Saturday's blind set, verbatim), or `sweep` (redo
|
||||
every miss, nothing new). Numbers a `gate` or `problems` entry names are
|
||||
`RESERVED`: the drill and gate samplers never draw them.
|
||||
fixed numbers), `review` (a `Review <N> — <Phase>` issue with its problems
|
||||
in solve order; Mon/Tue/Thu/Fri only), `gate` (Saturday's blind set,
|
||||
verbatim; `[]` skips that week's checkpoint), or `sweep` (redo every miss,
|
||||
nothing new). Numbers a `gate`, `problems` or `review` entry names are
|
||||
`RESERVED`: the drill and gate samplers never draw them. The catalog
|
||||
reconcile reports any drift between a `review` entry and its issue.
|
||||
- The GitHub Project mirror writes `Target Date` and nothing else: it is
|
||||
best-effort, so failures log warnings and never block a D1 write, and topic
|
||||
rows are never written. SRS stage and first-attempt result stay in D1 only —
|
||||
|
||||
@@ -22,7 +22,25 @@
|
||||
"2026-09-28": { "topics": [24] },
|
||||
"2026-09-29": { "topics": [25] },
|
||||
"2026-09-30": { "topics": [26] },
|
||||
"2026-10-01": { "problems": [399, 261, 909, 77, 52] },
|
||||
"2026-10-02": { "sweep": true },
|
||||
"2026-10-03": { "gate": [347, 1254, 1971, 131, 494, 986] }
|
||||
"2026-10-01": { "review": { "issue": 225, "title": "Review 1 — Linear Structures", "problems": [205, 974, 209, 34, 42] } },
|
||||
"2026-10-02": { "review": { "issue": 226, "title": "Review 2 — Linear Structures", "problems": [290, 525, 189, 1004, 84] }, "sweep": true },
|
||||
"2026-10-03": { "gate": [] },
|
||||
"2026-10-05": { "review": { "issue": 227, "title": "Review 3 — Linear Structures", "problems": [643, 128, 162, 71, 4] } },
|
||||
"2026-10-06": { "review": { "issue": 228, "title": "Review 4 — Linear Structures", "problems": [496, 80, 901, 76, 224] } },
|
||||
"2026-10-08": { "review": { "issue": 229, "title": "Review 5 — Nodal & Grid", "problems": [876, 19, 36] } },
|
||||
"2026-10-09": { "review": { "issue": 230, "title": "Review 6 — Nodal & Grid", "problems": [92, 82, 380] } },
|
||||
"2026-10-12": { "review": { "issue": 231, "title": "Review 7 — Nodal & Grid", "problems": [143, 86, 289] } },
|
||||
"2026-10-13": { "review": { "issue": 232, "title": "Review 8 — Nodal & Grid", "problems": [61, 138] } },
|
||||
"2026-10-15": { "review": { "issue": 233, "title": "Review 9 — Hierarchical", "problems": [100, 112, 530, 1046, 103, 124] } },
|
||||
"2026-10-16": { "review": { "issue": 234, "title": "Review 10 — Hierarchical", "problems": [572, 543, 703, 105, 295] } },
|
||||
"2026-10-19": { "review": { "issue": 235, "title": "Review 11 — Hierarchical", "problems": [101, 637, 173, 621, 502] } },
|
||||
"2026-10-20": { "review": { "issue": 236, "title": "Review 12 — Hierarchical", "problems": [108, 129, 373, 648, 212] } },
|
||||
"2026-10-22": { "review": { "issue": 237, "title": "Review 13 — Relational", "problems": [1971, 695, 547, 433] } },
|
||||
"2026-10-23": { "review": { "issue": 238, "title": "Review 14 — Relational", "problems": [130, 261, 909, 1462] } },
|
||||
"2026-10-26": { "review": { "issue": 239, "title": "Review 15 — Relational", "problems": [286, 1254, 721, 399] } },
|
||||
"2026-10-27": { "review": { "issue": 240, "title": "Review 16 — Relational", "problems": [1091, 802, 269] } },
|
||||
"2026-10-29": { "review": { "issue": 241, "title": "Review 17 — Decision Space", "problems": [746, 228, 17, 77, 213, 63, 122] } },
|
||||
"2026-10-30": { "review": { "issue": 242, "title": "Review 18 — Decision Space", "problems": [22, 90, 139, 120, 45, 253, 72] } },
|
||||
"2026-11-02": { "review": { "issue": 243, "title": "Review 19 — Decision Space", "problems": [79, 131, 91, 494, 763, 986, 920] } },
|
||||
"2026-11-03": { "review": { "issue": 244, "title": "Review 20 — Decision Space", "problems": [300, 647, 97, 221, 918, 452, 52] } }
|
||||
}
|
||||
+38
-4
@@ -16,9 +16,9 @@
|
||||
* problem's row: upserts only, so nothing breaks — its labels just stop
|
||||
* syncing.
|
||||
*/
|
||||
import { DIFFICULTIES, SETS, workingDay } from "./srs.ts";
|
||||
import { DIFFICULTIES, SCHEDULE, SETS, workingDay } from "./srs.ts";
|
||||
import type { GitHub } from "./github.ts";
|
||||
import { movedFromTopics, readReviews } from "./reviews.ts";
|
||||
import { movedFromTopics, readReviews, type Review } from "./reviews.ts";
|
||||
|
||||
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
||||
|
||||
@@ -28,6 +28,37 @@ const DEFER_FROM = "2026-09-28";
|
||||
export interface ReconcileReport {
|
||||
topics: number;
|
||||
problems: number;
|
||||
/** Where schedule.json's review days disagree with the Review issues; empty when in step. */
|
||||
reviewDrift: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* schedule.json copies each review day's issue, title and problems so the
|
||||
* digest and RESERVED need no GitHub call; the issues stay the truth. Compared
|
||||
* here because this is the one place that already holds both.
|
||||
*/
|
||||
function reviewDrift(reviews: Review[]): string[] {
|
||||
const drift: string[] = [];
|
||||
const booked = new Map<number, string>();
|
||||
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||
if (!day.review) continue;
|
||||
const { issue, title, problems } = day.review;
|
||||
booked.set(issue, date);
|
||||
const review = reviews.find((r) => r.number === issue);
|
||||
if (!review) {
|
||||
drift.push(`${date}: #${issue} is not a "Review <N> — <Phase>" issue`);
|
||||
continue;
|
||||
}
|
||||
if (review.title !== title) drift.push(`${date}: #${issue} is titled "${review.title}", schedule says "${title}"`);
|
||||
const actual = review.problems.map((p) => Number(p.title.match(TITLE_RE)?.[1]));
|
||||
if (actual.join(",") !== problems.join(",")) {
|
||||
drift.push(`${date}: #${issue} holds LC ${actual.join(", ")}, schedule says LC ${problems.join(", ")}`);
|
||||
}
|
||||
}
|
||||
for (const review of reviews) {
|
||||
if (!booked.has(review.number)) drift.push(`#${review.number} "${review.title}" has no day in schedule.json`);
|
||||
}
|
||||
return drift;
|
||||
}
|
||||
|
||||
/** Label names on one issue payload, narrowed with guards rather than cast. */
|
||||
@@ -79,7 +110,10 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
||||
);
|
||||
}
|
||||
|
||||
const moved = movedFromTopics(await readReviews(gh));
|
||||
const reviews = await readReviews(gh);
|
||||
const drift = reviewDrift(reviews);
|
||||
for (const line of drift) console.warn(`catalog: review drift — ${line}`);
|
||||
const moved = movedFromTopics(reviews);
|
||||
let problems = 0;
|
||||
let deferredSeen = 0;
|
||||
for (const t of topics) {
|
||||
@@ -128,5 +162,5 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
||||
for (let i = 0; i < statements.length; i += 50) {
|
||||
await db.batch(statements.slice(i, i + 50));
|
||||
}
|
||||
return { topics: topics.length, problems };
|
||||
return { topics: topics.length, problems, reviewDrift: drift };
|
||||
}
|
||||
+15
-2
@@ -8,8 +8,9 @@
|
||||
* Retrieval rules: review, drill and sweep rows carry number + difficulty
|
||||
* only — never the topic, never a solution link. `DigestRow` has no field for
|
||||
* either, so the rule holds by construction. The plan's own sections — a
|
||||
* learning day's topics (core, then the optional "if time" list) and a set
|
||||
* day's problems — are the only labeled, linked ones. At most REVIEW_CAP (3)
|
||||
* learning day's topics (core, then the optional "if time" list), a set
|
||||
* day's problems, and a review day's `Review <N>` issue — are the only
|
||||
* labeled, linked ones. At most REVIEW_CAP (3)
|
||||
* reviews per day; drills fill toward DAILY_CAP (6) only when the due queue
|
||||
* is light, and never on a sweep day, which assigns every open miss and
|
||||
* nothing new. Overflow is levelled, not stacked: sendDigest gives every
|
||||
@@ -150,6 +151,18 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
|
||||
data.assigned = rows.map((p) => topicRow(env, p));
|
||||
}
|
||||
|
||||
// A review day: the `Review <N> — <Phase>` issue's problems, in its solve
|
||||
// order, linked like a set day's and headed by the issue itself.
|
||||
if (plan.review) {
|
||||
const { rows, missing } = await lookupProblems(db, plan.review.problems);
|
||||
if (missing.length) console.warn(`digest ${date}: review problems not in the catalog: ${missing.join(", ")}`);
|
||||
data.review = {
|
||||
title: plan.review.title,
|
||||
url: `https://github.com/${env.REPO}/issues/${plan.review.issue}`,
|
||||
rows: rows.map((p) => topicRow(env, p)),
|
||||
};
|
||||
}
|
||||
|
||||
// A sweep day works the miss log: every problem still standing on a
|
||||
// failure, minus the ones today's review list already carries.
|
||||
if (plan.sweep) {
|
||||
|
||||
+41
-8
@@ -71,6 +71,13 @@ const LAST_SEEN: Record<string, string> = {
|
||||
retired: "retired",
|
||||
};
|
||||
|
||||
// A review day's rules, the same ones its GitHub issue carries — one sentence
|
||||
// here so the HTML and the plain-text alternative cannot word them apart.
|
||||
const REVIEW_RULES =
|
||||
"90 minutes, top to bottom — easy to hard; skip what is already solved. " +
|
||||
"Before each submit, say the time and space complexity out loud and trace one edge case. " +
|
||||
"Whatever is unfinished stays open.";
|
||||
|
||||
// ── data model ───────────────────────────────────────────────────
|
||||
|
||||
/** A review or drill line: number + difficulty + one-tap links, nothing else. */
|
||||
@@ -101,7 +108,7 @@ export interface TopicSection {
|
||||
export interface DigestData {
|
||||
/** "Tuesday, August 25" */
|
||||
day: string;
|
||||
/** "Day 9 of 56 · Week 2" */
|
||||
/** "Day 9 of 79 · Week 2" — the length is CAMPAIGN_DAYS, derived from the plan. */
|
||||
progress: string;
|
||||
streak: number;
|
||||
rest: boolean;
|
||||
@@ -109,6 +116,8 @@ export interface DigestData {
|
||||
topics: TopicSection[];
|
||||
/** A set day's problems, assigned by number rather than by topic. */
|
||||
assigned?: TopicRow[];
|
||||
/** A review day: the `Review <N> — <Phase>` issue and its problems in solve order. */
|
||||
review?: { title: string; url: string; rows: TopicRow[] };
|
||||
reviews: DigestRow[];
|
||||
/** Reviews past the daily cap; levelled onto the coming days. */
|
||||
carried: number;
|
||||
@@ -293,11 +302,13 @@ function DigestEmail({ data }: { data: DigestData }) {
|
||||
const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
|
||||
const lead = data.topics[0]
|
||||
? `, starting with ${data.topics[0].name}`
|
||||
: data.assigned
|
||||
? ", starting with today's set"
|
||||
: data.sweep
|
||||
? " — sweep day, nothing new"
|
||||
: "";
|
||||
: data.review
|
||||
? `, starting with ${data.review.title}`
|
||||
: data.assigned
|
||||
? ", starting with today's set"
|
||||
: data.sweep
|
||||
? " — sweep day, nothing new"
|
||||
: "";
|
||||
const preview = data.rest ? "Rest day — nothing to do but rest." : `${load} to work through today${lead}.`;
|
||||
|
||||
const passed = data.yesterday.total - data.yesterday.failed.length;
|
||||
@@ -350,6 +361,19 @@ function DigestEmail({ data }: { data: DigestData }) {
|
||||
</Section>
|
||||
))}
|
||||
|
||||
{data.review ? (
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Today's review</Text>
|
||||
<Text style={{ fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
||||
<Link href={data.review.url} style={{ color: INK, textDecoration: "underline" }}>
|
||||
{data.review.title}
|
||||
</Link>
|
||||
</Text>
|
||||
<Text style={hint}>{REVIEW_RULES}</Text>
|
||||
<TopicTable rows={data.review.rows} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{data.assigned ? (
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Today's set</Text>
|
||||
@@ -407,14 +431,14 @@ function DigestEmail({ data }: { data: DigestData }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Eight weeks at a glance — the one thing here that is pure
|
||||
{/* The whole campaign at a glance — the one thing here that is pure
|
||||
encouragement rather than instruction. */}
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Every day you showed up</Text>
|
||||
<Link href={data.progressUrl}>
|
||||
<Img
|
||||
src={data.heatmapUrl}
|
||||
alt="Attempt heatmap across the eight-week campaign"
|
||||
alt="Attempt heatmap across the whole campaign"
|
||||
width="552"
|
||||
style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }}
|
||||
/>
|
||||
@@ -473,6 +497,15 @@ function plainDigest(data: DigestData): string {
|
||||
out.push(" If time — only once the core is done inside the clock:", ...topic.ifTime.map(linked));
|
||||
}
|
||||
}
|
||||
if (data.review) {
|
||||
out.push(
|
||||
"",
|
||||
`TODAY'S REVIEW — ${data.review.title}`,
|
||||
` ${data.review.url}`,
|
||||
REVIEW_RULES,
|
||||
...data.review.rows.map(linked),
|
||||
);
|
||||
}
|
||||
if (data.assigned) {
|
||||
out.push(
|
||||
"",
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* The gate half is a topic-blind quiz. When the plan fixes the set
|
||||
* (`gate` in data/schedule.json) that list IS the gate, verbatim — every
|
||||
* number must be a catalog problem, or the issue is not created, because a
|
||||
* `/done` on an uncatalogued number can never be logged. Otherwise the set
|
||||
* `/done` on an uncatalogued number can never be logged; an empty list skips
|
||||
* the week's checkpoint outright. Otherwise the set
|
||||
* is sampled: one unsolved optional problem per topic scheduled this week
|
||||
* (which is why daily drills only draw from earlier weeks), never one the
|
||||
* plan has reserved. The recap half is generated data, deliberately NOT a
|
||||
@@ -77,6 +78,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
||||
// week without topics, across every topic that still has an unsolved pool.
|
||||
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
||||
const planned = SCHEDULE[date]?.gate;
|
||||
if (planned?.length === 0) return { created: false, reason: `the plan skips the week ${week} checkpoint` };
|
||||
if (planned) {
|
||||
const { rows, missing } = await lookupProblems(db, planned);
|
||||
if (missing.length) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { type GitHub, github } from "./github.ts";
|
||||
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
|
||||
import { type Mirror, projectMirror } from "./mirror.ts";
|
||||
import {
|
||||
CAMPAIGN_DAYS,
|
||||
CAMPAIGN_START,
|
||||
type LogOutcome,
|
||||
type Stage,
|
||||
@@ -333,10 +334,10 @@ export default {
|
||||
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
|
||||
}
|
||||
if (path === "/chart/heatmap.svg") {
|
||||
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, 56), { headers: SVG_HEADERS });
|
||||
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, CAMPAIGN_DAYS), { headers: SVG_HEADERS });
|
||||
}
|
||||
if (path === "/chart/heatmap.png") {
|
||||
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, 56), { headers: PNG_HEADERS });
|
||||
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, CAMPAIGN_DAYS), { headers: PNG_HEADERS });
|
||||
}
|
||||
if (path === "/badge/gate.svg") {
|
||||
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
|
||||
|
||||
@@ -134,6 +134,8 @@ describe("the plan", () => {
|
||||
expect(isRestDay(date)).toBe(false);
|
||||
if (day.gate) expect(weekdayOf(date)).toBe(6);
|
||||
if (day.topics) expect(weekdayOf(date)).toBeLessThan(6);
|
||||
// Reviews run Mon/Tue and Thu/Fri; Wednesday and the weekend stay free.
|
||||
if (day.review) expect([1, 2, 4, 5]).toContain(weekdayOf(date));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -150,6 +152,20 @@ describe("the plan", () => {
|
||||
expect(topicDate(999)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a review is booked once, and no problem sits in two reviews", () => {
|
||||
const issues = new Set<number>();
|
||||
const problems = new Set<number>();
|
||||
for (const day of Object.values(SCHEDULE)) {
|
||||
if (!day.review) continue;
|
||||
expect(issues.has(day.review.issue)).toBe(false);
|
||||
issues.add(day.review.issue);
|
||||
for (const lc of day.review.problems) {
|
||||
expect(problems.has(lc)).toBe(false);
|
||||
problems.add(lc);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("every gate and set problem is reserved from the samplers", () => {
|
||||
for (const day of Object.values(SCHEDULE)) {
|
||||
for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) {
|
||||
|
||||
+33
-9
@@ -22,8 +22,8 @@ import scheduleJson from "../data/schedule.json";
|
||||
|
||||
// ── dates (America/New_York) ─────────────────────────────────────
|
||||
|
||||
/** Day 1. The last day is not a constant: see CAMPAIGN_END below the schedule. */
|
||||
export const CAMPAIGN_START = "2026-08-17";
|
||||
export const CAMPAIGN_DAYS = 56;
|
||||
|
||||
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "America/New_York",
|
||||
@@ -130,15 +130,26 @@ export function prettyDate(date: string): string {
|
||||
* One calendar day of the plan. Most days teach topics (one, or two when the
|
||||
* plan doubles up); the rest are named by what they assign instead: a fixed
|
||||
* gate set for Saturday's review issue, a fixed problem set (a "B-set" day),
|
||||
* or a sweep — redo every miss, nothing new. Absent dates are unplanned:
|
||||
* the ladder still runs, the digest just has nothing new to say.
|
||||
* a transfer-phase review day, or a sweep — redo every miss, nothing new.
|
||||
* Absent dates are unplanned: the ladder still runs, the digest just has
|
||||
* nothing new to say.
|
||||
*/
|
||||
export interface ScheduledDay {
|
||||
/** Topic issues taught that day, in the order the digest presents them. */
|
||||
topics?: number[];
|
||||
/** Problems assigned by number instead of by topic. */
|
||||
problems?: number[];
|
||||
/** Saturday: the blind set, fixed by the plan rather than sampled. */
|
||||
/**
|
||||
* A `Review <N> — <Phase>` day (Mon/Tue/Thu/Fri only): the issue, its title
|
||||
* and its problems in solve order. The issue's sub-issues are the truth; this
|
||||
* copy is what the digest and RESERVED read without calling GitHub, and the
|
||||
* catalog reconcile reports any drift between the two.
|
||||
*/
|
||||
review?: { issue: number; title: string; problems: number[] };
|
||||
/**
|
||||
* Saturday: the blind set, fixed by the plan rather than sampled. An empty
|
||||
* list skips that week's checkpoint — no issue is created.
|
||||
*/
|
||||
gate?: number[];
|
||||
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
|
||||
sweep?: boolean;
|
||||
@@ -146,6 +157,15 @@ export interface ScheduledDay {
|
||||
|
||||
export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
|
||||
|
||||
/**
|
||||
* The last day the plan books anything — the campaign ends there. Derived,
|
||||
* not declared: booking a later day (the review phase ran the plan past its
|
||||
* original eight weeks) moves the digest's "Day N/M", the stats' `heat` and
|
||||
* the heatmap charts with it, and nothing can disagree about when it ends.
|
||||
*/
|
||||
export const CAMPAIGN_END = Object.keys(SCHEDULE).sort().at(-1)!;
|
||||
export const CAMPAIGN_DAYS = daysBetween(CAMPAIGN_START, CAMPAIGN_END) + 1;
|
||||
|
||||
/** Date the schedule teaches a topic on, if it teaches it at all. */
|
||||
export function topicDate(topic: number): string | undefined {
|
||||
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||
@@ -161,13 +181,17 @@ export function topicWeek(topic: number): number | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every problem the plan names outright — a gate's blind set or a set day's
|
||||
* list. The plan owns these: the samplers (daily drills, a sampled gate)
|
||||
* never draw them, so a gate problem stays unseen until its Saturday and a
|
||||
* set day's problems wait for their day.
|
||||
* Every problem the plan names outright — a gate's blind set, a set day's
|
||||
* list, or a review day's. The plan owns these: the samplers (daily drills,
|
||||
* a sampled gate) never draw them, so a gate problem stays unseen until its
|
||||
* Saturday and a set or review day's problems wait for their day.
|
||||
*/
|
||||
export const RESERVED: ReadonlySet<number> = new Set(
|
||||
Object.values(SCHEDULE).flatMap((day) => [...(day.gate ?? []), ...(day.problems ?? [])]),
|
||||
Object.values(SCHEDULE).flatMap((day) => [
|
||||
...(day.gate ?? []),
|
||||
...(day.problems ?? []),
|
||||
...(day.review?.problems ?? []),
|
||||
]),
|
||||
);
|
||||
|
||||
// ── deterministic sampling ───────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user