feat(srs): switch ladder to +3/+7 and enforce rest‑day logic

This commit is contained in:
Prad Nukala
2026-08-31 10:41:45 -04:00
parent 7e46fceb1c
commit 38fbb18f32
12 changed files with 140 additions and 161 deletions
+18 -11
View File
@@ -27,7 +27,9 @@ flowchart LR
- **Reconcile, don't react.** `close-solved.ts`, `close-topics.ts`, and the Worker's catalog sync recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional. `close-solved` posts its *whole* implemented set to `/admin/solved`, not just the issues it closed this run. - **Reconcile, don't react.** `close-solved.ts`, `close-topics.ts`, and the Worker's catalog sync recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional. `close-solved` posts its *whole* implemented set to `/admin/solved`, not just the issues it closed this run.
- **Solving has three doors, one write path.** A digest one-tap, a `/done` comment, and a solution landing in `work/` all end in `logAttempt()`. Closing an issue is *not* one of them: the catalog reconcile never reads issue state, so a `work/` push that skipped the email would otherwise leave `stage='new'` — invisible to the charts, the digest's solved ticks, and the drill/gate pools. Hence `/admin/solved` (`source='commit'`, first solve only, no index needed for idempotency: it refuses any problem past stage `new`). `close-topics` needs no such call — D1 stores no topic completion, only problem rows. - **Solving has three doors, one write path.** A digest one-tap, a `/done` comment, and a solution landing in `work/` all end in `logAttempt()`. Closing an issue is *not* one of them: the catalog reconcile never reads issue state, so a `work/` push that skipped the email would otherwise leave `stage='new'` — invisible to the charts, the digest's solved ticks, and the drill/gate pools. Hence `/admin/solved` (`source='commit'`, first solve only, no index needed for idempotency: it refuses any problem past stage `new`). `close-topics` needs no such call — D1 stores no topic completion, only problem rows.
- **Review drills are stateless and live in Actions, not the Worker.** `spaced-repetition.yml` asks GitHub one question — which `problem` issues closed 3 and 7 days ago (ET) — and writes today's `Spaced Repetition — <Month D, YYYY>` issue with direct LeetCode links, capped at `WINDOW_CAP` (3) per window with the overflow listed as optional. It reads no D1, stores nothing, and is keyed by the ET date in its title, so a re-run rewrites that one body and a backfilled close simply shows up in the next window it belongs to. This is deliberately independent of the Worker's digest/gate machinery: nothing to migrate, nothing to drift. - **The rest day is structural, not cosmetic.** Sunday is never booked: topics run MonFri (`apps/api/data/schedule.json`), the gate is Saturday, and the ladder's windows are 3 and 7 precisely because those return a solve to a working day. Every scheduled date — ladder review, levelled overflow, deferred-Hard release, review-issue window — comes out of `workingDay()` in `apps/api/src/srs.ts`, which slides the one exception (a Thursday solve's `+3`) forward to Monday. Forward, never back: a review may slip later than its interval, never shorten it. Never mint a scheduled date with bare `addDays()`, and never "fix" a Sunday landing downstream — the digest's Sunday rest branch is a courtesy, not the mechanism. `apps/api/src/srs.test.ts` sweeps the campaign calendar to keep this honest.
- **Review drills are stateless and live in Actions, not the Worker.** `spaced-repetition.yml` asks GitHub one question — which `problem` issues closed 3 and 7 days ago (ET), plus 4 days ago when that window's Sunday slid onto today — and writes today's `Spaced Repetition — <Month D, YYYY>` issue with direct LeetCode links, capped at `WINDOW_CAP` (3) per window with the overflow listed as optional. It reads no D1, stores nothing, skips Sunday entirely, and is keyed by the ET date in its title, so a re-run rewrites that one body and a backfilled close simply shows up in the next window it belongs to. It shares `WINDOWS`, `etDate`, `addDays`, `isRestDay` and `workingDay` with the Worker (`apps/api/src/srs.ts` imports cleanly into Bun scripts) so the two cannot drift; everything else about it stays independent of the digest/gate machinery.
- **The picker owns the local review loop; the buckets are its state.** `work/` holds three buckets (`1` = first solve, `3`/`7` = blind re-solves, named after the ladder rungs) and `bun run pick` has three sections — Tab cycles them. Pools are reconciled from the filesystem plus one `git log` (`apps/cli/work.ts`): a first solve dates from the ET date of the commit that added its `work/1` file — the same push that closes the problem issue and starts the +3/+7 windows — and a window stops being owed the moment its bucket holds a file. Uncapped, unlike the review issue's `WINDOW_CAP`: that issue is one day's assignment, the picker is everything still owed. An empty scaffold under `work/1` owes nothing (it is unsolved), which is why `isImplemented()` lives in `apps/cli/source.ts` and is shared with `close-solved.ts`. leetcode-cli reads its output directory from `~/.leetcode/workspaces/<active>/config.json` and nowhere else — no flag, no env var — so `pick.ts` MERGES `workDir` into that file around the child (never replays saved bytes: the CLI owns the file too) and leaves it pointed at `work/1`, then treats the scaffold's existence as the acceptance test, since leetcode-cli exits 0 even when an expired session made it write nothing. Nothing here touches D1: a re-solve is graded by `bun run test`/`submit`, and D1 still learns about reviews from the digest tap or a `/done` comment.
- **The README charts are live, never committed.** They are Worker endpoints reading D1 per request (`Cache-Control: max-age=300`, honored by GitHub Camo), so a solved push moves them within ~5 minutes with no commit and no workflow of their own. - **The README charts are live, never committed.** They are Worker endpoints reading D1 per request (`Cache-Control: max-age=300`, honored by GitHub Camo), so a solved push moves them within ~5 minutes with no commit and no workflow of their own.
- **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). `Target Date` is the *only* Project field the Worker mirrors (`apps/api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written. SRS stage and first-attempt result live **only** in D1 — do not re-add them as Project fields. The Project's `Set`/`Difficulty` single-selects are a projection of the issue labels, reconciled by `bun run sync-project-fields` — a best-effort final step of `close-solved.yml`, never the Worker. - **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). `Target Date` is the *only* Project field the Worker mirrors (`apps/api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written. SRS stage and first-attempt result live **only** in D1 — do not re-add them as Project fields. The Project's `Set`/`Difficulty` single-selects are a projection of the issue labels, reconciled by `bun run sync-project-fields` — a best-effort final step of `close-solved.yml`, never the Worker.
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind). - **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
@@ -38,10 +40,10 @@ flowchart LR
| Path | Purpose | | Path | Purpose |
|---|---| |---|---|
| `work/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/Easy/Array/1.two-sum.py`. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape | | `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here | | `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `islands/`, `public/`). `content/(<category>)/<num>-<slug>.mdx` generated by sync | | `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `islands/`, `public/`). `content/(<category>)/<num>-<slug>.mdx` generated by sync |
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (day → topic issue, human-edited, bundled at deploy), `migrations/`, `scripts/import-srs.ts` | | `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (day → topic issue, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
| `.github/workflows/` | deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) | | `.github/workflows/` | deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
## Development Commands ## Development Commands
@@ -49,14 +51,16 @@ flowchart LR
All from the repo root (a Bun workspace over `apps/*`): All from the repo root (a Bun workspace over `apps/*`):
```sh ```sh
bun run pick # scaffold a solution via leetcode-cli (fuzzy picker; hides problems bun run pick # scaffold a solution via leetcode-cli (fuzzy picker; Tab cycles the
# already under work/, caches index+descriptions in # sections: new problems → work/1, then the owed 3-day and 7-day
# re-solves → work/3, work/7. Each section hides what its own bucket
# already holds; index+descriptions cached in
# ~/.local/share/leetcode/data.db — see apps/cli/db.ts) # ~/.local/share/leetcode/data.db — see apps/cli/db.ts)
bun run test # run ONE solution against LeetCode's judge (not a test suite; bun run test # run ONE solution against LeetCode's judge (not a test suite;
# most recently tested first, stamped in the db on exit 0) # most recently tested first, stamped in the db on exit 0)
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
# hidden; last_submitted stamped in the db on exit 0) # hidden; last_submitted stamped in the db on exit 0)
bun run sync # work/ → apps/docs/content/ pages bun run sync # work/1 → apps/docs/content/ pages (re-solves are never published)
bun run dev|build # Blume docs site (runs in apps/docs/) bun run dev|build # Blume docs site (runs in apps/docs/)
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1) bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
bun run close-topics -- --dry-run bun run close-topics -- --dry-run
@@ -66,9 +70,11 @@ SRS_API=http://localhost:8787 SRS_ADMIN_KEY=$LINK_KEY bun run close-solved -- --
# Project Set/Difficulty from the issue labels (needs a `project`-scoped token; # Project Set/Difficulty from the issue labels (needs a `project`-scoped token;
# runs as the last step of close-solved.yml in CI): # runs as the last step of close-solved.yml in CI):
GH_TOKEN=$PROJECT_PAT bun run sync-project-fields -- --dry-run GH_TOKEN=$PROJECT_PAT bun run sync-project-fields -- --dry-run
# today's review issue from the +3d/+7d closes (--date backfills a missed day): # today's review issue from the +3d/+7d closes (--date backfills a missed day;
# a Sunday date writes nothing — the rest day has no assignment):
bun run spaced-repetition -- --dry-run --date=2026-08-31 bun run spaced-repetition -- --dry-run --date=2026-08-31
bun run api:dev # wrangler dev on :8787 (local D1); api:test = DST guard tests bun run api:dev # wrangler dev on :8787 (local D1)
bun run api:test # DST guards + the rest-day calendar sweep
bun run api:deploy # deploy the Worker by hand (CI also deploys on main pushes) bun run api:deploy # deploy the Worker by hand (CI also deploys on main pushes)
curl -X POST -H "Authorization: Bearer $LINK_KEY" \ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
"localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing "localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing
@@ -82,18 +88,19 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
- **Dry-run everything:** reconcilers take `--dry-run`/`DRY_RUN=1`; Worker admin routes take `?dry=1&force=1&date=` (the `SRS_TODAY` equivalent) and `wrangler dev` runs against local D1 — exercise logic there before touching production state. - **Dry-run everything:** reconcilers take `--dry-run`/`DRY_RUN=1`; Worker admin routes take `?dry=1&force=1&date=` (the `SRS_TODAY` equivalent) and `wrangler dev` runs against local D1 — exercise logic there before touching production state.
- **Dates:** always ET calendar strings (`YYYY-MM-DD`), arithmetic anchored at noon UTC (`atNoon` in `apps/api/src/srs.ts`) to dodge DST. Never `new Date()` math directly. - **Dates:** always ET calendar strings (`YYYY-MM-DD`), arithmetic anchored at noon UTC (`atNoon` in `apps/api/src/srs.ts`) to dodge DST. Never `new Date()` math directly.
- **Style:** section-divider comments (`// ── name ───`), file-top doc comments explaining the *why* and invariants, 2-space JSON with trailing newline, `Map` for dynamic keys / `Record` for static tables, no tiny one-expression wrapper functions. - **Style:** section-divider comments (`// ── name ───`), file-top doc comments explaining the *why* and invariants, 2-space JSON with trailing newline, `Map` for dynamic keys / `Record` for static tables, no tiny one-expression wrapper functions.
- **Known intentional duplication:** `close-solved.ts` re-implements header stripping instead of importing from `sync.ts` because `sync.ts` runs its pipeline on import. Don't "deduplicate" it. - **Known intentional duplication:** `sync.ts` keeps its own header split instead of sharing `apps/cli/source.ts`, because `sync.ts` runs its whole pipeline on import and so cannot be imported from. Don't "deduplicate" it. `close-solved.ts` and `work.ts` do share it — the stub test decides both "close the issue" and "owes a review", and the two must never disagree.
## Important Files ## Important Files
- `apps/api/src/srs.ts` — SRS domain: ladder (`new → +2 → +5 → +10 → retired`; fail resets to `+2`; first-ever log enters at `+2`), ET date math, 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 date, ≤ 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` (the one vocabulary shared by the charts, the `work/<n>` buckets and the review issues), ET date math, `workingDay()` (no scheduled date is ever a Sunday), 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/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`. - `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`.
- `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/56) LeetCode Daily Digest`. - `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/56) LeetCode Daily Digest`.
- `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/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: only `## Solution` onward is script-owned on existing pages; human prose is never touched; never hand-write solution pages or edit inside `## Solution`. - `apps/cli/sync.ts` — work→docs contract: only `## Solution` onward is script-owned on existing pages; human prose is never touched; never hand-write solution pages or edit inside `## Solution`.
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `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.
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the docs `/progress` page fetches `/api/stats` client-side (`apps/docs/islands/ProgressDashboard.tsx`). - `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the docs `/progress` page fetches `/api/stats` client-side (`apps/docs/islands/ProgressDashboard.tsx`).
- `apps/docs/blume.config.ts` — site base `/leetcode`; `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`). - `apps/docs/blume.config.ts` — site base `/leetcode`; `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`).
- Old `.github/srs/` JSON state and the `srs-*` Actions are RETIRED — do not resurrect; `apps/api/scripts/import-srs.ts` documents the migration. - Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
## Runtime/Tooling Preferences ## Runtime/Tooling Preferences
+3 -1
View File
@@ -10,6 +10,8 @@
<p align="center"> <p align="center">
<sub>8-week interview-prep fight camp. Each phase is a milestone; each topic an issue; each problem a sub-issue.<br> <sub>8-week interview-prep fight camp. Each phase is a milestone; each topic an issue; each problem a sub-issue.<br>
New topic MonFri, gate on Saturday, Sunday off — every solve comes back at <strong>+3</strong> and <strong>+7</strong> days,
the two windows that keep the rest day clear (a Thursday solve's +3 slides to Monday).<br>
Charts are live from the SRS Worker, never committed: pushing a solution or tapping the daily digest moves them within ~5 min.</sub> Charts are live from the SRS Worker, never committed: pushing a solution or tapping the daily digest moves them within ~5 min.</sub>
</p> </p>
@@ -18,7 +20,7 @@
<td colspan="2" align="center"><img alt="phase progress" width="100%" src="https://srs-api.prdlk.workers.dev/chart/progress.svg" /></td> <td colspan="2" align="center"><img alt="phase progress" width="100%" src="https://srs-api.prdlk.workers.dev/chart/progress.svg" /></td>
</tr> </tr>
<tr> <tr>
<td align="center" width="53%"><img alt="SRS ladder" width="100%" src="https://srs-api.prdlk.workers.dev/chart/ladder.svg" /></td> <td align="center" width="53%"><img alt="SRS ladder — new / +3 / +7 / retired" width="100%" src="https://srs-api.prdlk.workers.dev/chart/ladder.svg" /></td>
<td align="center" width="47%"><img alt="attempt heatmap" width="100%" src="https://srs-api.prdlk.workers.dev/chart/heatmap.svg" /></td> <td align="center" width="47%"><img alt="attempt heatmap" width="100%" src="https://srs-api.prdlk.workers.dev/chart/heatmap.svg" /></td>
</tr> </tr>
</table> </table>
+23 -9
View File
@@ -26,10 +26,23 @@ Live at `https://srs-api.prdlk.workers.dev`.
## The ladder ## The ladder
`new → +2 → +5 → +10 → retired`; pass advances, fail resets to `+2`. `new → +3 → +7 → retired`; pass advances, fail resets to `+3`. A problem's
A problem's first-ever log enters at `+2` regardless of result. Stage names first-ever log enters at `+3` regardless of result. Stage names the NEXT
the NEXT review's interval. All dates are ET calendar strings anchored at review's interval, and the two rungs are the same numbers as the `work/3` and
noon UTC (`src/srs.ts`) — never raw `Date` math. `work/7` buckets `bun run pick` scaffolds re-solves into.
**Sunday is never booked.** Topics run MonFri, the gate is Saturday, and 3/7
are chosen so a solve returns on a working day: only a Thursday solve's `+3`
would land on the rest day, and `workingDay()` in `src/srs.ts` slides it to
Monday. Every scheduled date in the Worker — ladder reviews, levelled
overflow, deferred-Hard release dates — is minted by that one function, so the
rest day cannot be booked and then swallowed by the digest's Sunday branch.
Reviews may slip later, never earlier: pulling one back to Saturday would
shorten the interval it exists to test. `src/srs.test.ts` sweeps the whole
campaign calendar to prove it.
All dates are ET calendar strings anchored at noon UTC (`src/srs.ts`) — never
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
@@ -83,13 +96,14 @@ bunx wrangler d1 migrations apply srs --local
bun run dev # wrangler dev on :8787, local D1 bun run dev # wrangler dev on :8787, local D1
curl -X POST -H "Authorization: Bearer $LINK_KEY" \ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
"localhost:8787/admin/digest?dry=1&date=2026-08-31" # prints HTML, sends nothing "localhost:8787/admin/digest?dry=1&date=2026-08-31" # prints HTML, sends nothing
bun test src # DST guard + date math bun test src # DST guards, date math, the rest-day sweep
``` ```
`?date=` on admin routes is the `SRS_TODAY` equivalent. The one-shot `?date=` on admin routes is the `SRS_TODAY` equivalent. Migrations are
migration from the retired `.github/srs/srs.json` lives at append-only and applied in order; `0002_ladder_3_7.sql` is the +2/+5/+10 →
`scripts/import-srs.ts` (`--remote` for production D1); it is re-runnable — `+3`/`+7` rebuild, which also lifts every date that was sitting on a Sunday.
import-sourced attempts are wiped and re-inserted. The one-shot importer for the retired `.github/srs/srs.json` is gone — that
state no longer exists, and git history is its record.
## Deploy ## Deploy
-94
View File
@@ -1,94 +0,0 @@
#!/usr/bin/env bun
/**
* One-shot migration: .github/srs/srs.json → D1.
*
* Run AFTER the catalog reconcile has filled `problems` (the import only
* overlays SRS-owned fields — stage, next_review, defer_until — and inserts
* attempts/topic counters). Re-runnable without duplicates: import-sourced
* attempts are wiped and re-inserted, everything else upserts.
*
* bun apps/api/scripts/import-srs.ts # local D1 (wrangler dev state)
* bun apps/api/scripts/import-srs.ts --remote # production D1
*
* Prints row counts; verify them against srs.json before deleting anything.
*/
import { $ } from "bun";
import { join } from "node:path";
const ROOT = join(import.meta.dir, "..", "..");
const API = join(ROOT, "api");
const REMOTE = process.argv.includes("--remote");
interface Attempt {
date: string;
kind: string;
result: string;
}
interface Problem {
issue: number;
topic: number;
difficulty: string;
set: string;
solved_on?: string;
stage: string;
next_review?: string;
defer_until?: string;
history: Attempt[];
}
interface State {
problems: Record<string, Problem>;
topics: Record<string, { misses: number; boost: boolean }>;
drill_pool_used: number[];
}
const state: State = JSON.parse(
await Bun.file(join(ROOT, ".github", "srs", "srs.json")).text(),
);
const q = (v: string | null | undefined) => (v == null ? "NULL" : `'${v}'`);
const lines: string[] = ["DELETE FROM attempts WHERE source = 'import';"];
let attempts = 0;
for (const [lc, p] of Object.entries(state.problems)) {
lines.push(
`UPDATE problems SET stage = ${q(p.stage)}, next_review = ${q(p.next_review ?? null)}, ` +
`defer_until = ${q(p.defer_until ?? null)} WHERE lc_number = ${Number(lc)};`,
);
for (const a of p.history) {
lines.push(
`INSERT INTO attempts (lc_number, date, kind, result, source) ` +
`VALUES (${Number(lc)}, ${q(a.date)}, ${q(a.kind)}, ${q(a.result)}, 'import');`,
);
attempts++;
}
}
for (const [topic, t] of Object.entries(state.topics)) {
lines.push(
`UPDATE topics SET misses = ${t.misses}, boost = ${t.boost ? 1 : 0} WHERE issue = ${Number(topic)};`,
);
}
for (const lc of state.drill_pool_used) {
lines.push(`INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (${lc});`);
}
const sqlPath = join(API, "migrations", ".import.sql");
await Bun.write(sqlPath, lines.join("\n") + "\n");
const flag = REMOTE ? "--remote" : "--local";
await $`bunx wrangler d1 execute srs ${flag} --file ${sqlPath}`.cwd(API);
await $`rm ${sqlPath}`;
const counts =
await $`bunx wrangler d1 execute srs ${flag} --json --command ${"SELECT (SELECT COUNT(*) FROM problems) AS problems, (SELECT COUNT(*) FROM problems WHERE stage != 'new') AS laddered, (SELECT COUNT(*) FROM problems WHERE defer_until IS NOT NULL) AS deferred, (SELECT COUNT(*) FROM attempts WHERE source='import') AS imported_attempts, (SELECT COUNT(*) FROM topics) AS topics, (SELECT COUNT(*) FROM drill_pool_used) AS drills_used"}`
.cwd(API)
.json();
const expected = {
json_problems: Object.keys(state.problems).length,
json_attempts: attempts,
json_laddered: Object.values(state.problems).filter((p) => p.stage !== "new").length,
json_deferred: Object.values(state.problems).filter((p) => p.defer_until).length,
json_topics: Object.keys(state.topics).length,
json_drills_used: state.drill_pool_used.length,
};
console.log("expected from srs.json:", JSON.stringify(expected));
console.log("in D1:", JSON.stringify(counts[0]?.results?.[0] ?? counts));
+6 -5
View File
@@ -10,7 +10,7 @@
* so relabelling a problem is enough to move it and titles stay readable. * so relabelling a problem is enough to move it and titles stay readable.
* The `problem` label is what marks a sub-issue as curriculum. * The `problem` label is what marks a sub-issue as curriculum.
*/ */
import { addDays } from "./srs.ts"; import { workingDay } from "./srs.ts";
import type { GitHub } from "./github.ts"; import type { GitHub } from "./github.ts";
const TITLE_RE = /^LC (\d+) · (.+)$/; const TITLE_RE = /^LC (\d+) · (.+)$/;
@@ -18,7 +18,7 @@ const TITLE_RE = /^LC (\d+) · (.+)$/;
const DIFFICULTIES = ["easy", "medium", "hard"] as const; const DIFFICULTIES = ["easy", "medium", "hard"] as const;
const SETS = ["core", "optional", "deferred"] as const; const SETS = ["core", "optional", "deferred"] as const;
/** Deferred Hards enter the queue from this date, two per day. */ /** Deferred Hards enter the queue from this date, two per working day. */
const DEFER_FROM = "2026-09-28"; const DEFER_FROM = "2026-09-28";
export interface ReconcileReport { export interface ReconcileReport {
@@ -96,9 +96,10 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
continue; continue;
} }
const lc = Number(m[1]); const lc = Number(m[1]);
// Two deferred Hards per day from DEFER_FROM, in catalog walk order — // Two deferred Hards per WORKING day from DEFER_FROM, in catalog walk
// applied only when the row is first created (SRS owns it afterwards). // order — applied only when the row is first created (SRS owns it
const defer = set === "deferred" ? addDays(DEFER_FROM, Math.floor(deferredSeen / 2)) : null; // afterwards). Sunday is skipped like every other scheduled date.
const defer = set === "deferred" ? workingDay(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
if (set === "deferred") deferredSeen++; if (set === "deferred") deferredSeen++;
statements.push( statements.push(
db db
+4 -2
View File
@@ -14,6 +14,7 @@
* badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode * badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode
* green ramp. Every function renders on a zero-row DB. * green ramp. Every function renders on a zero-row DB.
*/ */
import { STAGES } from "./srs.ts";
import { Canvas, textWidth } from "./png.ts"; import { Canvas, textWidth } from "./png.ts";
// D1Database comes from the generated worker-configuration.d.ts runtime types. // D1Database comes from the generated worker-configuration.d.ts runtime types.
@@ -43,8 +44,6 @@ const PHASES = [
{ milestone: 5, name: "V — Decision Space" }, { milestone: 5, name: "V — Decision Space" },
]; ];
const STAGES = ["new", "+2", "+5", "+10", "retired"];
// ── svg helpers ────────────────────────────────────────────────── // ── svg helpers ──────────────────────────────────────────────────
/** Opening tag plus the rounded dark card every chart starts with. */ /** Opening tag plus the rounded dark card every chart starts with. */
@@ -159,6 +158,9 @@ export async function progressChart(db: D1Database): Promise<string> {
// ── ladder: bar per stage ──────────────────────────────────────── // ── ladder: bar per stage ────────────────────────────────────────
// One bar per rung, labelled and ordered by srs.ts's STAGES, so changing the
// ladder can never leave a stale bar here: the slot width is derived from the
// rung count, only the bar width inside a slot is fixed.
export async function ladderChart(db: D1Database): Promise<string> { export async function ladderChart(db: D1Database): Promise<string> {
const { results } = await db const { results } = await db
.prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`) .prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`)
+6 -6
View File
@@ -62,12 +62,12 @@ const DIFFICULTY: Record<string, { label: string; color: string }> = {
hard: { label: "Hard", color: RED }, hard: { label: "Hard", color: RED },
}; };
// The SRS ladder in plain English — "+5" means the last look was 5 days back. // The SRS ladder in plain English — stage "+3" means the last clean look was
// three days back, so that is what the reader is told.
const LAST_SEEN: Record<string, string> = { const LAST_SEEN: Record<string, string> = {
new: "first look", new: "first look",
"+2": "2 days ago", "+3": "3 days ago",
"+5": "5 days ago", "+7": "7 days ago",
"+10": "10 days ago",
retired: "retired", retired: "retired",
}; };
@@ -302,7 +302,7 @@ function DigestEmail({ data }: { data: DigestData }) {
<Text style={{ color: MUTED, fontSize: "13px", margin: "8px 0 0" }}> <Text style={{ color: MUTED, fontSize: "13px", margin: "8px 0 0" }}>
{data.progress} {data.progress}
{data.rest {data.rest
? " · nothing is due today, and anything overdue waits for Monday." ? " · the ladder never books a Sunday, so nothing is due — anything still open waits for Monday."
: data.streak === 0 : data.streak === 0
? " · no streak going yet — today is a good day to start one." ? " · no streak going yet — today is a good day to start one."
: ` · 🔥 ${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.`}
@@ -403,7 +403,7 @@ function plainDigest(data: DigestData): string {
data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`, data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`,
data.progress + data.progress +
(data.rest (data.rest
? " · nothing is due today, and anything overdue waits for Monday." ? " · the ladder never books a Sunday, so nothing is due — anything still open waits for Monday."
: data.streak === 0 : data.streak === 0
? " · no streak going yet — today is a good day to start one." ? " · no streak going yet — today is a good day to start one."
: ` · ${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.`),
+1 -1
View File
@@ -5,7 +5,7 @@
* The gate half is a topic-blind quiz — one unsolved optional problem per * The gate half is a topic-blind quiz — one unsolved optional problem per
* topic scheduled this week (which is why daily drills only draw from * topic scheduled this week (which is why daily drills only draw from
* earlier weeks). The recap half is generated data, deliberately NOT a task * earlier weeks). The recap half is generated data, deliberately NOT a task
* list: the week's solved problems are already scheduled by the +2 ladder, * list: the week's solved problems are already scheduled by the +3/+7 ladder,
* and re-assigning them on Saturday would be massed practice. * 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
+3 -3
View File
@@ -44,7 +44,7 @@ async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Pro
try { try {
await gh.closeIssue( await gh.closeIssue(
outcome.issue, outcome.issue,
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +2. Logged by the SRS Worker.`, `First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +3. Logged by the SRS Worker.`,
); );
} catch (err) { } catch (err) {
console.warn(`close #${outcome.issue}: ${err}`); console.warn(`close #${outcome.issue}: ${err}`);
@@ -96,7 +96,7 @@ async function handleTap(request: Request, env: Env, ctx: ExecutionContext): Pro
} }
ctx.waitUntil(mirrorOutcome(env, outcome, "email")); ctx.waitUntil(mirrorOutcome(env, outcome, "email"));
return page( return page(
result === "pass" ? "Logged ✅" : "Logged — back to +2", result === "pass" ? "Logged ✅" : "Logged — back to +3",
outcomeLine(outcome), outcomeLine(outcome),
); );
} }
@@ -232,7 +232,7 @@ async function handleSolved(request: Request, env: Env, date: string): Promise<R
const p = await getProblem(env.DB, lc); const p = await getProblem(env.DB, lc);
if (!p) skipped.push(`LC ${lc} is not in the curriculum`); if (!p) skipped.push(`LC ${lc} is not in the curriculum`);
else if (p.stage !== "new") skipped.push(`LC ${lc}: already at stage ${p.stage}`); else if (p.stage !== "new") skipped.push(`LC ${lc}: already at stage ${p.stage}`);
else logged.push(`LC ${lc}: would enter the ladder at +2`); else logged.push(`LC ${lc}: would enter the ladder at +3`);
continue; continue;
} }
const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit" }); const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit" });
+73 -18
View File
@@ -1,12 +1,19 @@
/** /**
* SRS domain: ET dates, the interval ladder, deterministic sampling, and the * SRS domain: ET dates, the work week, the interval ladder, deterministic
* one write path for attempts — ported from scripts/srs.ts, re-homed on D1. * sampling, and the one write path for attempts.
* *
* D1 is the single source of truth. The stage names the review a problem must * D1 is the single source of truth. The stage names the review a problem must
* pass NEXT (`+2` = due 2 days after last clean solve). Passing advances * pass NEXT (`+3` = due 3 working days after the last clean solve). Passing
* new → +2 → +5 → +10 → retired; any failure resets to +2. A problem's * advances new → +3 → +7 → retired; any failure resets to +3. A problem's
* FIRST-ever log enters the ladder at +2 regardless of result: a pass earns * FIRST-ever log enters the ladder at +3 regardless of result: a pass earns a
* a +2 review, a fail must be re-solved just as soon. * 3-day review, a fail must be re-solved just as soon. Two rungs, 3 and 7 —
* the same numbers as the `work/3` and `work/7` buckets a re-solve is
* scaffolded into, and as the review-issue windows in
* apps/cli/spaced-repetition.ts.
*
* Sunday is off, structurally: every scheduled date comes out of
* workingDay(), which never returns one. Nothing "falls on" the rest day and
* gets swallowed or carried — it is simply never booked there.
* *
* All dates are America/New_York calendar strings (YYYY-MM-DD); arithmetic is * All dates are America/New_York calendar strings (YYYY-MM-DD); arithmetic is
* anchored at noon UTC so DST edges cannot shift a date. Never raw Date math. * anchored at noon UTC so DST edges cannot shift a date. Never raw Date math.
@@ -56,6 +63,37 @@ export function weekdayOf(date: string): number {
return atNoon(date).getUTCDay(); return atNoon(date).getUTCDay();
} }
// ── the work week ────────────────────────────────────────────────
/**
* Sunday, the one day the campaign never schedules: topics run MonFri
* (data/schedule.json), the gate is Saturday, and the review windows below are
* picked so a solve comes back on a working day.
*/
const REST_DAY = 0;
export function isRestDay(date: string): boolean {
return weekdayOf(date) === REST_DAY;
}
/**
* The `offset`-th working day counting from `from` (0 = `from` itself), with
* Sundays skipped — the ONE place a scheduled date is minted.
*
* Work may slide later, never earlier: pulling a review back to Saturday would
* shorten the very interval it exists to test, so a Sunday landing becomes
* Monday. With the +3/+7 ladder that only ever happens to a Thursday solve's
* 3-day review; +7 lands on the solve's own weekday, which is never a Sunday.
*/
export function workingDay(from: string, offset = 0): string {
let cursor = isRestDay(from) ? addDays(from, 1) : from;
for (let i = 0; i < offset; i++) {
cursor = addDays(cursor, 1);
if (isRestDay(cursor)) cursor = addDays(cursor, 1);
}
return cursor;
}
export function campaignDay(date: string): number { export function campaignDay(date: string): number {
return daysBetween(CAMPAIGN_START, date) + 1; return daysBetween(CAMPAIGN_START, date) + 1;
} }
@@ -126,7 +164,21 @@ export function sample<T>(pool: T[], n: number, random: () => number): T[] {
// ── rows ───────────────────────────────────────────────────────── // ── rows ─────────────────────────────────────────────────────────
export type Stage = "new" | "+2" | "+5" | "+10" | "retired"; /**
* Review windows in days. One vocabulary for the whole campaign: these are the
* ladder rungs, the `work/<n>` buckets the picker scaffolds into, and the
* windows the `Spaced Repetition — <date>` issues are built from.
*
* 3 and 7 keep the rest day free. +7 returns a solve to its own weekday, and
* of the five learning weekdays only Thursday's +3 touches a Sunday — which
* workingDay() slides to Monday.
*/
export const WINDOWS = [3, 7] as const;
export type Window = (typeof WINDOWS)[number];
export type Stage = "new" | `+${Window}` | "retired";
/** Chart / stats order, low to high. */
export const STAGES: Stage[] = ["new", "+3", "+7", "retired"];
export type Result = "pass" | "fail"; export type Result = "pass" | "fail";
export type Kind = "first" | "review" | "drill" | "gate"; export type Kind = "first" | "review" | "drill" | "gate";
@@ -142,8 +194,8 @@ export interface ProblemRow {
defer_until: string | null; defer_until: string | null;
} }
export const INTERVAL: Record<string, number> = { "+2": 2, "+5": 5, "+10": 10 }; export const INTERVAL: Record<string, number> = { "+3": 3, "+7": 7 };
const NEXT_STAGE: Record<string, Stage> = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" }; const NEXT_STAGE: Record<string, Stage> = { new: "+3", "+3": "+7", "+7": "retired" };
// ── queries ────────────────────────────────────────────────────── // ── queries ──────────────────────────────────────────────────────
@@ -170,12 +222,13 @@ export const REVIEW_CAP = 3;
/** /**
* Load-level an overloaded review queue: everything due beyond today's * Load-level an overloaded review queue: everything due beyond today's
* REVIEW_CAP is pushed to a concrete future date — at most REVIEW_CAP per * REVIEW_CAP is pushed to a concrete future WORKING day — at most REVIEW_CAP
* day, oldest first — instead of piling up as "due today". Runs every digest * per day, oldest first — instead of piling up as "due today". Runs every
* morning, so a future day that grows past the cap (spill plus newly * digest morning, so a future day that grows past the cap (spill plus newly
* maturing reviews) is simply re-levelled when it arrives. Idempotent within * maturing reviews) is simply re-levelled when it arrives. Idempotent within
* a date: after one pass at most REVIEW_CAP problems remain due today, so a * a date: after one pass at most REVIEW_CAP problems remain due today, so a
* second pass moves nothing. * second pass moves nothing. The spill starts tomorrow, or Monday when
* tomorrow is the rest day.
*/ */
export async function levelReviews(db: D1Database, date: string): Promise<number> { export async function levelReviews(db: D1Database, date: string): Promise<number> {
const overflow = (await dueReviews(db, date)).slice(REVIEW_CAP); const overflow = (await dueReviews(db, date)).slice(REVIEW_CAP);
@@ -184,7 +237,7 @@ export async function levelReviews(db: D1Database, date: string): Promise<number
overflow.map((p, i) => overflow.map((p, i) =>
db db
.prepare("UPDATE problems SET next_review = ? WHERE lc_number = ?") .prepare("UPDATE problems SET next_review = ? WHERE lc_number = ?")
.bind(addDays(date, 1 + Math.floor(i / REVIEW_CAP)), p.lc_number), .bind(workingDay(addDays(date, 1), Math.floor(i / REVIEW_CAP)), p.lc_number),
), ),
); );
return overflow.length; return overflow.length;
@@ -336,16 +389,18 @@ export async function logAttempt(
return { ...nothing, title: p.title, kind, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true }; return { ...nothing, title: p.title, kind, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true };
} }
// Ladder move. First-ever logs enter at +2 for pass AND fail. // Ladder move. First-ever logs enter at +3 for pass AND fail.
let stage: Stage; let stage: Stage;
if (first) { if (first) {
stage = "+2"; stage = "+3";
} else if (opts.result === "pass") { } else if (opts.result === "pass") {
stage = NEXT_STAGE[p.stage] ?? "retired"; stage = NEXT_STAGE[p.stage] ?? "retired";
} else { } else {
stage = "+2"; stage = "+3";
} }
const next = stage === "retired" ? null : addDays(opts.date, INTERVAL[stage]!); // workingDay(), not addDays(): a Thursday solve's +3 would land on the rest
// day, and it takes Monday instead.
const next = stage === "retired" ? null : workingDay(addDays(opts.date, INTERVAL[stage]!));
const writes = [ const writes = [
db.prepare( db.prepare(
+3 -1
View File
@@ -11,6 +11,7 @@ import {
campaignDay, campaignDay,
campaignWeek, campaignWeek,
isoWeek, isoWeek,
STAGES,
streak, streak,
} from "./srs.ts"; } from "./srs.ts";
@@ -59,7 +60,8 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
const { results: ladderRows } = await db const { results: ladderRows } = await db
.prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage") .prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage")
.all<{ stage: string; n: number }>(); .all<{ stage: string; n: number }>();
const ladder: Record<string, number> = { new: 0, "+2": 0, "+5": 0, "+10": 0, retired: 0 }; // Every rung is present even at zero: the page's bar list is this object.
const ladder: Record<string, number> = Object.fromEntries(STAGES.map((s) => [s, 0]));
for (const row of ladderRows) ladder[row.stage] = row.n; for (const row of ladderRows) ladder[row.stage] = row.n;
// gates rows are keyed by ISO week (that's the digest/gate contract), but // gates rows are keyed by ISO week (that's the digest/gate contract), but
-10
View File
@@ -1,10 +0,0 @@
[2026-08-26T19:15:12.457Z] [SHUTDOWN] before-quit fired
[2026-08-26T19:15:12.458Z] [SHUTDOWN] shutdown started
[2026-08-26T19:15:12.458Z] [SHUTDOWN] Stopping local daemon server early
[2026-08-26T19:15:12.458Z] [SHUTDOWN] Phase 1: sending Ctrl+C to all terminals
[2026-08-26T19:15:12.459Z] [SHUTDOWN] Signaled 1 terminals: __pane_chat_terminal__
[2026-08-26T19:15:12.659Z] [SHUTDOWN] Sent second Ctrl+C, waiting 2s...
[2026-08-26T19:15:14.660Z] [SHUTDOWN] 2s wait complete
[2026-08-26T19:15:14.660Z] [SHUTDOWN] Phase 2: saving terminal states
[2026-08-26T19:15:14.670Z] [SHUTDOWN] ERROR during shutdown: BoundaryDecodeError: input.wasInterrupted: expected JSON value
[2026-08-26T19:15:14.671Z] [SHUTDOWN] Calling app.exit(0)