mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
feat(api): add structured schedule plan with topics, problems, gate, sweep and reserved problem handling
This commit is contained in:
1 parent
00f44a1c47
commit
86107d5ef7
7 files changed
+317
-86
No files matched your search
+10
-3
@@ -14,8 +14,12 @@ Live at `https://srs-api.prdlk.workers.dev`.
|
|||||||
labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via
|
labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via
|
||||||
`/admin/reconcile`); it recomputes from scratch, never invents rows, and
|
`/admin/reconcile`); it recomputes from scratch, never invents rows, and
|
||||||
never overwrites SRS-owned columns (`stage`, `next_review`).
|
never overwrites SRS-owned columns (`stage`, `next_review`).
|
||||||
- **The repo owns the schedule** — `data/schedule.json`, human-edited,
|
- **The repo owns the plan** — `data/schedule.json`, human-edited,
|
||||||
bundled at deploy. Git history is its audit log.
|
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.
|
||||||
- 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 —
|
||||||
@@ -47,7 +51,10 @@ raw `Date` math.
|
|||||||
Blind drills draw only from topics **already learned**: scheduled in an
|
Blind drills draw only from topics **already learned**: scheduled in an
|
||||||
earlier week (the current week's optional pool is reserved for Saturday's
|
earlier week (the current week's optional pool is reserved for Saturday's
|
||||||
gate) and with at least one core problem on the ladder — a skipped learning
|
gate) and with at least one core problem on the ladder — a skipped learning
|
||||||
day never feeds drills just because its calendar week lapsed.
|
day never feeds drills just because its calendar week lapsed. A Saturday
|
||||||
|
whose plan fixes the `gate` set uses that list as-is; every number must be a
|
||||||
|
catalog row (a `set:optional` sub-issue of its topic), or the review issue is
|
||||||
|
not created — a `/done` on an uncatalogued number could never be logged.
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
|
|||||||
+26
-24
@@ -1,26 +1,28 @@
|
|||||||
{
|
{
|
||||||
"2026-08-17": 3,
|
"2026-08-17": { "topics": [3] },
|
||||||
"2026-08-18": 4,
|
"2026-08-18": { "topics": [4] },
|
||||||
"2026-08-24": 5,
|
"2026-08-24": { "topics": [5] },
|
||||||
"2026-08-25": 6,
|
"2026-08-25": { "topics": [6] },
|
||||||
"2026-08-26": 7,
|
"2026-08-26": { "topics": [7] },
|
||||||
"2026-08-27": 8,
|
"2026-08-27": { "topics": [8] },
|
||||||
"2026-08-28": 9,
|
"2026-08-28": { "topics": [9] },
|
||||||
"2026-08-31": 10,
|
"2026-08-31": { "topics": [10] },
|
||||||
"2026-09-01": 11,
|
"2026-09-01": { "topics": [11] },
|
||||||
"2026-09-02": 12,
|
"2026-09-02": { "topics": [12] },
|
||||||
"2026-09-03": 13,
|
"2026-09-03": { "topics": [13] },
|
||||||
"2026-09-04": 14,
|
"2026-09-04": { "topics": [14] },
|
||||||
"2026-09-07": 15,
|
"2026-09-07": { "topics": [15] },
|
||||||
"2026-09-08": 16,
|
"2026-09-08": { "topics": [16] },
|
||||||
"2026-09-09": 17,
|
"2026-09-21": { "topics": [17, 18] },
|
||||||
"2026-09-10": 18,
|
"2026-09-22": { "topics": [19, 20] },
|
||||||
"2026-09-11": 19,
|
"2026-09-23": { "topics": [21] },
|
||||||
"2026-09-14": 20,
|
"2026-09-24": { "topics": [22] },
|
||||||
"2026-09-15": 21,
|
"2026-09-25": { "topics": [23] },
|
||||||
"2026-09-16": 22,
|
"2026-09-26": { "gate": [648, 1091, 802, 547, 90] },
|
||||||
"2026-09-17": 23,
|
"2026-09-28": { "topics": [24] },
|
||||||
"2026-09-18": 24,
|
"2026-09-29": { "topics": [25] },
|
||||||
"2026-09-21": 25,
|
"2026-09-30": { "topics": [26] },
|
||||||
"2026-09-22": 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] }
|
||||||
}
|
}
|
||||||
+70
-24
@@ -5,14 +5,16 @@
|
|||||||
* the HTML and the plain-text alternative from that single tree (React Email
|
* the HTML and the plain-text alternative from that single tree (React Email
|
||||||
* `render`), so the two can never drift.
|
* `render`), so the two can never drift.
|
||||||
*
|
*
|
||||||
* Retrieval rules: review and drill rows carry number + difficulty only —
|
* Retrieval rules: review, drill and sweep rows carry number + difficulty
|
||||||
* 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 learning day's core list is
|
* either, so the rule holds by construction. The plan's own sections — a
|
||||||
* the only labeled section. At most REVIEW_CAP (3) reviews per day; drills
|
* learning day's topics (core, then the optional "if time" list) and a set
|
||||||
* fill toward DAILY_CAP (6) only when the due queue is light. Overflow is
|
* day's problems — are the only labeled, linked ones. At most REVIEW_CAP (3)
|
||||||
* levelled, not stacked: sendDigest gives every review past the cap a
|
* reviews per day; drills fill toward DAILY_CAP (6) only when the due queue
|
||||||
* concrete future date, ≤ REVIEW_CAP per day, oldest first. Sunday is a
|
* is light, and never on a sweep day, which assigns every open miss and
|
||||||
* two-line rest note.
|
* nothing new. Overflow is levelled, not stacked: sendDigest gives every
|
||||||
|
* review past the cap a concrete future date, ≤ REVIEW_CAP per day, oldest
|
||||||
|
* first. Sunday is a two-line rest note.
|
||||||
*
|
*
|
||||||
* Idempotency: email_log keys sends by ET date — a same-day re-send is a
|
* Idempotency: email_log keys sends by ET date — a same-day re-send is a
|
||||||
* no-op unless forced; the body itself is deterministic (drill picks are
|
* no-op unless forced; the body itself is deterministic (drill picks are
|
||||||
@@ -22,20 +24,23 @@
|
|||||||
import {
|
import {
|
||||||
CAMPAIGN_DAYS,
|
CAMPAIGN_DAYS,
|
||||||
type ProblemRow,
|
type ProblemRow,
|
||||||
|
RESERVED,
|
||||||
REVIEW_CAP,
|
REVIEW_CAP,
|
||||||
SCHEDULE,
|
SCHEDULE,
|
||||||
|
type ScheduledDay,
|
||||||
addDays,
|
addDays,
|
||||||
campaignDay,
|
campaignDay,
|
||||||
campaignWeek,
|
campaignWeek,
|
||||||
dueReviews,
|
dueReviews,
|
||||||
isoWeek,
|
isoWeek,
|
||||||
levelReviews,
|
levelReviews,
|
||||||
|
lookupProblems,
|
||||||
pickDrills,
|
pickDrills,
|
||||||
prettyDate,
|
prettyDate,
|
||||||
streak,
|
streak,
|
||||||
weekdayOf,
|
weekdayOf,
|
||||||
} from "./srs.ts";
|
} from "./srs.ts";
|
||||||
import { type DigestData, type DigestRow, renderDigest } from "./email.tsx";
|
import { type DigestData, type DigestRow, type TopicRow, renderDigest } from "./email.tsx";
|
||||||
import { signLink } from "./links.ts";
|
import { signLink } from "./links.ts";
|
||||||
|
|
||||||
/** Total daily ceiling: drills only fill what the whole due queue leaves. */
|
/** Total daily ceiling: drills only fill what the whole due queue leaves. */
|
||||||
@@ -63,23 +68,50 @@ async function tapRow(env: Env, p: ProblemRow, date: string, staged: boolean): P
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A plan-named problem: linked to its issue, ticked once it has entered the ladder. */
|
||||||
|
function topicRow(env: Env, p: ProblemRow): TopicRow {
|
||||||
|
return {
|
||||||
|
lc: p.lc_number,
|
||||||
|
difficulty: p.difficulty,
|
||||||
|
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
|
||||||
|
solved: p.stage !== "new",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Problems whose most recent attempt failed — the miss log a sweep day works through. */
|
||||||
|
async function openMisses(db: D1Database): Promise<ProblemRow[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT p.* FROM problems p
|
||||||
|
JOIN attempts a ON a.lc_number = p.lc_number
|
||||||
|
WHERE a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number)
|
||||||
|
AND a.result = 'fail' AND p.stage != 'retired'
|
||||||
|
ORDER BY a.date, p.lc_number`,
|
||||||
|
)
|
||||||
|
.all<ProblemRow>();
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
/** Everything the email needs, read straight out of D1 and the schedule. */
|
/** Everything the email needs, read straight out of D1 and the schedule. */
|
||||||
export async function collectDigest(env: Env, date: string): Promise<DigestData> {
|
export async function collectDigest(env: Env, date: string): Promise<DigestData> {
|
||||||
const db = env.DB;
|
const db = env.DB;
|
||||||
const rest = weekdayOf(date) === 0;
|
const rest = weekdayOf(date) === 0;
|
||||||
|
const plan: ScheduledDay = rest ? {} : (SCHEDULE[date] ?? {});
|
||||||
|
|
||||||
// Reviews surface at most REVIEW_CAP; drills fill toward DAILY_CAP only
|
// Reviews surface at most REVIEW_CAP; drills fill toward DAILY_CAP only
|
||||||
// when the WHOLE due queue is light — a heavy backlog must never trade
|
// when the WHOLE due queue is light — a heavy backlog must never trade
|
||||||
// review slots for brand-new drill problems.
|
// review slots for brand-new drill problems — and never on a sweep day.
|
||||||
const due = rest ? [] : await dueReviews(db, date);
|
const due = rest ? [] : await dueReviews(db, date);
|
||||||
const capped = due.slice(0, REVIEW_CAP);
|
const capped = due.slice(0, REVIEW_CAP);
|
||||||
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
|
const drills =
|
||||||
|
rest || plan.sweep ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
|
||||||
|
|
||||||
const data: DigestData = {
|
const data: DigestData = {
|
||||||
day: prettyDate(date),
|
day: prettyDate(date),
|
||||||
progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`,
|
progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`,
|
||||||
streak: await streak(db, date),
|
streak: await streak(db, date),
|
||||||
rest,
|
rest,
|
||||||
|
topics: [],
|
||||||
reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))),
|
reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))),
|
||||||
carried: due.length - capped.length,
|
carried: due.length - capped.length,
|
||||||
drills: await Promise.all(drills.map((p) => tapRow(env, p, date, false))),
|
drills: await Promise.all(drills.map((p) => tapRow(env, p, date, false))),
|
||||||
@@ -88,28 +120,42 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
|
|||||||
progressUrl: `${env.DOCS_URL}/progress`,
|
progressUrl: `${env.DOCS_URL}/progress`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// New topic — the only section allowed to name problems and link them.
|
// New topics — the plan's sections, the only ones allowed to name problems
|
||||||
const topicIssue = rest ? undefined : SCHEDULE[date];
|
// and link them. Core first; the optional set is "if time", minus anything
|
||||||
if (topicIssue !== undefined) {
|
// the plan has reserved for a gate or a set day.
|
||||||
|
for (const topicIssue of plan.topics ?? []) {
|
||||||
const topic = await db
|
const topic = await db
|
||||||
.prepare("SELECT name FROM topics WHERE issue = ?")
|
.prepare("SELECT name FROM topics WHERE issue = ?")
|
||||||
.bind(topicIssue)
|
.bind(topicIssue)
|
||||||
.first<{ name: string }>();
|
.first<{ name: string }>();
|
||||||
const { results: core } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT * FROM problems WHERE topic_issue = ? AND set_label = 'core' ORDER BY lc_number",
|
"SELECT * FROM problems WHERE topic_issue = ? AND set_label IN ('core', 'optional') ORDER BY lc_number",
|
||||||
)
|
)
|
||||||
.bind(topicIssue)
|
.bind(topicIssue)
|
||||||
.all<ProblemRow>();
|
.all<ProblemRow>();
|
||||||
data.topic = {
|
data.topics.push({
|
||||||
name: topic?.name ?? `#${topicIssue}`,
|
name: topic?.name ?? `#${topicIssue}`,
|
||||||
core: core.map((p) => ({
|
core: results.filter((p) => p.set_label === "core").map((p) => topicRow(env, p)),
|
||||||
lc: p.lc_number,
|
ifTime: results
|
||||||
difficulty: p.difficulty,
|
.filter((p) => p.set_label === "optional" && !RESERVED.has(p.lc_number))
|
||||||
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
|
.map((p) => topicRow(env, p)),
|
||||||
solved: p.stage !== "new",
|
});
|
||||||
})),
|
}
|
||||||
};
|
|
||||||
|
// A set day assigns problems by number rather than by topic.
|
||||||
|
if (plan.problems) {
|
||||||
|
const { rows, missing } = await lookupProblems(db, plan.problems);
|
||||||
|
if (missing.length) console.warn(`digest ${date}: planned problems not in the catalog: ${missing.join(", ")}`);
|
||||||
|
data.assigned = 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) {
|
||||||
|
const reviewed = new Set(capped.map((p) => p.lc_number));
|
||||||
|
const misses = (await openMisses(db)).filter((p) => !reviewed.has(p.lc_number));
|
||||||
|
data.sweep = await Promise.all(misses.map((p) => tapRow(env, p, date, true)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saturday: the review issue already exists (created at midnight ET).
|
// Saturday: the review issue already exists (created at midnight ET).
|
||||||
|
|||||||
+85
-17
@@ -83,7 +83,7 @@ export interface DigestRow {
|
|||||||
failUrl: string;
|
failUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A learning-day core problem — the only rows allowed a link. */
|
/** A plan-named problem — the only rows allowed a link. */
|
||||||
export interface TopicRow {
|
export interface TopicRow {
|
||||||
lc: number;
|
lc: number;
|
||||||
difficulty: string;
|
difficulty: string;
|
||||||
@@ -91,6 +91,13 @@ export interface TopicRow {
|
|||||||
solved: boolean;
|
solved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One learning-day topic: the core list, then the optional "if time" list. */
|
||||||
|
export interface TopicSection {
|
||||||
|
name: string;
|
||||||
|
core: TopicRow[];
|
||||||
|
ifTime: TopicRow[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DigestData {
|
export interface DigestData {
|
||||||
/** "Tuesday, August 25" */
|
/** "Tuesday, August 25" */
|
||||||
day: string;
|
day: string;
|
||||||
@@ -98,11 +105,16 @@ export interface DigestData {
|
|||||||
progress: string;
|
progress: string;
|
||||||
streak: number;
|
streak: number;
|
||||||
rest: boolean;
|
rest: boolean;
|
||||||
topic?: { name: string; core: TopicRow[] };
|
/** Topics taught today, in plan order — empty on every other day. */
|
||||||
|
topics: TopicSection[];
|
||||||
|
/** A set day's problems, assigned by number rather than by topic. */
|
||||||
|
assigned?: 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;
|
||||||
drills: DigestRow[];
|
drills: DigestRow[];
|
||||||
|
/** A sweep day's miss log: present (even empty) only on a sweep day. */
|
||||||
|
sweep?: DigestRow[];
|
||||||
gate?: { week: number; url: string };
|
gate?: { week: number; url: string };
|
||||||
yesterday: { total: number; failed: number[] };
|
yesterday: { total: number; failed: number[] };
|
||||||
gateRate?: number;
|
gateRate?: number;
|
||||||
@@ -278,10 +290,15 @@ function Block({
|
|||||||
// ── email ────────────────────────────────────────────────────────
|
// ── email ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DigestEmail({ data }: { data: DigestData }) {
|
function DigestEmail({ data }: { data: DigestData }) {
|
||||||
const load = data.reviews.length + data.drills.length;
|
const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
|
||||||
const preview = data.rest
|
const lead = data.topics[0]
|
||||||
? "Rest day — nothing to do but rest."
|
? `, starting with ${data.topics[0].name}`
|
||||||
: `${load} to work through today${data.topic ? `, starting with ${data.topic.name}` : ""}.`;
|
: 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;
|
const passed = data.yesterday.total - data.yesterday.failed.length;
|
||||||
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
|
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
|
||||||
@@ -310,15 +327,47 @@ function DigestEmail({ data }: { data: DigestData }) {
|
|||||||
|
|
||||||
{data.rest ? null : (
|
{data.rest ? null : (
|
||||||
<>
|
<>
|
||||||
{data.topic ? (
|
{data.topics.map((topic, i) => (
|
||||||
<Section style={{ marginTop: "28px" }}>
|
<Section key={topic.name} style={{ marginTop: i === 0 ? "28px" : "20px" }}>
|
||||||
<Text style={label}>Something new today</Text>
|
{i === 0 ? (
|
||||||
|
<Text style={label}>
|
||||||
|
{data.topics.length === 1 ? "Something new today" : "Two new things today"}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
||||||
{data.topic.name}
|
{topic.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={hint}>Work through these from the top — a tick means it is already solved.</Text>
|
<Text style={hint}>Work through these from the top — a tick means it is already solved.</Text>
|
||||||
<TopicTable rows={data.topic.core} />
|
<TopicTable rows={topic.core} />
|
||||||
|
{topic.ifTime.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Text style={{ ...hint, margin: "12px 0 8px" }}>
|
||||||
|
If time — only once the core is done inside the clock.
|
||||||
|
</Text>
|
||||||
|
<TopicTable rows={topic.ifTime} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</Section>
|
</Section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{data.assigned ? (
|
||||||
|
<Section style={{ marginTop: "28px" }}>
|
||||||
|
<Text style={label}>Today's set</Text>
|
||||||
|
<Text style={hint}>
|
||||||
|
Picked by number, not by topic — all of them, in order, inside the clock.
|
||||||
|
</Text>
|
||||||
|
<TopicTable rows={data.assigned} />
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data.sweep ? (
|
||||||
|
<Block
|
||||||
|
title="Sweep — redo every red"
|
||||||
|
note="Every problem still standing on a miss. Nothing new today; clear these first."
|
||||||
|
rows={data.sweep}
|
||||||
|
staged
|
||||||
|
empty="The miss log is clear — nothing is standing on a fail."
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Block
|
<Block
|
||||||
@@ -409,16 +458,27 @@ function plainDigest(data: DigestData): string {
|
|||||||
: ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`),
|
: ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const linked = (p: TopicRow) =>
|
||||||
|
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`;
|
||||||
|
|
||||||
if (!data.rest) {
|
if (!data.rest) {
|
||||||
if (data.topic) {
|
for (const topic of data.topics) {
|
||||||
out.push(
|
out.push(
|
||||||
"",
|
"",
|
||||||
`SOMETHING NEW TODAY — ${data.topic.name}`,
|
`SOMETHING NEW TODAY — ${topic.name}`,
|
||||||
"Work through these from the top; the ones marked done you have already solved.",
|
"Work through these from the top; the ones marked done you have already solved.",
|
||||||
...data.topic.core.map(
|
...topic.core.map(linked),
|
||||||
(p) =>
|
);
|
||||||
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`,
|
if (topic.ifTime.length > 0) {
|
||||||
),
|
out.push(" If time — only once the core is done inside the clock:", ...topic.ifTime.map(linked));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.assigned) {
|
||||||
|
out.push(
|
||||||
|
"",
|
||||||
|
"TODAY'S SET",
|
||||||
|
"Picked by number, not by topic — all of them, in order, inside the clock.",
|
||||||
|
...data.assigned.map(linked),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,6 +496,14 @@ function plainDigest(data: DigestData): string {
|
|||||||
"No cold starts today.",
|
"No cold starts today.",
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
if (data.sweep) {
|
||||||
|
lists.unshift([
|
||||||
|
"SWEEP — REDO EVERY RED",
|
||||||
|
"Every problem still standing on a miss. Nothing new today; clear these first.",
|
||||||
|
data.sweep,
|
||||||
|
"The miss log is clear — nothing is standing on a fail.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
for (const [title, note, rows, empty] of lists) {
|
for (const [title, note, rows, empty] of lists) {
|
||||||
out.push("", title);
|
out.push("", title);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
|
|||||||
+31
-13
@@ -2,21 +2,27 @@
|
|||||||
* Saturday review issue: created at midnight ET so the 8 AM digest can link
|
* Saturday review issue: created at midnight ET so the 8 AM digest can link
|
||||||
* to it; scored when the issue closes (webhook).
|
* to it; scored when the issue closes (webhook).
|
||||||
*
|
*
|
||||||
* The gate half is a topic-blind quiz — one unsolved optional problem per
|
* The gate half is a topic-blind quiz. When the plan fixes the set
|
||||||
* topic scheduled this week (which is why daily drills only draw from
|
* (`gate` in data/schedule.json) that list IS the gate, verbatim — every
|
||||||
* earlier weeks). The recap half is generated data, deliberately NOT a task
|
* number must be a catalog problem, or the issue is not created, because a
|
||||||
* list: the week's solved problems are already scheduled by the +3/+7 ladder,
|
* `/done` on an uncatalogued number can never be logged. Otherwise the set
|
||||||
* and re-assigning them on Saturday would be massed practice.
|
* 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
|
||||||
|
* task list: the week's solved problems are already scheduled by the +3/+7
|
||||||
|
* ladder, and re-assigning them on Saturday would be massed practice.
|
||||||
*
|
*
|
||||||
* Label is `review` (the retired Actions system owned `gate`). Creation is
|
* Label is `review` (the retired Actions system owned `gate`). Creation is
|
||||||
* idempotent by week; deferred Hards never appear.
|
* idempotent by week; deferred Hards never appear in a sampled set.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
type ProblemRow,
|
type ProblemRow,
|
||||||
|
RESERVED,
|
||||||
type Result,
|
type Result,
|
||||||
SCHEDULE,
|
SCHEDULE,
|
||||||
campaignWeek,
|
campaignWeek,
|
||||||
isoWeek,
|
isoWeek,
|
||||||
|
lookupProblems,
|
||||||
rng,
|
rng,
|
||||||
sample,
|
sample,
|
||||||
streak,
|
streak,
|
||||||
@@ -32,7 +38,7 @@ function capitalized(difficulty: string): string {
|
|||||||
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
|
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unsolved problems of a topic in a set — never attempted, never drilled. */
|
/** Unsolved problems of a topic in a set — never attempted, drilled, or reserved. */
|
||||||
async function freshPool(db: D1Database, topic: number, set: string): Promise<ProblemRow[]> {
|
async function freshPool(db: D1Database, topic: number, set: string): Promise<ProblemRow[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -44,7 +50,7 @@ async function freshPool(db: D1Database, topic: number, set: string): Promise<Pr
|
|||||||
)
|
)
|
||||||
.bind(topic, set)
|
.bind(topic, set)
|
||||||
.all<ProblemRow>();
|
.all<ProblemRow>();
|
||||||
return results;
|
return results.filter((p) => !RESERVED.has(p.lc_number));
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateReport {
|
export interface CreateReport {
|
||||||
@@ -67,12 +73,24 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
|||||||
return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` };
|
return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Topics scheduled this week; review-only weeks (Sep 23 onward) sample
|
// The plan's fixed set wins; otherwise sample this week's topics, and in a
|
||||||
// across every topic that still has unsolved optional problems.
|
// 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) {
|
||||||
|
const { rows, missing } = await lookupProblems(db, planned);
|
||||||
|
if (missing.length) {
|
||||||
|
return {
|
||||||
|
created: false,
|
||||||
|
reason: `planned gate names problems outside the catalog: LC ${missing.join(", LC ")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
picks.push(...rows.map((p) => ({ p, fallback: false })));
|
||||||
|
} else {
|
||||||
let topics = Object.entries(SCHEDULE)
|
let topics = Object.entries(SCHEDULE)
|
||||||
.filter(([d]) => campaignWeek(d) === week)
|
.filter(([d]) => campaignWeek(d) === week)
|
||||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||||
.map(([, t]) => t);
|
.flatMap(([, day]) => day.topics ?? []);
|
||||||
if (topics.length === 0) {
|
if (topics.length === 0) {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -89,7 +107,6 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
|||||||
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
|
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
|
||||||
|
|
||||||
const random = rng(`gate-${iso}`);
|
const random = rng(`gate-${iso}`);
|
||||||
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
|
||||||
for (const topic of topics) {
|
for (const topic of topics) {
|
||||||
const optional = await freshPool(db, topic, "optional");
|
const optional = await freshPool(db, topic, "optional");
|
||||||
if (optional.length > 0) {
|
if (optional.length > 0) {
|
||||||
@@ -101,6 +118,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
|||||||
// Both pools exhausted: the topic is fully solved; nothing to quiz.
|
// Both pools exhausted: the topic is fully solved; nothing to quiz.
|
||||||
}
|
}
|
||||||
if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` };
|
if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` };
|
||||||
|
}
|
||||||
|
|
||||||
// Recap: everything attempted this week, plus streak — data, not tasks.
|
// Recap: everything attempted this week, plus streak — data, not tasks.
|
||||||
const monday = weekMonday(date);
|
const monday = weekMonday(date);
|
||||||
@@ -154,7 +172,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
|||||||
|
|
||||||
const milestone = await db
|
const milestone = await db
|
||||||
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
|
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
|
||||||
.bind(topics[0])
|
.bind(picks[0]!.p.topic_issue)
|
||||||
.first<{ milestone: number }>();
|
.first<{ milestone: number }>();
|
||||||
|
|
||||||
const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
|
const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { describe, expect, test } from "bun:test";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
INTERVAL,
|
INTERVAL,
|
||||||
|
RESERVED,
|
||||||
|
SCHEDULE,
|
||||||
STAGES,
|
STAGES,
|
||||||
WINDOWS,
|
WINDOWS,
|
||||||
addDays,
|
addDays,
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
sample,
|
sample,
|
||||||
TEMPERATURES,
|
TEMPERATURES,
|
||||||
temperatureOf,
|
temperatureOf,
|
||||||
|
topicDate,
|
||||||
weekdayOf,
|
weekdayOf,
|
||||||
workingDay,
|
workingDay,
|
||||||
} from "./srs.ts";
|
} from "./srs.ts";
|
||||||
@@ -125,6 +128,38 @@ describe("the rest day", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the plan", () => {
|
||||||
|
test("never books the rest day, and gates only on Saturday", () => {
|
||||||
|
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||||
|
expect(isRestDay(date)).toBe(false);
|
||||||
|
if (day.gate) expect(weekdayOf(date)).toBe(6);
|
||||||
|
if (day.topics) expect(weekdayOf(date)).toBeLessThan(6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a topic is taught once; a doubled day answers for both", () => {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
for (const day of Object.values(SCHEDULE)) {
|
||||||
|
for (const topic of day.topics ?? []) {
|
||||||
|
expect(seen.has(topic)).toBe(false);
|
||||||
|
seen.add(topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(topicDate(17)).toBe("2026-09-21");
|
||||||
|
expect(topicDate(18)).toBe("2026-09-21");
|
||||||
|
expect(topicDate(999)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 ?? [])]) {
|
||||||
|
expect(RESERVED.has(lc)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(RESERVED.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("the ladder", () => {
|
describe("the ladder", () => {
|
||||||
test("two rungs, named after the windows", () => {
|
test("two rungs, named after the windows", () => {
|
||||||
expect(STAGES).toEqual(["new", "+3", "+7", "retired"]);
|
expect(STAGES).toEqual(["new", "+3", "+7", "retired"]);
|
||||||
|
|||||||
+60
-5
@@ -126,12 +126,30 @@ export function prettyDate(date: string): string {
|
|||||||
|
|
||||||
// ── the schedule (bundled; humans edit apps/api/data/schedule.json) ───
|
// ── the schedule (bundled; humans edit apps/api/data/schedule.json) ───
|
||||||
|
|
||||||
export const SCHEDULE: Record<string, number> = scheduleJson;
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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. */
|
||||||
|
gate?: number[];
|
||||||
|
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
|
||||||
|
sweep?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
|
||||||
|
|
||||||
/** 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, t] of Object.entries(SCHEDULE)) {
|
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||||
if (t === topic) return date;
|
if (day.topics?.includes(topic)) return date;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -142,6 +160,16 @@ export function topicWeek(topic: number): number | undefined {
|
|||||||
return date === undefined ? undefined : campaignWeek(date);
|
return date === undefined ? undefined : campaignWeek(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export const RESERVED: ReadonlySet<number> = new Set(
|
||||||
|
Object.values(SCHEDULE).flatMap((day) => [...(day.gate ?? []), ...(day.problems ?? [])]),
|
||||||
|
);
|
||||||
|
|
||||||
// ── 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. */
|
||||||
@@ -248,6 +276,31 @@ export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow
|
|||||||
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalog rows for a list of numbers the plan names, in the plan's order,
|
||||||
|
* plus the numbers the catalog does not know — a planned problem without an
|
||||||
|
* issue can never be logged, so callers decide whether that is fatal.
|
||||||
|
*/
|
||||||
|
export async function lookupProblems(
|
||||||
|
db: D1Database,
|
||||||
|
lcs: number[],
|
||||||
|
): Promise<{ rows: ProblemRow[]; missing: number[] }> {
|
||||||
|
if (lcs.length === 0) return { rows: [], missing: [] };
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(`SELECT * FROM problems WHERE lc_number IN (${lcs.map(() => "?").join(",")})`)
|
||||||
|
.bind(...lcs)
|
||||||
|
.all<ProblemRow>();
|
||||||
|
const byNumber = new Map(results.map((p) => [p.lc_number, p]));
|
||||||
|
const rows: ProblemRow[] = [];
|
||||||
|
const missing: number[] = [];
|
||||||
|
for (const lc of lcs) {
|
||||||
|
const row = byNumber.get(lc);
|
||||||
|
if (row) rows.push(row);
|
||||||
|
else missing.push(lc);
|
||||||
|
}
|
||||||
|
return { rows, missing };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What "due" means, in one place: not retired, scheduled on/before ?1, and
|
* What "due" means, in one place: not retired, scheduled on/before ?1, and
|
||||||
* past its deferral if it has one. Every caller that asks "what is open on
|
* past its deferral if it has one. Every caller that asks "what is open on
|
||||||
@@ -358,8 +411,9 @@ export async function levelReviews(db: D1Database, date: string): Promise<number
|
|||||||
* scheduled in an earlier week (the current week's optional pool is reserved
|
* scheduled in an earlier week (the current week's optional pool is reserved
|
||||||
* for Saturday's gate) AND showing learning evidence: at least one of the
|
* for Saturday's gate) AND showing learning evidence: at least one of the
|
||||||
* topic's core problems has entered the ladder. A skipped learning day never
|
* topic's core problems has entered the ladder. A skipped learning day never
|
||||||
* feeds drills just because its calendar week lapsed. Never repeated,
|
* feeds drills just because its calendar week lapsed. Problems the plan names
|
||||||
* seeded by date. Boosted topics contribute up to 2 extra.
|
* outright (RESERVED) are never drawn. Never repeated, seeded by date.
|
||||||
|
* Boosted topics contribute up to 2 extra.
|
||||||
*/
|
*/
|
||||||
export async function pickDrills(
|
export async function pickDrills(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -382,6 +436,7 @@ export async function pickDrills(
|
|||||||
)
|
)
|
||||||
.all<ProblemRow & { boost: number }>();
|
.all<ProblemRow & { boost: number }>();
|
||||||
const pool = results.filter((p) => {
|
const pool = results.filter((p) => {
|
||||||
|
if (RESERVED.has(p.lc_number)) return false;
|
||||||
const w = topicWeek(p.topic_issue);
|
const w = topicWeek(p.topic_issue);
|
||||||
return w !== undefined && w < week;
|
return w !== undefined && w < week;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user