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