feat(api): rebook reviews into a 30-day interview sprint after the break

Review 4 closes out today (Nov 2); Reviews 7-20 move to Mon/Tue/Thu/Fri from
Fri Nov 6 to Tue Dec 1 (Thanksgiving unbooked), then two sweep days and a
gate: [] Saturday that makes Dec 5 day 30. The digest counts the sprint
(SPRINT_START, length derived from the plan's end) and counts down before it.
REVIEW_CAP 3 -> 5 so the ~110-review backlog drains inside the sprint.
This commit is contained in:
root committed 2026-11-02 12:56:55 -05:00
1 parent 7249da8bd6
commit 2835f618aa
7 files changed
+89 -31

No files matched your search

+18 -15
View File
@@ -26,21 +26,24 @@
"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] } }
"2026-11-02": { "review": { "issue": 228, "title": "Review 4 — Linear Structures", "problems": [496, 80, 901, 76, 224] } },
"2026-11-06": { "review": { "issue": 231, "title": "Review 7 — Nodal & Grid", "problems": [143, 86, 289] } },
"2026-11-09": { "review": { "issue": 232, "title": "Review 8 — Nodal & Grid", "problems": [61, 138] } },
"2026-11-10": { "review": { "issue": 233, "title": "Review 9 — Hierarchical", "problems": [100, 112, 530, 1046, 103, 124] } },
"2026-11-12": { "review": { "issue": 234, "title": "Review 10 — Hierarchical", "problems": [572, 543, 703, 105, 295] } },
"2026-11-13": { "review": { "issue": 235, "title": "Review 11 — Hierarchical", "problems": [101, 637, 173, 621, 502] } },
"2026-11-16": { "review": { "issue": 236, "title": "Review 12 — Hierarchical", "problems": [108, 129, 373, 648, 212] } },
"2026-11-17": { "review": { "issue": 237, "title": "Review 13 — Relational", "problems": [1971, 695, 547, 433] } },
"2026-11-19": { "review": { "issue": 238, "title": "Review 14 — Relational", "problems": [130, 261, 909, 1462] } },
"2026-11-20": { "review": { "issue": 239, "title": "Review 15 — Relational", "problems": [286, 1254, 721, 399] } },
"2026-11-23": { "review": { "issue": 240, "title": "Review 16 — Relational", "problems": [1091, 802, 269] } },
"2026-11-24": { "review": { "issue": 241, "title": "Review 17 — Decision Space", "problems": [746, 228, 17, 77, 213, 63, 122] } },
"2026-11-27": { "review": { "issue": 242, "title": "Review 18 — Decision Space", "problems": [22, 90, 139, 120, 45, 253, 72] } },
"2026-11-30": { "review": { "issue": 243, "title": "Review 19 — Decision Space", "problems": [79, 131, 91, 494, 763, 986, 920] } },
"2026-12-01": { "review": { "issue": 244, "title": "Review 20 — Decision Space", "problems": [300, 647, 97, 221, 918, 452, 52] } },
"2026-12-03": { "sweep": true },
"2026-12-04": { "sweep": true },
"2026-12-05": { "gate": [] }
}
+18 -6
View File
@@ -10,12 +10,13 @@
* either, so the rule holds by construction. The plan's own sections — a
* 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)
* labeled, linked ones. At most REVIEW_CAP (5)
* 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
* review past the cap a concrete future date, ≤ REVIEW_CAP per day, oldest
* first. Sunday is a two-line rest note.
* first. Sunday is a two-line rest note. Day N/M counts the interview sprint
* (SPRINT_START → the plan's last day); before it, the email counts down.
*
* 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
@@ -23,15 +24,15 @@
* and a re-run finds nothing left over the cap.
*/
import {
CAMPAIGN_DAYS,
MISS_WHERE,
type ProblemRow,
RESERVED,
REVIEW_CAP,
SCHEDULE,
SPRINT_DAYS,
SPRINT_START,
type ScheduledDay,
addDays,
campaignDay,
campaignWeek,
dueReviews,
isoWeek,
@@ -39,6 +40,8 @@ import {
lookupProblems,
pickDrills,
prettyDate,
sprintDay,
sprintWeek,
streak,
weekdayOf,
} from "./srs.ts";
@@ -107,9 +110,13 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
const drills =
rest || plan.sweep ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
const sprint = sprintDay(date);
const data: DigestData = {
day: prettyDate(date),
progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`,
progress:
sprint >= 1
? `Day ${sprint} of ${SPRINT_DAYS} · Week ${sprintWeek(date)}`
: `The ${SPRINT_DAYS}-day interview sprint starts ${prettyDate(SPRINT_START)}`,
streak: await streak(db, date),
rest,
topics: [],
@@ -206,8 +213,13 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
export async function buildDigest(env: Env, date: string): Promise<Digest> {
const data = await collectDigest(env, date);
const { html, text } = await renderDigest(data);
const sprint = sprintDay(date);
const until = 1 - sprint;
return {
subject: `(Day ${campaignDay(date)}/${CAMPAIGN_DAYS}) LeetCode Daily Digest`,
subject:
sprint >= 1
? `(Day ${sprint}/${SPRINT_DAYS}) LeetCode Daily Digest`
: `(Sprint in ${until} ${until === 1 ? "day" : "days"}) LeetCode Daily Digest`,
html,
text,
};
+1 -1
View File
@@ -108,7 +108,7 @@ export interface TopicSection {
export interface DigestData {
/** "Tuesday, August 25" */
day: string;
/** "Day 9 of 79 · Week 2" — the length is CAMPAIGN_DAYS, derived from the plan. */
/** "Day 9 of 30 · Week 2" of the sprint (SPRINT_DAYS, derived from the plan), or its countdown before day 1. */
progress: string;
streak: number;
rest: boolean;
+17 -2
View File
@@ -15,6 +15,12 @@ import {
addDays,
campaignDay,
campaignWeek,
CAMPAIGN_DAYS,
CAMPAIGN_END,
SPRINT_DAYS,
SPRINT_START,
sprintDay,
sprintWeek,
etDate,
etHour,
isRestDay,
@@ -28,8 +34,8 @@ import {
workingDay,
} from "./srs.ts";
/** Every ET date of the campaign, plus the deferred-Hard tail. */
const CALENDAR = Array.from({ length: 70 }, (_, i) => addDays("2026-08-17", i));
/** Every ET date of the campaign (sprint included), plus two weeks of ladder tail. */
const CALENDAR = Array.from({ length: CAMPAIGN_DAYS + 14 }, (_, i) => addDays("2026-08-17", i));
describe("DST-proof cron guard", () => {
test("digest fires only at ET hour 8 — EDT (UTC-4)", () => {
@@ -67,6 +73,15 @@ describe("date math (noon-UTC anchored)", () => {
expect(campaignWeek("2026-08-30")).toBe(2);
expect(campaignWeek("2026-08-31")).toBe(3);
});
test("sprint math: day 1 is SPRINT_START, the last is the plan's end, weeks run Mon–Sun", () => {
expect(sprintDay(SPRINT_START)).toBe(1);
expect(sprintDay(addDays(SPRINT_START, -1))).toBe(0); // countdown, never "Day 0 of N"
expect(sprintDay(CAMPAIGN_END)).toBe(SPRINT_DAYS);
expect(sprintWeek("2026-11-06")).toBe(1); // Friday start
expect(sprintWeek("2026-11-08")).toBe(1); // its Sunday closes week 1
expect(sprintWeek("2026-11-09")).toBe(2);
expect(sprintWeek(CAMPAIGN_END)).toBe(5);
});
test("iso week is stable across a week", () => {
expect(isoWeek("2026-08-24")).toBe(isoWeek("2026-08-29"));
expect(isoWeek("2026-08-30")).toBe(isoWeek("2026-08-24")); // Sun ends the ISO week
+30 -2
View File
@@ -166,6 +166,30 @@ export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
export const CAMPAIGN_END = Object.keys(SCHEDULE).sort().at(-1)!;
export const CAMPAIGN_DAYS = daysBetween(CAMPAIGN_START, CAMPAIGN_END) + 1;
/**
* Day 1 of the interview sprint: the plan restarted here after two weeks off,
* with every open Review issue rebooked between this Friday and CAMPAIGN_END.
* Only the start is declared; the length is derived like CAMPAIGN_DAYS, so
* booking a later day lengthens both. The digest counts days and weeks from
* here; the heatmaps and /api/stats keep the whole campaign.
*/
export const SPRINT_START = "2026-11-06";
export const SPRINT_DAYS = daysBetween(SPRINT_START, CAMPAIGN_END) + 1;
/** 1-based sprint day; zero or negative before SPRINT_START. */
export function sprintDay(date: string): number {
return daysBetween(SPRINT_START, date) + 1;
}
/**
* 1-based sprint week, Mon–Sun like the campaign's, so the Friday start makes
* week 1 three days long and every later week a whole one.
*/
export function sprintWeek(date: string): number {
const monday = addDays(SPRINT_START, -((weekdayOf(SPRINT_START) + 6) % 7));
return Math.floor(daysBetween(monday, date) / 7) + 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)) {
@@ -413,8 +437,12 @@ export async function dueToday(db: D1Database, date: string): Promise<{ date: st
};
}
/** Reviews surfaced per day; the digest levels everything past this forward. */
export const REVIEW_CAP = 3;
/**
* Reviews surfaced per day; the digest levels everything past this forward.
* 5, not 3, since the two-week break: at 3 a day the ~110-review backlog would
* outlast the sprint by a fortnight.
*/
export const REVIEW_CAP = 5;
/**
* Load-level an overloaded review queue: everything due beyond today's