mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +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
+171
-27
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,
|
- **The repo owns the plan** — `data/schedule.json`, human-edited,
|
||||||
bundled at deploy. Git history is its audit log. Each date is a
|
bundled at deploy. Git history is its audit log. Each date is a
|
||||||
`ScheduledDay`: `topics` (one or more topic issues), `problems` (a set day,
|
`ScheduledDay`: `topics` (one or more topic issues), `problems` (a set day,
|
||||||
fixed numbers), `gate` (Saturday's blind set, verbatim), or `sweep` (redo
|
fixed numbers), `review` (a `Review <N> — <Phase>` issue with its problems
|
||||||
every miss, nothing new). Numbers a `gate` or `problems` entry names are
|
in solve order; Mon/Tue/Thu/Fri only), `gate` (Saturday's blind set,
|
||||||
`RESERVED`: the drill and gate samplers never draw them.
|
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
|
- 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
|
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 —
|
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-28": { "topics": [24] },
|
||||||
"2026-09-29": { "topics": [25] },
|
"2026-09-29": { "topics": [25] },
|
||||||
"2026-09-30": { "topics": [26] },
|
"2026-09-30": { "topics": [26] },
|
||||||
"2026-10-01": { "problems": [399, 261, 909, 77, 52] },
|
"2026-10-01": { "review": { "issue": 225, "title": "Review 1 — Linear Structures", "problems": [205, 974, 209, 34, 42] } },
|
||||||
"2026-10-02": { "sweep": true },
|
"2026-10-02": { "review": { "issue": 226, "title": "Review 2 — Linear Structures", "problems": [290, 525, 189, 1004, 84] }, "sweep": true },
|
||||||
"2026-10-03": { "gate": [347, 1254, 1971, 131, 494, 986] }
|
"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
|
* problem's row: upserts only, so nothing breaks — its labels just stop
|
||||||
* syncing.
|
* syncing.
|
||||||
*/
|
*/
|
||||||
import { DIFFICULTIES, SETS, workingDay } from "./srs.ts";
|
import { DIFFICULTIES, SCHEDULE, SETS, workingDay } from "./srs.ts";
|
||||||
import type { GitHub } from "./github.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+) · (.+)$/;
|
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
||||||
|
|
||||||
@@ -28,6 +28,37 @@ const DEFER_FROM = "2026-09-28";
|
|||||||
export interface ReconcileReport {
|
export interface ReconcileReport {
|
||||||
topics: number;
|
topics: number;
|
||||||
problems: 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. */
|
/** 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 problems = 0;
|
||||||
let deferredSeen = 0;
|
let deferredSeen = 0;
|
||||||
for (const t of topics) {
|
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) {
|
for (let i = 0; i < statements.length; i += 50) {
|
||||||
await db.batch(statements.slice(i, 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
|
* Retrieval rules: review, drill and sweep rows carry number + difficulty
|
||||||
* only — never the topic, never a solution link. `DigestRow` has no field for
|
* 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
|
* 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
|
* learning day's topics (core, then the optional "if time" list), a set
|
||||||
* day's problems — are the only labeled, linked ones. At most REVIEW_CAP (3)
|
* 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
|
* 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
|
* is light, and never on a sweep day, which assigns every open miss and
|
||||||
* nothing new. Overflow is levelled, not stacked: sendDigest gives every
|
* 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));
|
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
|
// A sweep day works the miss log: every problem still standing on a
|
||||||
// failure, minus the ones today's review list already carries.
|
// failure, minus the ones today's review list already carries.
|
||||||
if (plan.sweep) {
|
if (plan.sweep) {
|
||||||
|
|||||||
+36
-3
@@ -71,6 +71,13 @@ const LAST_SEEN: Record<string, string> = {
|
|||||||
retired: "retired",
|
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 ───────────────────────────────────────────────────
|
// ── data model ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/** A review or drill line: number + difficulty + one-tap links, nothing else. */
|
/** A review or drill line: number + difficulty + one-tap links, nothing else. */
|
||||||
@@ -101,7 +108,7 @@ export interface TopicSection {
|
|||||||
export interface DigestData {
|
export interface DigestData {
|
||||||
/** "Tuesday, August 25" */
|
/** "Tuesday, August 25" */
|
||||||
day: string;
|
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;
|
progress: string;
|
||||||
streak: number;
|
streak: number;
|
||||||
rest: boolean;
|
rest: boolean;
|
||||||
@@ -109,6 +116,8 @@ export interface DigestData {
|
|||||||
topics: TopicSection[];
|
topics: TopicSection[];
|
||||||
/** A set day's problems, assigned by number rather than by topic. */
|
/** A set day's problems, assigned by number rather than by topic. */
|
||||||
assigned?: TopicRow[];
|
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: DigestRow[];
|
||||||
/** Reviews past the daily cap; levelled onto the coming days. */
|
/** Reviews past the daily cap; levelled onto the coming days. */
|
||||||
carried: number;
|
carried: number;
|
||||||
@@ -293,6 +302,8 @@ function DigestEmail({ data }: { data: DigestData }) {
|
|||||||
const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
|
const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
|
||||||
const lead = data.topics[0]
|
const lead = data.topics[0]
|
||||||
? `, starting with ${data.topics[0].name}`
|
? `, starting with ${data.topics[0].name}`
|
||||||
|
: data.review
|
||||||
|
? `, starting with ${data.review.title}`
|
||||||
: data.assigned
|
: data.assigned
|
||||||
? ", starting with today's set"
|
? ", starting with today's set"
|
||||||
: data.sweep
|
: data.sweep
|
||||||
@@ -350,6 +361,19 @@ function DigestEmail({ data }: { data: DigestData }) {
|
|||||||
</Section>
|
</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 ? (
|
{data.assigned ? (
|
||||||
<Section style={{ marginTop: "28px" }}>
|
<Section style={{ marginTop: "28px" }}>
|
||||||
<Text style={label}>Today's set</Text>
|
<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. */}
|
encouragement rather than instruction. */}
|
||||||
<Section style={{ marginTop: "28px" }}>
|
<Section style={{ marginTop: "28px" }}>
|
||||||
<Text style={label}>Every day you showed up</Text>
|
<Text style={label}>Every day you showed up</Text>
|
||||||
<Link href={data.progressUrl}>
|
<Link href={data.progressUrl}>
|
||||||
<Img
|
<Img
|
||||||
src={data.heatmapUrl}
|
src={data.heatmapUrl}
|
||||||
alt="Attempt heatmap across the eight-week campaign"
|
alt="Attempt heatmap across the whole campaign"
|
||||||
width="552"
|
width="552"
|
||||||
style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }}
|
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));
|
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) {
|
if (data.assigned) {
|
||||||
out.push(
|
out.push(
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* The gate half is a topic-blind quiz. When the plan fixes the set
|
* 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
|
* (`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
|
* 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
|
* is sampled: one unsolved optional problem per topic scheduled this week
|
||||||
* (which is why daily drills only draw from earlier weeks), never one the
|
* (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
|
* 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.
|
// week without topics, across every topic that still has an unsolved pool.
|
||||||
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
||||||
const planned = SCHEDULE[date]?.gate;
|
const planned = SCHEDULE[date]?.gate;
|
||||||
|
if (planned?.length === 0) return { created: false, reason: `the plan skips the week ${week} checkpoint` };
|
||||||
if (planned) {
|
if (planned) {
|
||||||
const { rows, missing } = await lookupProblems(db, planned);
|
const { rows, missing } = await lookupProblems(db, planned);
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { type GitHub, github } from "./github.ts";
|
|||||||
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
|
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
|
||||||
import { type Mirror, projectMirror } from "./mirror.ts";
|
import { type Mirror, projectMirror } from "./mirror.ts";
|
||||||
import {
|
import {
|
||||||
|
CAMPAIGN_DAYS,
|
||||||
CAMPAIGN_START,
|
CAMPAIGN_START,
|
||||||
type LogOutcome,
|
type LogOutcome,
|
||||||
type Stage,
|
type Stage,
|
||||||
@@ -333,10 +334,10 @@ export default {
|
|||||||
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
|
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
|
||||||
}
|
}
|
||||||
if (path === "/chart/heatmap.svg") {
|
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") {
|
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") {
|
if (path === "/badge/gate.svg") {
|
||||||
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
|
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ describe("the plan", () => {
|
|||||||
expect(isRestDay(date)).toBe(false);
|
expect(isRestDay(date)).toBe(false);
|
||||||
if (day.gate) expect(weekdayOf(date)).toBe(6);
|
if (day.gate) expect(weekdayOf(date)).toBe(6);
|
||||||
if (day.topics) expect(weekdayOf(date)).toBeLessThan(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();
|
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", () => {
|
test("every gate and set problem is reserved from the samplers", () => {
|
||||||
for (const day of Object.values(SCHEDULE)) {
|
for (const day of Object.values(SCHEDULE)) {
|
||||||
for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) {
|
for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) {
|
||||||
|
|||||||
+33
-9
@@ -22,8 +22,8 @@ import scheduleJson from "../data/schedule.json";
|
|||||||
|
|
||||||
// ── dates (America/New_York) ─────────────────────────────────────
|
// ── 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_START = "2026-08-17";
|
||||||
export const CAMPAIGN_DAYS = 56;
|
|
||||||
|
|
||||||
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
|
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
|
||||||
timeZone: "America/New_York",
|
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
|
* 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
|
* 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),
|
* 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:
|
* a transfer-phase review day, or a sweep — redo every miss, nothing new.
|
||||||
* the ladder still runs, the digest just has nothing new to say.
|
* Absent dates are unplanned: the ladder still runs, the digest just has
|
||||||
|
* nothing new to say.
|
||||||
*/
|
*/
|
||||||
export interface ScheduledDay {
|
export interface ScheduledDay {
|
||||||
/** Topic issues taught that day, in the order the digest presents them. */
|
/** Topic issues taught that day, in the order the digest presents them. */
|
||||||
topics?: number[];
|
topics?: number[];
|
||||||
/** Problems assigned by number instead of by topic. */
|
/** Problems assigned by number instead of by topic. */
|
||||||
problems?: number[];
|
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[];
|
gate?: number[];
|
||||||
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
|
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
|
||||||
sweep?: boolean;
|
sweep?: boolean;
|
||||||
@@ -146,6 +157,15 @@ export interface ScheduledDay {
|
|||||||
|
|
||||||
export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
|
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. */
|
/** Date the schedule teaches a topic on, if it teaches it at all. */
|
||||||
export function topicDate(topic: number): string | undefined {
|
export function topicDate(topic: number): string | undefined {
|
||||||
for (const [date, day] of Object.entries(SCHEDULE)) {
|
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
|
* Every problem the plan names outright — a gate's blind set, a set day's
|
||||||
* list. The plan owns these: the samplers (daily drills, a sampled gate)
|
* list, or a review day's. The plan owns these: the samplers (daily drills,
|
||||||
* never draw them, so a gate problem stays unseen until its Saturday and a
|
* a sampled gate) never draw them, so a gate problem stays unseen until its
|
||||||
* set day's problems wait for their day.
|
* Saturday and a set or review day's problems wait for their day.
|
||||||
*/
|
*/
|
||||||
export const RESERVED: ReadonlySet<number> = new Set(
|
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 ───────────────────────────────────────
|
// ── deterministic sampling ───────────────────────────────────────
|
||||||
|
|||||||
Reference in new issue
Block a user