# Curio — Implementation Plan (12 features)
Audience: **Claude Sonnet**, executing one feature at a time. Each section is self-contained: goal, verified current state (with file refs), changes, tests, and acceptance criteria.
This plan was written after mapping the codebase on 2026-06-23. **Verify file:line refs before editing — code may have moved.**
## Global conventions (apply to every feature — non-negotiable)
These come from `CLAUDE.md` and the spec. Violating them is a bug:
1. **All LLM calls go through `src/lib/llm/client.ts`.** Never call a provider/AI-SDK model directly. Generator and grader must differ.
2. **Prompts live in the versioned registry** `src/lib/llm/prompts/index.ts` (`{ id, version, template }`). Never inline a prompt. Bump `version` on any semantic change.
3. **Contracts are Zod schemas** in `src/schemas/`, shared by LLM ↔ API ↔ UI. Validate LLM structured output against them.
4. **Serve path reads the buffer; jobs generate.** No synchronous generation in request handlers except the documented cold-start gate. Postgres is source of truth.
5. **Paid background jobs are idempotent** (idempotency key + durable `jobs` ledger row). A retry must never regenerate-and-rebill.
6. **Per-session token budget is enforced in code** (`src/lib/budget/index.ts`) — call `checkBudget` before any new paid LLM call, `recordUsage` after.
7. **Migrations are append-only.** Never edit an applied migration. Add a new one via `pnpm db:generate` then `pnpm db:migrate`. (Note: the migrations dir already contains a few accidental duplicate migrations — do not "fix" them, just add new ones.)
8. **i18n: every user-facing string goes in BOTH `messages/en.json` and `messages/fr.json`** under the right namespace, read via `useTranslations` (client) or `getTranslations` (server). No hardcoded copy.
9. **Tests with every change.** Unit tests mock the LLM (Vitest, deterministic fixtures). Real-model calls only in `tests/golden/`. Run `pnpm test` before declaring done.
10. **Design quality floor:** responsive, visible keyboard focus, reduced-motion respected, WCAG-AA contrast, dark-mode tokens. Reading surface stays calm/editorial; no gamification.
Stack reference: Next.js 15 App Router + React 19, Drizzle + Postgres/pgvector, Redis (ioredis + Upstash ratelimit), BullMQ workers (`jobs/`, run via `pnpm workers`), Vercel AI SDK, next-auth v5 (JWT), next-intl, nodemailer (SMTP), Langfuse. Vitest + Playwright.
---
## Suggested execution order
Dependency-aware. Do them in this order unless told otherwise:
1. **#7 (audit migration)** — small, de-risks the account story others build on.
2. **#10 (dark-mode polish)** — small, isolated; extracts the `setTheme` helper #13 reuses.
3. **#13 (settings upgrade)** — needs #10's `setTheme` helper; otherwise self-contained UI.
4. **#12 (review upgrade)** — extracts the shared grading hook from the lesson reader.
5. **#2 (content versioning)** — foundational; #1 and #3 lean on version/provenance.
6. **#1 (novel-misconception harvest loop)** — moat; table already exists.
7. **#3 (false-fail dashboard)** — consumes #1's labeled data + grades.
8. **#6 (multi-lesson journey)** — product; #5 and #11's due-zone build on journey/mastery state.
9. **#11 (mastery upgrade)** — surfaces due reviews; pairs with #6/#5.
10. **#5 (returning-learner home)** — product; needs #6's journey state.
11. **#9 (streaming generation)** — infra polish.
12. **#8 (due-review email digest)** — retention; independent, do last.
UI features (#11/#12/#13) are loosely ordered — they can move earlier if a UI pass is wanted sooner; only #13→#10 (theme helper) and #12→lesson-reader (shared hook) are hard dependencies.
Each feature is one focused PR/commit. Keep Route Handlers thin; logic in `src/lib/`.
---
## #7 — Anonymous → account progress migration (AUDIT + HARDEN)
**Status: already implemented.** This is an audit/harden task, NOT a build.
**Current state (verified):**
- `src/app/api/auth/migrate-session/route.ts` exists. In a transaction it reassigns `responses`, `mastery` (with on-conflict skip when the auth user already has that concept), and `journeyInstances` from `anonUserId` → `authUserId`, then renames the Redis `budget:{id}` key. Has `__tests__/`.
- Called from `src/app/auth/login/login-client.tsx` and `src/app/auth/register/page.tsx` after auth success; clears `sessionStorage` `curio_uid`.
**Goal:** make it correct and complete, not bigger.
**Changes / checks:**
1. **Mastery conflict policy.** Today conflicting concepts are *skipped* (anon progress dropped). Decide intended behavior: keep the higher `score` / more-recent `lastSeen` instead of silently dropping. If changing, do it in the `onConflictDoUpdate` and document why.
2. **Auth coverage.** Confirm migration fires for ALL sign-in paths: credentials login, register, AND OAuth (Google/GitHub/OIDC) first sign-in. OAuth users never hit `login-client.tsx`. Add a migration trigger on the OAuth callback path (e.g. a client effect on first authenticated landing, or a `signIn` event in `src/auth.ts` events) — without it, OAuth users lose anon progress.
3. **Idempotency / double-call.** Calling migrate twice (or with no anon data) must be a no-op, not an error. Verify.
4. **Validation & authz.** The route must take `anonUserId` from the request but `authUserId` strictly from the server session (never trust a client-supplied authUserId). Confirm a malicious `anonUserId` can only move data INTO the caller's own account.
5. **`accounts`/`sessions` tables** must not be touched by migration (those are auth-adapter owned).
**Tests:** extend `migrate-session/__tests__`: (a) conflict resolution keeps best mastery; (b) OAuth-path migration; (c) double-call no-op; (d) authz — session id always wins over body.
**Acceptance:** A user who learns anonymously, then signs up OR logs in via any provider, keeps all mastery/responses/journey. No data loss on concept conflicts. `pnpm test` green.
---
## #10 — Dark mode (AUDIT + POLISH)
**Status: already implemented.** Audit/polish task.
**Current state (verified):**
- `src/styles/tokens.css` has a full dark token block under `:root[data-theme='dark']` plus a `@media (prefers-color-scheme: dark)` fallback for `:root:not([data-theme='light'])`.
- `src/app/layout.tsx` runs a no-flash theme bootstrap (reads `localStorage('curio-theme')`, sets `data-theme` on ``).
- `src/components/site-header.tsx` has a `ThemeToggle` cycling system → dark → light.
**Goal:** verified-correct dark mode on every surface + a settings entry.
**Changes:**
1. **Settings selector.** Add a theme control to `src/app/settings/settings-client.tsx` (System / Light / Dark) that writes the same `localStorage('curio-theme')` key + `data-theme` the header toggle uses (share one helper — extract `setTheme()` to e.g. `src/lib/theme.ts`). Add i18n keys under `settings.appearance.*` in both locales.
2. **Contrast audit (WCAG-AA, spec §7.5 non-negotiable).** Verify every diagnosis-state color in dark mode meets AA against its background: `--color-mastered/partial/misconception/uncertain` and their `-subtle` pairs, plus `--color-tutor` marginalia and accent on `--color-surface`. Fix any token that fails. This is the highest-risk part — the diagnosis colors carry meaning.
3. **Surface sweep.** Manually verify dark rendering on: home, learn/reading surface + marginalia, checkpoint input, review, mastery map, auth pages, admin, settings. Look for hardcoded colors (e.g. `#fff`) that ignore tokens — `grep -rn "#fff\|#000\|rgb(" src/app src/components src/styles` and replace with tokens.
4. **Persisted preference (optional).** If a user is logged in, persist theme in `users.preferences` (jsonb already exists) so it follows them across devices; localStorage remains the anon/no-flash source.
**Tests:** unit test the `setTheme` helper. Playwright: toggle to dark, assert `data-theme="dark"` on `` and a known token's computed value.
**Acceptance:** Theme switchable from header AND settings; persists; no flash on reload; all diagnosis colors pass AA in both themes; no hardcoded non-token colors remain on core surfaces.
---
## #2 — Content versioning + lazy invalidation
**Goal:** when a blueprint's generator model or prompt version changes, mark its content stale and regenerate lazily (on next serve or via a job), without regenerate-and-rebill races. Spec §10 / invariant #10.
**Current state (verified):**
- `blueprints.modelVersion` (text) + `blueprints.contentVersion` (int, default 1). `segments.contentVersion` + `checkpoints.contentVersion` exist but are **never incremented** and **never read** (`generate-blueprint.ts:~85` writes `modelVersion = process.env.LLM_GENERATOR_MODEL`, `contentVersion = 1`).
- Prompt registry has versions (e.g. `GENERATE_LESSON` is v5) but nothing ties a blueprint to the prompt version it was built with.
- `jobs/promote-blueprint-job.ts` already invalidates the Redis cache after promotion — reuse that invalidation primitive.
**Define "current content signature":** a tuple `(generatorModel, generateLessonPromptVersion)`. Persist what each blueprint was built with; compare against current at serve/audit time.
**Changes:**
1. **Schema migration:** add `blueprints.promptVersion` (int) and `blueprints.staleAt` (timestamp, nullable). Optionally `blueprints.lastBuiltModel` if `modelVersion` semantics are ambiguous. Generate via `pnpm db:generate`.
2. **Stamp on generation:** in `src/lib/generation/generate-lesson.ts` / `generate-blueprint.ts`, write the current `prompts.GENERATE_LESSON.version` and generator model id at build time.
3. **Staleness check:** add `src/lib/generation/staleness.ts` exporting `isBlueprintStale(blueprint): boolean` (compares stored model+promptVersion to current config) and `markStale(blueprintId)`.
4. **Invalidation trigger:** add an admin action (button on `src/app/admin/blueprints/[id]`) + an internal function to mark a blueprint stale and bump `contentVersion`. On the serve path (`getLessonResponse` in `src/lib/db/queries.ts` / `src/app/learn/[key]/page.tsx`), if stale, enqueue a regeneration job (do NOT regenerate synchronously) and keep serving the old version until the new one is ready (lazy invalidation — spec invariant #10).
5. **Regeneration job:** add `jobs/regenerate-blueprint-job.ts` (or extend `generate-lesson-job`) that regenerates segments/checkpoints at a new `contentVersion`, re-runs T0/T1 (and T2 at promotion), and swaps the cache. **Idempotency key must include the target `contentVersion`** so a retry never double-bills. Reuse the `jobs` ledger pattern from `generate-lesson-job.ts`.
6. **Flagged-item regeneration:** `autoFlagCheckpointIfNeeded` (already in `queries.ts`) sets a checkpoint to `verifyStatus='fail'`. Add an endpoint/admin action to regenerate just the flagged checkpoint at a bumped `contentVersion` (cheaper than whole-blueprint).
**Tests:** unit (LLM mocked): `isBlueprintStale` truth table; regeneration job bumps `contentVersion` and is idempotent under retry (same key → no second generation); serve path enqueues but does not block on stale.
**Acceptance:** Changing `LLM_GENERATOR_MODEL` or bumping the lesson prompt version causes new serves to enqueue regeneration; learners keep seeing valid old content until the new version verifies; retried jobs never double-generate; admin can force-regenerate a blueprint or a single flagged checkpoint.
---
## #1 — Novel-misconception harvest loop
**Goal:** close the moat flywheel — turn `novel`-tagged grades into labeled misconceptions that grow the misconception library AND the golden set. Spec §10.2 / §10.4 / §15.
**Current state (verified):**
- `grades.misconceptionTag` can be `'novel'`. `src/lib/verification/grader.ts:~183` already enqueues novel cases into the `novelMisconductQueue` table (`{ gradeId, checkpointId, responseText, evidenceSpan, processed:boolean default false, createdAt }`, schema `~line 268`).
- `getPersonalizationContext` (`queries.ts:~394`) already excludes `'novel'` from personalization (only labeled misconceptions are used). So once a novel case is *labeled* into `misconceptions`, it automatically starts being used.
- There is **no UI to review the queue, no labeling action, and no path from a labeled item into `tests/golden/misconception-cases.ts`.** That's the gap.
**Changes:**
1. **Queries** (`src/lib/db/queries.ts`): `getNovelQueue({ processed:false, limit })` returning queue rows joined with checkpoint + concept context; `labelNovelMisconception({ queueId, conceptId, tag, signature, description, correction })` which (a) inserts a row into `misconceptions` with `verifyStatus='pending'`, (b) marks the queue row `processed=true`, in one transaction; `dismissNovelQueueItem(queueId)` for false positives.
2. **Admin page** `src/app/admin/misconceptions/` (new): list unprocessed queue items showing the learner response with the `evidenceSpan` highlighted (reuse the marginalia highlight idiom from `grade-display.tsx`), the checkpoint prompt, reference answer, and existing misconceptions for that concept. Provide a label form (tag, signature, description, correction) and a dismiss button. Add to admin nav (`src/app/admin/layout.tsx`).
3. **Verification hook:** after labeling, the new misconception is `verifyStatus='pending'`. Run it through the existing `verifyMisconceptions` (`src/lib/verification/verify-misconceptions.ts`) — either inline on label or via a small job — so only verified misconceptions go live.
4. **Golden-set growth:** add `appendMisconceptionGoldenCase()` that writes a new case to `tests/golden/misconception-cases.ts` (or a sibling JSON the runner reads). At minimum, the admin label action should offer "also add as golden case" producing `{ response, expectedTag, conceptId }`. Keep the golden file the source of truth for the eval.
5. **Optional clustering:** if the queue is large, add a cheap-model job to cluster similar novel responses (group by embedding similarity of `responseText`) so a human labels a cluster once. Use `client.cheapGrader` / `embedText`. Defer if time-boxed.
**Tests:** unit (LLM mocked): labeling inserts misconception + flips `processed` atomically; dismiss leaves no misconception; labeled+verified misconception appears in `getPersonalizationContext` for that concept. Add ≥2 new cases to `misconception-cases.ts` and confirm `pnpm test:golden:misconceptions` still passes thresholds.
**Acceptance:** An admin can see novel responses, label or dismiss them; labeled items become verified misconceptions used in future grading/personalization; the golden set grows; the queue drains (`processed=true`). No new uncontrolled LLM cost on the serve path.
---
## #3 — False-fail monitoring dashboard
**Goal:** make the spec's core quality metric — false-fail rate (telling a correct learner they're wrong) — continuously visible, not just a CI gate. Spec §15.
**Current state (verified):**
- `tests/golden/run-grading-eval.ts` computes false-fail rate and verdict accuracy against frozen cases; CI-gated (`pnpm test:golden`, fails if false-fail > 10% or accuracy < 70%). **Writes no persistent results** (console only).
- Live signal exists but isn't aggregated: `grades` (verdict, confidence, payloadJson), `contentReports` with `reason='grade_wrong'` (learner-disputed grades), `getCheckpointFailureRate` + `autoFlagCheckpointIfNeeded` in `queries.ts`.
- `src/app/admin/reports/` lists raw content reports; no metrics/aggregation.
**Two data sources, show both:**
- **Offline (golden):** trend of false-fail rate / accuracy per eval run.
- **Online (production):** `grade_wrong` report rate vs total grades; misconception/uncertain verdict distribution; checkpoints auto-flagged.
**Changes:**
1. **Persist golden runs:** new table `eval_runs` (`id, suite, falseFailRate, accuracy, total, model, promptVersion, gitSha nullable, createdAt`). Make `run-grading-eval.ts` (and the content/misconception runners) insert a row at the end (guard behind an env flag so local runs can skip DB writes). Keep console output.
2. **Aggregation queries** (`src/lib/db/queries.ts` or `src/lib/metrics.ts`): `getEvalTrend(suite, limit)`; `getOnlineGradeMetrics(window)` → counts by verdict, `grade_wrong` report rate, # auto-flagged checkpoints, optionally per-blueprint.
3. **Admin dashboard** `src/app/admin/quality/` (new; link in admin nav): show latest false-fail rate with the 10% threshold marked (red if breached), a small trend (sparkline/table) from `eval_runs`, the online verdict distribution, and a list of checkpoints with the highest dispute/failure rate (drill into `/admin/blueprints/[id]`). Keep it calm and tabular — admin tool, not the learner surface.
4. **Alerting (optional):** if the latest persisted false-fail rate breaches threshold, surface a banner on `/admin`.
**Tests:** unit: aggregation queries over seeded grades/reports return correct counts and rates; `eval_runs` insert is gated by env flag. No need to run real models in unit tests.
**Acceptance:** After a golden run, the admin quality page shows the latest + historical false-fail rate against the threshold; production grade/dispute metrics are visible; high-dispute checkpoints are findable. False-fail is now observable continuously.
---
## #6 — Multi-lesson journey continuity
**Goal:** a Journey is a *sequence* of bounded lessons through a blueprint, not a single lesson. Let a learner finish lesson N and continue to N+1, with visible progress. Spec §1, §3.
**Current state (verified):**
- `blueprints` → `concepts` (ord) → `lessons` (ord, depth, locale) → `segments` → `checkpoints`. `journeyInstances.position` is incremented by `advanceJourneyPosition` (`queries.ts:~675`) on lesson complete, but **nothing reads position** and there is **no next-lesson chaining**. `/learn/[key]` serves one lesson per intent (`getLessonResponse`).
- `LessonComplete` in `lesson-reader.tsx` calls `POST /api/advance` then shows "Progress saved" — a dead end.
**Decision to confirm with product:** does one blueprint contain multiple `lessons` (multi-lesson per intent), or is each intent one lesson and a "journey" spans related blueprints? The schema supports multiple `lessons` per blueprint (`lessons.ord`), so **default assumption: multiple lessons per blueprint, ordered by `lessons.ord`.** Generation currently makes one lesson — extending generation to produce a multi-lesson arc is a larger content change; for this feature, support *serving and navigating* multiple lessons and generate subsequent lessons lazily on advance.
**Changes:**
1. **Serve by position:** extend `getLessonResponse` (or add `getLessonAtPosition(intentKey, userId, position, ...)`) to return the lesson at the learner's current `journeyInstances.position` (default 0 → first lesson by `lessons.ord`). `/learn/[key]/page.tsx` reads the user's position to pick the lesson.
2. **Advance → next:** on lesson complete, after `advanceJourneyPosition`, if a next lesson exists (or can be generated), navigate to it (e.g. `/learn/[key]?pos=N` or just re-fetch since position advanced). If the next lesson isn't generated yet, enqueue `generate-lesson-job` for `(blueprintId, ord=position)` and show the outline/poll state (reuse existing polling). If no next lesson and the blueprint is complete, show a real "Journey complete" terminal state (not just "saved").
3. **Lesson generation for position N:** `generate-lesson.ts` / `generate-lesson-job.ts` currently assume ord 0. Parameterize by `ord`/`position` and pass the concept(s) for that position. Idempotency key must include `ord`.
4. **Progress UI:** add a journey progress indicator to the reading surface header (e.g. "Lesson 2 of 5") sourced from `lessons` count + position. Calm, non-gamified (spec §7). i18n both locales.
5. **Cross-check #5:** the returning-home "continue" surface will read the same `journeyInstances` + position.
**Tests:** unit (LLM mocked): position increments serve the next lesson; terminal state when position ≥ lesson count; advance enqueues generation for an ungenerated next lesson with an ord-scoped idempotency key. Playwright: complete a lesson → land on the next or a real completion screen.
**Acceptance:** Completing a lesson advances to the next lesson in the blueprint (generating it lazily if needed) with a visible "Lesson N of M"; finishing the last lesson shows a Journey-complete state; position persists per user. No synchronous generation on the serve path.
---
## #5 — Returning-learner home
**Goal:** give the second visit a story — resume in-progress journeys, see due reviews, glance at mastery — instead of only an intent box. Spec §16 (mastery map, Daily Review).
**Current state (verified):**
- `src/app/page.tsx` is intent entry only (title, lede, input, depth/mode, example chips, "spark"). No resume surface.
- Data already available: `journeyInstances` (position) — after #6 this is meaningful; `getMasteryMap(userId)`; `getReviewCheckpoints(userId)` / `getDueReviews`. Anonymous users have a `curio_uid`; logged-in users have `session.user.id`.
**Changes:**
1. **Detect "returning":** a learner is "returning" if they have any `journeyInstances`, `mastery`, or due reviews. For anon users this requires the client `curio_uid`; the home page is a client component already, so fetch on mount (or add a `GET /api/home-summary?userId=` that returns `{ inProgress: [{intentKey, title, position, total}], dueReviewCount, recentMastery: [...] }`). For logged-in users, prefer the session id.
2. **New endpoint** `src/app/api/home-summary/route.ts`: aggregates in-progress journeys (join `journeyInstances` → `blueprints`, compute position/total), `dueReviewCount` (from `getReviewCheckpoints`), and a small recent-mastery slice. Reuse existing queries; add a `getInProgressJourneys(userId)` query.
3. **Home UI:** when summary is non-empty, render (above or beside the intent box) a calm "Continue" section: resume cards linking to `/learn/[intentKey]` (which #6 resolves to the right lesson via position), a "Review (N due)" link to `/review` when `dueReviewCount > 0`, and a "Mastery" link. When empty (first visit), show today's intent-first layout unchanged. Keep editorial calm; no streaks/XP (spec anti-pattern).
4. **i18n:** `home.continue.*`, `home.dueReviews`, etc., both locales.
5. **Loading/empty states:** skeleton while summary loads; never block the intent box on the summary fetch (intent entry must always be instant).
**Tests:** unit: `getInProgressJourneys` + home-summary aggregation over seeded data; empty summary → first-visit layout. Playwright: a user with progress sees Continue cards + due-review link; a fresh user sees only the intent entry.
**Acceptance:** Returning learners (anon or logged-in) land on a home that surfaces in-progress journeys, due-review count, and mastery, each one click away; first-time visitors see the existing clean intent entry; the intent box is never delayed by the summary.
---
## #9 — Streaming generation (skeleton-first)
**Goal:** replace the 4-second poll with true streamed delivery so a cold-start lesson appears progressively. Spec §8.3 (skeleton-first + buffer).
**Current state (verified):**
- Cold start returns an `outline` state; `src/components/lesson-reader.tsx:~97` polls `router.refresh()` every 4000ms until the lesson is `ready`. Generation runs in `jobs/generate-lesson-job.ts` (BullMQ), warming the Redis buffer.
- Vercel AI SDK supports streaming; `client.ts` uses structured output (`generateObject`-style). Lesson generation is schema-constrained, which complicates token-streaming of structured output.
**Approach (pick the lower-risk one that fits):**
- **Option A — Stream segment-by-segment from the buffer (recommended).** Keep generation in the job, but as each segment is generated + T0/T1-verified, push it to the Redis buffer (`buffer:{...}` per spec §17) and expose `GET /api/lessons/stream?key=...` as an SSE endpoint that emits segments as they land. `LessonReader` consumes the SSE and appends segments, replacing the poll. This honors invariant #4 (serve reads buffer, jobs generate) and degrades to the existing poll if SSE drops.
- **Option B — Stream the model directly** via `streamObject` for the reading text only (checkpoints still structured). More premium feel but riskier with schema validation + provenance (`source_chunk_ids`) + verification. Only if Option A is insufficient.
**Changes (Option A):**
1. **Buffer incrementally:** in `generate-lesson-job.ts`, write each verified segment to the Redis buffer as it completes (not only the whole lesson at the end). Keep the final whole-lesson cache write for the fast path.
2. **SSE route** `src/app/api/lessons/stream/route.ts`: Node runtime (not edge — needs timeout headroom), reads the buffer, emits `segment` events as they appear, a `ready` event when complete, and closes. Respect the per-session budget only for the generation job, not the read.
3. **Client:** in `lesson-reader.tsx`, when in outline/cold-start state, open an `EventSource` to the stream and append segments with the existing View-Transition reveal; fall back to the 4s poll if `EventSource` errors or is unsupported. Remove the poll once `ready`.
4. **Reduced motion:** segment-in animation must respect `prefers-reduced-motion` (already handled in `reading.css`).
**Tests:** unit: buffer write-per-segment ordering; SSE route emits segments then `ready` (mock the buffer). Playwright: cold-start lesson shows segments appearing without a full reload.
**Acceptance:** A cold-start lesson streams in segment-by-segment instead of popping after a 4s poll; falls back gracefully to polling; no synchronous generation on the request path; reduced-motion respected.
---
## #8 — Due-review email digest
**Goal:** bring learners back when reviews are due — the spaced-repetition retention loop. Email infra already exists; only transactional sends today.
**Current state (verified):**
- `src/lib/email/index.ts` `sendEmail({to,subject,html,text?})` over nodemailer SMTP (dev: jsonTransport→console). `src/lib/email/templates.ts` has verification/invite/reset templates. **No scheduled sending.**
- `src/lib/memory/spaced-rep.ts` (`isDue`, `scheduleNextReview`) + `getReviewCheckpoints(userId)` / `getDueReviews` give due items. `mastery.nextReview` is the schedule. Only logged-in users have an email (`users.email`); anon users can't be emailed.
**Changes:**
1. **Digest template:** add `reviewDigestEmail({ name, dueCount, topConcepts, reviewUrl })` to `templates.ts` (+ both-locale subject/body via the user's `users.locale`). Calm, on the learner's side; one clear CTA to `/review`. No gamified streak language.
2. **Recipient query** (`src/lib/db/queries.ts`): `getUsersWithDueReviews(now)` → users with `email IS NOT NULL`, `emailVerified`, ≥1 mastery row where `nextReview <= now`, with a due count + a few concept names. Respect an opt-out preference (see 4).
3. **Scheduled job:** add a BullMQ repeatable job (or a cron-invoked Route Handler protected by a secret header, e.g. for Vercel Cron) `jobs/review-digest-job.ts` that runs daily, fetches due users, and sends at most one digest per user per day. **Idempotency:** key on `(userId, yyyy-mm-dd)` via the `jobs` ledger so a re-run never double-sends. Throttle/batch sends.
4. **Opt-out:** add `emailDigest: boolean` (default true) to `users.preferences` (jsonb exists) with a toggle in `settings-client.tsx` (+ i18n). Include an unsubscribe link (signed token route) in the email — required for good email hygiene.
5. **Config:** guard the whole feature behind an env flag (e.g. `REVIEW_DIGEST_ENABLED`) and document SMTP + cron setup in `.env.example` / `docs`.
**Tests:** unit (mock `sendEmail`): `getUsersWithDueReviews` filters correctly (no email / unverified / opted-out excluded); digest job sends once per user per day (idempotency key blocks re-send); unsubscribe flips the preference. Do not send real email in tests.
**Acceptance:** A daily job emails verified, opted-in users who have reviews due, at most once/day, idempotently, with a working unsubscribe and locale-correct copy; feature is env-gated and documented; anon users are never emailed.
---
## #11 — Mastery page upgrade
**Goal:** turn the mastery page from a flat card grid into a calm "state of your learning" dashboard, and surface the actionable thing (due reviews). Spec §16 (mastery map). Stays editorial, non-gamified (spec §7).
**Current state (verified):**
- `src/components/mastery-map.tsx` — title + per-blueprint groups of `ConceptCard`s (name, score bar, %, a small "due" tag). Data from `GET /api/mastery?userId=`. `src/app/mastery/page.tsx` just renders `