From 38fbb18f32bd1ad50a5fa3878564e6bf1162fad2 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Mon, 31 Aug 2026 10:41:45 -0400 Subject: [PATCH] =?UTF-8?q?feat(srs):=20switch=20ladder=20to=20+3/+7=20and?= =?UTF-8?q?=20enforce=20rest=E2=80=91day=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 29 +++++++---- README.md | 4 +- apps/api/README.md | 32 ++++++++---- apps/api/scripts/import-srs.ts | 94 ---------------------------------- apps/api/src/catalog.ts | 11 ++-- apps/api/src/charts.ts | 6 ++- apps/api/src/email.tsx | 12 ++--- apps/api/src/gate.ts | 2 +- apps/api/src/index.ts | 6 +-- apps/api/src/srs.ts | 91 +++++++++++++++++++++++++------- apps/api/src/stats.ts | 4 +- backend-debug.log | 10 ---- 12 files changed, 140 insertions(+), 161 deletions(-) delete mode 100755 apps/api/scripts/import-srs.ts delete mode 100644 backend-debug.log diff --git a/AGENTS.md b/AGENTS.md index 975187a..101d501 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - **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 — ` 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 Mon–Fri (`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 — ` 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//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. - **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). @@ -38,10 +40,10 @@ flowchart LR | Path | Purpose | |---|---| -| `work///..{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>///..{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/docs/` | Blume site (`blume.config.ts`, `content/`, `islands/`, `public/`). `content/()/-.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) | ## Development Commands @@ -49,14 +51,16 @@ flowchart LR All from the repo root (a Bun workspace over `apps/*`): ```sh -bun run pick # scaffold a solution via leetcode-cli (fuzzy picker; hides problems - # already under work/, caches index+descriptions in +bun run pick # scaffold a solution via leetcode-cli (fuzzy picker; Tab cycles the + # 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) 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) bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing # 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 close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1) 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; # runs as the last step of close-solved.yml in CI): 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 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) curl -X POST -H "Authorization: Bearer $LINK_KEY" \ "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. - **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. -- **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 -- `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/` 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/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/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`). - `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 diff --git a/README.md b/README.md index 26f20a9..602a3f0 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@

8-week interview-prep fight camp. 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.

@@ -18,7 +20,7 @@ phase progress - SRS ladder + SRS ladder — new / +3 / +7 / retired attempt heatmap diff --git a/apps/api/README.md b/apps/api/README.md index c8bf37a..7ad09fa 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -26,10 +26,23 @@ Live at `https://srs-api.prdlk.workers.dev`. ## The ladder -`new → +2 → +5 → +10 → retired`; pass advances, fail resets to `+2`. -A problem's first-ever log enters at `+2` regardless of result. Stage names -the NEXT review's interval. All dates are ET calendar strings anchored at -noon UTC (`src/srs.ts`) — never raw `Date` math. +`new → +3 → +7 → retired`; pass advances, fail resets to `+3`. A problem's +first-ever log enters at `+3` regardless of result. Stage names the NEXT +review's interval, and the two rungs are the same numbers as the `work/3` and +`work/7` buckets `bun run pick` scaffolds re-solves into. + +**Sunday is never booked.** Topics run Mon–Fri, 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 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 curl -X POST -H "Authorization: Bearer $LINK_KEY" \ "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 -migration from the retired `.github/srs/srs.json` lives at -`scripts/import-srs.ts` (`--remote` for production D1); it is re-runnable — -import-sourced attempts are wiped and re-inserted. +`?date=` on admin routes is the `SRS_TODAY` equivalent. Migrations are +append-only and applied in order; `0002_ladder_3_7.sql` is the +2/+5/+10 → +`+3`/`+7` rebuild, which also lifts every date that was sitting on a Sunday. +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 diff --git a/apps/api/scripts/import-srs.ts b/apps/api/scripts/import-srs.ts deleted file mode 100755 index 848183b..0000000 --- a/apps/api/scripts/import-srs.ts +++ /dev/null @@ -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; - topics: Record; - 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)); diff --git a/apps/api/src/catalog.ts b/apps/api/src/catalog.ts index b785d8c..4bf50a3 100644 --- a/apps/api/src/catalog.ts +++ b/apps/api/src/catalog.ts @@ -10,7 +10,7 @@ * so relabelling a problem is enough to move it and titles stay readable. * 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"; const TITLE_RE = /^LC (\d+) · (.+)$/; @@ -18,7 +18,7 @@ const TITLE_RE = /^LC (\d+) · (.+)$/; const DIFFICULTIES = ["easy", "medium", "hard"] 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"; export interface ReconcileReport { @@ -96,9 +96,10 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise { // ── 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 { const { results } = await db .prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`) diff --git a/apps/api/src/email.tsx b/apps/api/src/email.tsx index e6d8cdd..c8f5451 100644 --- a/apps/api/src/email.tsx +++ b/apps/api/src/email.tsx @@ -62,12 +62,12 @@ const DIFFICULTY: Record = { 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 = { new: "first look", - "+2": "2 days ago", - "+5": "5 days ago", - "+10": "10 days ago", + "+3": "3 days ago", + "+7": "7 days ago", retired: "retired", }; @@ -302,7 +302,7 @@ function DigestEmail({ data }: { data: DigestData }) { {data.progress} {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 ? " · 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.`} @@ -403,7 +403,7 @@ function plainDigest(data: DigestData): string { data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`, data.progress + (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 ? " · 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.`), diff --git a/apps/api/src/gate.ts b/apps/api/src/gate.ts index 928b010..a305c83 100644 --- a/apps/api/src/gate.ts +++ b/apps/api/src/gate.ts @@ -5,7 +5,7 @@ * 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 * 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. * * Label is `review` (the retired Actions system owned `gate`). Creation is diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ff5c3a4..5ab0dcb 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -44,7 +44,7 @@ async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Pro try { await gh.closeIssue( 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) { 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")); return page( - result === "pass" ? "Logged ✅" : "Logged — back to +2", + result === "pass" ? "Logged ✅" : "Logged — back to +3", outcomeLine(outcome), ); } @@ -232,7 +232,7 @@ async function handleSolved(request: Request, env: Env, date: string): Promise(pool: T[], n: number, random: () => number): T[] { // ── 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/` buckets the picker scaffolds into, and the + * windows the `Spaced Repetition — ` 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 Kind = "first" | "review" | "drill" | "gate"; @@ -142,8 +194,8 @@ export interface ProblemRow { defer_until: string | null; } -export const INTERVAL: Record = { "+2": 2, "+5": 5, "+10": 10 }; -const NEXT_STAGE: Record = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" }; +export const INTERVAL: Record = { "+3": 3, "+7": 7 }; +const NEXT_STAGE: Record = { new: "+3", "+3": "+7", "+7": "retired" }; // ── queries ────────────────────────────────────────────────────── @@ -170,12 +222,13 @@ export const REVIEW_CAP = 3; /** * 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 - * day, oldest first — instead of piling up as "due today". Runs every digest - * morning, so a future day that grows past the cap (spill plus newly + * REVIEW_CAP is pushed to a concrete future WORKING day — at most REVIEW_CAP + * per day, oldest first — instead of piling up as "due today". Runs every + * 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 * 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 { const overflow = (await dueReviews(db, date)).slice(REVIEW_CAP); @@ -184,7 +237,7 @@ export async function levelReviews(db: D1Database, date: string): Promise db .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; @@ -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 }; } - // 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; if (first) { - stage = "+2"; + stage = "+3"; } else if (opts.result === "pass") { stage = NEXT_STAGE[p.stage] ?? "retired"; } 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 = [ db.prepare( diff --git a/apps/api/src/stats.ts b/apps/api/src/stats.ts index 6be0400..dbdd7f9 100644 --- a/apps/api/src/stats.ts +++ b/apps/api/src/stats.ts @@ -11,6 +11,7 @@ import { campaignDay, campaignWeek, isoWeek, + STAGES, streak, } from "./srs.ts"; @@ -59,7 +60,8 @@ export async function buildStats(db: D1Database, today: string): Promise const { results: ladderRows } = await db .prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage") .all<{ stage: string; n: number }>(); - const ladder: Record = { 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 = Object.fromEntries(STAGES.map((s) => [s, 0])); for (const row of ladderRows) ladder[row.stage] = row.n; // gates rows are keyed by ISO week (that's the digest/gate contract), but diff --git a/backend-debug.log b/backend-debug.log deleted file mode 100644 index 9e0a4fb..0000000 --- a/backend-debug.log +++ /dev/null @@ -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)