diff --git a/AGENTS.md b/AGENTS.md
index 1b5c95e..266d500 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,7 +2,7 @@
## Project Overview
-LeetCode interview-prep campaign (2026-08-17 → Nov 3: eight weeks of topics, then the Review-issue phase; the end is the last date in `apps/api/data/schedule.json` — `CAMPAIGN_END`/`CAMPAIGN_DAYS` in `apps/api/src/srs.ts` are derived from it, never declared) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC · `, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
+LeetCode interview-prep campaign (2026-08-17 → Dec 5: eight weeks of topics, the Review-issue phase, a two-week break, then a 30-day interview sprint from Fri Nov 6 (`SPRINT_START`); the end is the last date in `apps/api/data/schedule.json` — `CAMPAIGN_END`/`CAMPAIGN_DAYS`/`SPRINT_DAYS` in `apps/api/src/srs.ts` are derived from it, never declared) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC · `, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
## Architecture & Data Flow
@@ -40,7 +40,7 @@ flowchart LR
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first, then the problems closed on the finish day / earlier, with solution links at the tag — problems still open at the finish are left out). A release is found by its `` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs `/changelog` is ONE custom page, `apps/docs/pages/changelog.astro` (a NeonUI-style timeline: date left, a card per release right, no per-release subpages, so nothing of it enters the sidebar or prev/next), linked from the header by a `navigation.tabs` entry (beside `/appendix`'s). `apps/docs/lib/releases.ts` fetches the releases at build (`GITHUB_TOKEN` in `deploy.yml`; a failed fetch fails the build), dates each by its timever tag, not `published_at` (which the API cannot backdate), tags it `Topic` or `Review` from the release name, and lists the rows of its `### Closed …` tables — so the tag format and that table shape are the page's contract with `render()`. There is no changelog RSS feed.
- **Review issues re-parent problems; topics still own them.** The transfer phase's 20 `Review — ` issues (label `review`, milestone `Review — Transfer & Mocks`; not the Worker's `Review — Week N` gate issues) hold open optional/deferred problems as sub-issues in solve order, moved there with `replace_parent`. `apps/api/src/reviews.ts` is the ONE recovery of ownership — `readReviews()` (one GraphQL request per 50 `review` issues, cheap enough for the Worker's cron subrequest budget) reads each moved problem's topic off its last `parent_issue_removed` event naming a topic, `movedFromTopics()` groups them — shared by the Worker's catalog reconcile (a moved problem's D1 row keeps syncing its labels and `topic_issue`) and `apps/cli/topics.ts` (`readProblems()` returns a topic's sub-issues plus its moved problems, so the finished rule and existing release bodies do not change when a problem moves). A Review issue finishes by `completion()` in `topics.ts` — every sub-issue closed, any set — which `close-topics.ts` closes it on and `release-topics.ts` releases it on, exactly like a topic: timever of the last close, same `` marker (issue numbers never collide), body fields from the review's `**Scheduled:**`/`**Phase:**`/`**Load:**` lines.
-- **Review days are plan days.** `schedule.json` books each Review issue on its own date — Mon/Tue/Thu/Fri only, Oct 1 → Nov 3 — as `review: { issue, title, problems }` (solve order). The digest renders it as a linked "Today's review" section with the issue's day rules (`REVIEW_RULES` in `email.tsx`, one sentence for HTML and text), its problems join `RESERVED` so drills and sampled gates never draw them early, and `gate: []` skips a Saturday's checkpoint (Oct 3). The issue's sub-issues stay the truth: the catalog reconcile compares every booked review's title and problem list with GitHub and returns any mismatch as `reviewDrift` (also logged), so a re-arranged Review issue shows up the next sync instead of silently disagreeing with the email. Move a review by editing its `schedule.json` date AND the issue's `**Scheduled:**` line (the release body reads it).
+- **Review days are plan days.** `schedule.json` books each Review issue on its own date — Mon/Tue/Thu/Fri only; Reviews 1–6 ran Oct 1–9, Review 4 closed out Nov 2 after the break, and Reviews 7–20 run through the sprint (Nov 6 → Dec 1, Thanksgiving left unbooked), followed by two sweep days and a `gate: []` Saturday that marks the last day — as `review: { issue, title, problems }` (solve order). The digest renders it as a linked "Today's review" section with the issue's day rules (`REVIEW_RULES` in `email.tsx`, one sentence for HTML and text), its problems join `RESERVED` so drills and sampled gates never draw them early, and `gate: []` skips a Saturday's checkpoint (Oct 3, Dec 5). The issue's sub-issues stay the truth: the catalog reconcile compares every booked review's title and problem list with GitHub and returns any mismatch as `reviewDrift` (also logged), so a re-arranged Review issue shows up the next sync instead of silently disagreeing with the email. Move a review by editing its `schedule.json` date AND the issue's `**Scheduled:**` line (the release body reads it).
## Key Directories
@@ -113,10 +113,10 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
## Important Files
-- `apps/api/src/srs.ts` — SRS domain: the ladder (`new → +3 → +7 → retired`; fail resets to `+3`; first-ever log enters at `+3`), `WINDOWS`/`STAGES`/`TEMPERATURES`/`DIFFICULTIES`/`SETS` (the one vocabulary shared by the charts, the `work/` buckets, the review issues and the stats dashboard), ET date math, `workingDay()` (no scheduled date is ever a Sunday), `temperatureOf()` (days since a concept was last exercised → `hot`/`fresh`/`fading`/`cold`, bands derived from `WINDOWS` so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
+- `apps/api/src/srs.ts` — SRS domain: the ladder (`new → +3 → +7 → retired`; fail resets to `+3`; first-ever log enters at `+3`), `WINDOWS`/`STAGES`/`TEMPERATURES`/`DIFFICULTIES`/`SETS` (the one vocabulary shared by the charts, the `work/` buckets, the review issues and the stats dashboard), ET date math, `workingDay()` (no scheduled date is ever a Sunday), `temperatureOf()` (days since a concept was last exercised → `hot`/`fresh`/`fading`/`cold`, bands derived from `WINDOWS` so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (5 since the break, to drain its backlog within the sprint) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 5 per day, oldest first, instead of letting the due pile grow.
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`, and `OPENROUTER_API_KEY`, whose one copy is the repo's Actions secret (deploy.yml puts it before every deploy — rotate it there).
- `apps/api/src/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice.
-- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/) LeetCode Daily Digest` — 79 with the plan ending Nov 3; booking a later day in `schedule.json` lengthens it, and the heatmaps and `/api/stats` `heat` with it.
+- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/) LeetCode Daily Digest` and the progress line `Day N of · Week k` (Mon–Sun weeks, so the Friday start makes week 1 three days long) — 30 with the sprint running Nov 6 → Dec 5; before day 1 they count down (`(Sprint in N days)`). Booking a later day in `schedule.json` lengthens the sprint, and the whole-campaign heatmaps and `/api/stats` `heat` (still from `CAMPAIGN_START`) with it.
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `companies:` (`{ name, period, frequency }`: every company that asked the problem in the last six months, at the most recent of the `Thirty Days`/`Three Months`/`Six Months` windows it appears in and its frequency there, most recent first — the vocabulary is ONE module, `apps/docs/companies.ts`, shared by sync, the schema and both readers) is the exception that IS rewritten every run, since recency ages: it comes from the local DuckDB company-frequency database (`~/.local/share/duckdb/data/leetcode-company-frequency.db`, built by that project's `mise run lc:build`) via one `duckdb -readonly -json` query, and where the database or the `duckdb` CLI is missing (CI) the step is skipped and pages keep the key as it is. `ProblemBadges.astro` renders it as a TypeTable-style table under the badge row (company pill · asked within · frequency bar), each pill linking to `companyHref()` — the company's Appendix card, filed A–Z among the glossary terms (category `Company`, no definition, no hand-written table: derived from every page's `companies:`), whose Solved backlinks are the problems it asked, each with how recently. `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1///` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-)//`, with every problem page in the topic's `(problems)/` group as `_.mdx` (a page is found by its frontmatter title, wherever it sits, so a layout change is one sync of `rename`s). The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "page"`); the phase `meta.ts` is written once when missing (`Phase N: Name`, `order: N`) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing through an emptied `(problems)` and topic into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
diff --git a/README.md b/README.md
index 323d43b..bd07fc1 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
- Interview-prep fight camp: eight weeks of topics, then a review phase to Nov 3. Each phase is a milestone; each topic an issue; each problem a sub-issue.
+ Interview-prep fight camp: eight weeks of topics, a review phase, then a 30-day interview sprint to Dec 5. Each phase is a milestone; each topic an issue; each problem a sub-issue.
New topic Mon–Fri, gate on Saturday, Sunday off — every solve comes back at +3 and +7 days,
the two windows that keep the rest day clear (a Thursday solve's +3 slides to Monday).
Charts are live from the SRS Worker, never committed: pushing a solution or tapping the daily digest moves them within ~5 min.
diff --git a/apps/api/data/schedule.json b/apps/api/data/schedule.json
index 4970398..f91c168 100644
--- a/apps/api/data/schedule.json
+++ b/apps/api/data/schedule.json
@@ -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": [] }
}
diff --git a/apps/api/src/digest.ts b/apps/api/src/digest.ts
index c99eea6..3a78fa1 100644
--- a/apps/api/src/digest.ts
+++ b/apps/api/src/digest.ts
@@ -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 ` 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
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
export async function buildDigest(env: Env, date: string): Promise {
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,
};
diff --git a/apps/api/src/email.tsx b/apps/api/src/email.tsx
index c420691..62e83d5 100644
--- a/apps/api/src/email.tsx
+++ b/apps/api/src/email.tsx
@@ -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;
diff --git a/apps/api/src/srs.test.ts b/apps/api/src/srs.test.ts
index 7d0c51b..4d04c00 100644
--- a/apps/api/src/srs.test.ts
+++ b/apps/api/src/srs.test.ts
@@ -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
diff --git a/apps/api/src/srs.ts b/apps/api/src/srs.ts
index 32acac3..613ef8b 100644
--- a/apps/api/src/srs.ts
+++ b/apps/api/src/srs.ts
@@ -166,6 +166,30 @@ export const SCHEDULE: Record = 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