From a3431da300bce227a217712fc577f6e12a633aa3 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Tue, 25 Aug 2026 11:19:19 -0400 Subject: [PATCH] feat(api): add SRS Cloudflare Worker with email digest, charts, and D1 integration --- apps/api/.dev.vars.example | 3 + apps/api/README.md | 92 ++++++ apps/api/data/schedule.json | 26 ++ apps/api/migrations/0001_init.sql | 53 ++++ apps/api/package.json | 21 ++ apps/api/scripts/import-srs.ts | 94 +++++++ apps/api/src/catalog.ts | 92 ++++++ apps/api/src/charts.ts | 406 +++++++++++++++++++++++++++ apps/api/src/digest.ts | 180 ++++++++++++ apps/api/src/email.tsx | 451 ++++++++++++++++++++++++++++++ apps/api/src/gate.ts | 270 ++++++++++++++++++ apps/api/src/github.ts | 94 +++++++ apps/api/src/index.ts | 324 +++++++++++++++++++++ apps/api/src/links.ts | 79 ++++++ apps/api/src/mirror.ts | 172 ++++++++++++ apps/api/src/png.ts | 254 +++++++++++++++++ apps/api/src/srs.test.ts | 59 ++++ apps/api/src/srs.ts | 330 ++++++++++++++++++++++ apps/api/src/stats.ts | 107 +++++++ apps/api/tsconfig.json | 18 ++ apps/api/wrangler.jsonc | 41 +++ 21 files changed, 3166 insertions(+) create mode 100644 apps/api/.dev.vars.example create mode 100644 apps/api/README.md create mode 100644 apps/api/data/schedule.json create mode 100644 apps/api/migrations/0001_init.sql create mode 100644 apps/api/package.json create mode 100755 apps/api/scripts/import-srs.ts create mode 100644 apps/api/src/catalog.ts create mode 100644 apps/api/src/charts.ts create mode 100644 apps/api/src/digest.ts create mode 100644 apps/api/src/email.tsx create mode 100644 apps/api/src/gate.ts create mode 100644 apps/api/src/github.ts create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/links.ts create mode 100644 apps/api/src/mirror.ts create mode 100644 apps/api/src/png.ts create mode 100644 apps/api/src/srs.test.ts create mode 100644 apps/api/src/srs.ts create mode 100644 apps/api/src/stats.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/api/wrangler.jsonc diff --git a/apps/api/.dev.vars.example b/apps/api/.dev.vars.example new file mode 100644 index 0000000..e927b90 --- /dev/null +++ b/apps/api/.dev.vars.example @@ -0,0 +1,3 @@ +GH_PAT=github_pat_or_gho_token_with_issues_and_projects_rw +WEBHOOK_SECRET=random_hex_matching_the_repo_webhook +LINK_KEY=random_hex_signing_one_tap_links diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..fef131e --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,92 @@ +# srs-api + +Cloudflare Worker running the spaced-repetition system: daily digest email, +one-tap logging, `/done` webhook, Saturday review issues, live SVG charts, +and the `/api/stats` feed for the docs progress page. + +Live at `https://srs-api.prdlk.workers.dev`. + +## Direction of truth + +- **D1 owns SRS state** — stages, review dates, attempt history, gate + scores, boost flags. Nothing else is authoritative. +- **GitHub issues own the catalog** — topics, problems, `set:*`/`diff:*` + labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via + `/admin/reconcile`); it recomputes from scratch, never invents rows, and + never overwrites SRS-owned columns (`stage`, `next_review`). +- **The repo owns the schedule** — `data/schedule.json`, human-edited, + bundled at deploy. Git history is its audit log. +- The GitHub Project mirror (`Target Date`, `SRS Stage`, `First Attempt`) + is best-effort: failures log warnings, never block a D1 write. Topic rows + are never written. + +## 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. + +Blind drills draw only from topics **already learned**: scheduled in an +earlier week (the current week's optional pool is reserved for Saturday's +gate) and with at least one core problem on the ladder — a skipped learning +day never feeds drills just because its calendar week lapsed. + +## Routes + +| Route | Auth | Purpose | +|---|---|---| +| `GET /log?p&r&d&sig` | HMAC (`LINK_KEY`) | one-tap pass/fail; idempotent per (problem, date, kind); links expire after 3 days | +| `POST /webhook/github` | HMAC (`WEBHOOK_SECRET`) | `/done pass\|fail` comments (owner only, any issue); `review`-issue close → gate scoring | +| `GET /chart/{progress,ladder,heatmap}.svg`, `GET /badge/gate.svg` | public | hand-rolled SVGs, `max-age=300` (GitHub Camo's freshness floor) | +| `GET /api/stats` | public, CORS-pinned to the docs origin | one JSON document for `/progress` | +| `POST /admin/{digest,review,reconcile}` | `Authorization: Bearer ` | manual triggers; `digest` takes `?dry=1&force=1&date=` | + +## Crons (DST-proof) + +Each event has two UTC crons; code fires only when the computed ET hour +matches (`etHour` in `src/srs.ts`, unit-checked in `src/srs.test.ts`): + +- `0 12,13 * * *` → 8 AM ET: catalog reconcile, then the digest. +- `0 4,5 * * 6` → midnight ET Saturday: create `Review — Week N` (so the + 8 AM digest can link to it). + +## Secrets & bindings + +`wrangler secret put` — `GH_PAT` (Issues + Projects RW), `WEBHOOK_SECRET` +(matches the repo webhook), `LINK_KEY` (signs one-tap links, gates admin +routes). Bindings in `wrangler.jsonc`: `DB` (D1 `srs`), `EMAIL` +(`send_email`, restricted to the verified destination). Sender domain +`prdlk.com` is onboarded to Email Sending. + +The `sync-d1.yml` Actions workflow additionally pushes curriculum issue +edits into D1 immediately (`POST /admin/reconcile` with the +`SRS_ADMIN_KEY` repo secret); the morning cron is the backstop. + +## Local dev + +```sh +cp .dev.vars.example .dev.vars # or fill GH_PAT/WEBHOOK_SECRET/LINK_KEY +bun install +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 +``` + +`?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. + +## Deploy + +```sh +bun run deploy # from apps/api/, or `bun run api:deploy` at the repo root +``` + +CI deploys on every push to `main` (`deploy.yml`, gated on `bun run api:test`, +using the `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_ACCOUNT_ID` repo secrets); the +commands above are for out-of-band deploys. After changing bindings, rerun +`bunx wrangler types`. diff --git a/apps/api/data/schedule.json b/apps/api/data/schedule.json new file mode 100644 index 0000000..49fcd85 --- /dev/null +++ b/apps/api/data/schedule.json @@ -0,0 +1,26 @@ +{ + "2026-08-17": 3, + "2026-08-18": 4, + "2026-08-24": 5, + "2026-08-25": 6, + "2026-08-26": 7, + "2026-08-27": 8, + "2026-08-28": 9, + "2026-08-31": 10, + "2026-09-01": 11, + "2026-09-02": 12, + "2026-09-03": 13, + "2026-09-04": 14, + "2026-09-07": 15, + "2026-09-08": 16, + "2026-09-09": 17, + "2026-09-10": 18, + "2026-09-11": 19, + "2026-09-14": 20, + "2026-09-15": 21, + "2026-09-16": 22, + "2026-09-17": 23, + "2026-09-18": 24, + "2026-09-21": 25, + "2026-09-22": 26 +} diff --git a/apps/api/migrations/0001_init.sql b/apps/api/migrations/0001_init.sql new file mode 100644 index 0000000..a16615e --- /dev/null +++ b/apps/api/migrations/0001_init.sql @@ -0,0 +1,53 @@ +-- SRS schema. D1 owns SRS state (stage, next_review, attempts, gates, +-- boosts); GitHub issues own the catalog columns, reconciled nightly. +CREATE TABLE problems ( + lc_number INTEGER PRIMARY KEY, + issue INTEGER NOT NULL, + topic_issue INTEGER NOT NULL, + title TEXT NOT NULL, + difficulty TEXT NOT NULL, -- easy|medium|hard + set_label TEXT NOT NULL, -- core|optional|deferred + stage TEXT NOT NULL DEFAULT 'new', -- new|+2|+5|+10|retired + next_review TEXT, -- YYYY-MM-DD ET, NULL when retired/unsolved + defer_until TEXT -- deferred Hards: 2026-09-28+ +); + +CREATE TABLE attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lc_number INTEGER NOT NULL REFERENCES problems(lc_number), + date TEXT NOT NULL, + kind TEXT NOT NULL, -- first|review|drill|gate + result TEXT NOT NULL, -- pass|fail + source TEXT NOT NULL -- email|webhook|import +); + +-- One-tap email links must be idempotent (same link twice = no-op), but +-- /done webhook corrections (pass then fail, same day) must stay legal — +-- so uniqueness applies to email-sourced attempts only. +CREATE UNIQUE INDEX attempts_email_once + ON attempts (lc_number, date, kind) WHERE source = 'email'; +CREATE INDEX attempts_by_date ON attempts (date); + +CREATE TABLE topics ( + issue INTEGER PRIMARY KEY, + name TEXT NOT NULL, + misses INTEGER NOT NULL DEFAULT 0, + boost INTEGER NOT NULL DEFAULT 0, + milestone INTEGER -- phase; from the topic issue +); + +CREATE TABLE gates ( + week INTEGER PRIMARY KEY, -- ISO week + issue INTEGER, + problems TEXT NOT NULL, -- JSON array of lc_numbers + pass_rate REAL, + closed_on TEXT +); + +CREATE TABLE drill_pool_used (lc_number INTEGER PRIMARY KEY); + +-- Digest idempotency: one email per ET date. +CREATE TABLE email_log ( + date TEXT PRIMARY KEY, -- YYYY-MM-DD ET + sent_at TEXT NOT NULL +); diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..ad48a25 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,21 @@ +{ + "name": "srs-api", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev --test-scheduled", + "deploy": "wrangler deploy", + "types": "wrangler types", + "test": "bun test src" + }, + "devDependencies": { + "@types/react": "^19.2.18", + "wrangler": "^4.125.0" + }, + "dependencies": { + "@react-email/components": "^1.0.12", + "@react-email/render": "^2.1.0", + "react": "^19.2.8", + "react-dom": "^19.2.8" + } +} diff --git a/apps/api/scripts/import-srs.ts b/apps/api/scripts/import-srs.ts new file mode 100755 index 0000000..848183b --- /dev/null +++ b/apps/api/scripts/import-srs.ts @@ -0,0 +1,94 @@ +#!/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 new file mode 100644 index 0000000..0f1afc4 --- /dev/null +++ b/apps/api/src/catalog.ts @@ -0,0 +1,92 @@ +/** + * Nightly GitHub → D1 catalog reconcile. GitHub issues own the catalog + * (topics, problems, set/diff labels, milestones); this recomputes desired + * rows from scratch on every run — re-runs are no-ops. SRS-owned columns + * (stage, next_review) are NEVER overwritten; defer_until is set once, on + * first sight of a deferred problem. The Worker never invents catalog rows. + */ +import { addDays } from "./srs.ts"; +import type { GitHub } from "./github.ts"; + +const TITLE_RE = /^LC (\d+) · (.+) · (Easy|Medium|Hard) · (core|optional|deferred)$/; + +/** Deferred Hards enter the queue from this date, two per day. */ +const DEFER_FROM = "2026-09-28"; + +export interface ReconcileReport { + topics: number; + problems: number; +} + +export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise { + interface TopicIssue { + number: number; + title: string; + milestone: number | null; + } + const topics: TopicIssue[] = []; + for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) { + if (!raw || typeof raw !== "object") continue; + if ("pull_request" in raw) continue; + if (!("number" in raw) || typeof raw.number !== "number") continue; + if (!("title" in raw) || typeof raw.title !== "string") continue; + let milestone: number | null = null; + if ( + "milestone" in raw && + raw.milestone && + typeof raw.milestone === "object" && + "number" in raw.milestone && + typeof raw.milestone.number === "number" + ) { + milestone = raw.milestone.number; + } + topics.push({ number: raw.number, title: raw.title, milestone }); + } + + const statements: D1PreparedStatement[] = []; + for (const t of topics) { + statements.push( + db + .prepare( + `INSERT INTO topics (issue, name, milestone) VALUES (?1, ?2, ?3) + ON CONFLICT(issue) DO UPDATE SET name = ?2, milestone = ?3`, + ) + .bind(t.number, t.title.replace(/^Topic \d+ — /, ""), t.milestone), + ); + } + + let problems = 0; + let deferredSeen = 0; + for (const t of topics) { + for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) { + if (!raw || typeof raw !== "object") continue; + if (!("number" in raw) || typeof raw.number !== "number") continue; + if (!("title" in raw) || typeof raw.title !== "string") continue; + const m = raw.title.match(TITLE_RE); + if (!m) continue; // non-curriculum sub-issue + const lc = Number(m[1]); + const set = m[4]!; + // Two deferred Hards per day from DEFER_FROM, in catalog walk order — + // applied only when the row is first created (SRS owns it afterwards). + const defer = set === "deferred" ? addDays(DEFER_FROM, Math.floor(deferredSeen / 2)) : null; + if (set === "deferred") deferredSeen++; + statements.push( + db + .prepare( + `INSERT INTO problems (lc_number, issue, topic_issue, title, difficulty, set_label, defer_until, next_review) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) + ON CONFLICT(lc_number) DO UPDATE SET + issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`, + ) + .bind(lc, raw.number, t.number, m[2]!, m[3]!.toLowerCase(), set, defer), + ); + problems++; + } + } + + // D1 batches are transactional; chunk to stay under statement limits. + for (let i = 0; i < statements.length; i += 50) { + await db.batch(statements.slice(i, i + 50)); + } + return { topics: topics.length, problems }; +} diff --git a/apps/api/src/charts.ts b/apps/api/src/charts.ts new file mode 100644 index 0000000..6411fd9 --- /dev/null +++ b/apps/api/src/charts.ts @@ -0,0 +1,406 @@ +/** + * Hand-rolled SVG chart generation for the SRS Worker. + * + * Feeds five endpoints — /chart/progress.svg, /chart/ladder.svg, + * /chart/heatmap.svg, /chart/heatmap.png, /badge/gate.svg — each served with + * `Cache-Control: public, max-age=300`. The heatmap has a raster twin because + * every major email client blocks SVG, so the digest cannot embed the vector. + * + * The SVG is string-built: no chart library, no dependencies, and the one + * import is the hand-rolled PNG encoder next door. The only dynamic values + * entering the markup are numbers, percentages, and dates the Worker itself + * computes, plus the fixed phase/stage labels — so nothing here needs XML + * escaping. Styling is shadcn dark zinc to match the README's shieldcn + * badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode + * green ramp. Every function renders on a zero-row DB. + */ +import { Canvas, textWidth } from "./png.ts"; + +// D1Database comes from the generated worker-configuration.d.ts runtime types. + +const FONT = "Verdana,DejaVu Sans,sans-serif"; + +// shadcn dark-zinc palette, matching the README's shieldcn badges. +const BG = "#09090b"; // zinc-950 card +const FG = "#fafafa"; // zinc-50 text +const MUTED = "#a1a1aa"; // zinc-400 secondary text +const LINE = "#27272a"; // zinc-800 structure / empty +const GREEN = "#16a34a"; // green-600 (core / pass) +const BLUE = "#2563eb"; // blue-600 (optional) +const RED = "#f87171"; // red-400 (fail, on dark) + +// Green ramp shared by the heatmap and the ladder (GitHub dark-mode +// contribution hues; level 0 is the empty zinc cell). +const GREENS = ["#27272a", "#0e4429", "#006d32", "#26a641", "#39d353"]; + +// Labels are stored SVG-ready: phase II's "&" is pre-escaped since these +// strings go straight into markup and nothing else here needs escaping. +const PHASES = [ + { milestone: 1, name: "I — Linear" }, + { milestone: 2, name: "II — Nodal & Grid" }, + { milestone: 3, name: "III — Hierarchical" }, + { milestone: 4, name: "IV — Relational" }, + { milestone: 5, name: "V — Decision Space" }, +]; + +const STAGES = ["new", "+2", "+5", "+10", "retired"]; + +// ── svg helpers ────────────────────────────────────────────────── + +/** Opening tag plus the rounded dark card every chart starts with. */ +function svgOpen(width: number, height: number): string { + return ( + `` + + `` + ); +} + +/** A element; charts place dozens of these with the same defaults. */ +function text( + x: number, + y: number, + content: string, + attrs: { size?: number; fill?: string; anchor?: string; weight?: string } = {}, +): string { + const { size = 12, fill = FG, anchor = "start", weight } = attrs; + const bold = weight ? ` font-weight="${weight}"` : ""; + return `${content}`; +} + +// ── progress: stacked bar per phase ────────────────────────────── + +interface ProgressRow { + milestone: number; + set_label: string; + done: number; + total: number; +} + +export async function progressChart(db: D1Database): Promise { + const { results } = await db + .prepare( + `SELECT t.milestone AS milestone, p.set_label AS set_label, + SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done, + COUNT(*) AS total + FROM problems p JOIN topics t ON p.topic_issue = t.issue + WHERE t.milestone IS NOT NULL + GROUP BY t.milestone, p.set_label`, + ) + .all(); + + // Per phase: core done / optional done / remaining (all-set total − all done). + const phases = PHASES.map((phase) => { + const rows = results.filter((r) => r.milestone === phase.milestone); + const total = rows.reduce((n, r) => n + r.total, 0); + const done = rows.reduce((n, r) => n + r.done, 0); + return { + name: phase.name, + coreDone: rows.find((r) => r.set_label === "core")?.done ?? 0, + optionalDone: rows.find((r) => r.set_label === "optional")?.done ?? 0, + remaining: total - done, + done, + total, + }; + }); + + const width = 640; + const height = 300; + const barX = 168; + const barMaxW = 400; + const barH = 22; + const rowStep = 48; + const scale = Math.max(1, ...phases.map((p) => p.total)); + + const parts = [svgOpen(width, height)]; + + // Legend on top. + const legend: [string, string][] = [ + [GREEN, "core done"], + [BLUE, "optional done"], + [LINE, "remaining"], + ]; + let lx = barX; + for (const [color, label] of legend) { + parts.push(``); + parts.push(text(lx + 17, 22, label, { size: 11, fill: MUTED })); + lx += 17 + label.length * 7 + 24; + } + + phases.forEach((p, i) => { + const y = 52 + i * rowStep; + const midY = y + barH / 2 + 4; + parts.push(text(barX - 12, midY, p.name, { anchor: "end" })); + + let x = barX; + const segments: [number, string][] = [ + [p.coreDone, GREEN], + [p.optionalDone, BLUE], + [p.remaining, LINE], + ]; + for (const [count, color] of segments) { + const w = (count / scale) * barMaxW; + if (w > 0) { + parts.push(``); + // Count inside the segment when it fits; tiny slivers stay unlabeled. + if (w >= 20) { + const labelFill = color === LINE ? MUTED : FG; + parts.push(text(x + w / 2, midY, String(count), { size: 11, fill: labelFill, anchor: "middle" })); + } + x += w; + } + } + parts.push(text(x + 8, midY, `${p.done}/${p.total}`, { size: 11, fill: MUTED })); + }); + + parts.push(""); + return parts.join(""); +} + +// ── ladder: bar per stage ──────────────────────────────────────── + +export async function ladderChart(db: D1Database): Promise { + const { results } = await db + .prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`) + .all<{ stage: string; count: number }>(); + + const counts = STAGES.map((s) => results.find((r) => r.stage === s)?.count ?? 0); + + const width = 640; + const height = 220; + const plotTop = 26; + const plotBottom = height - 32; + const plotH = plotBottom - plotTop; + const slotW = (width - 80) / STAGES.length; + const barW = 64; + const scale = Math.max(1, ...counts); + + const parts = [svgOpen(width, height)]; + + counts.forEach((count, i) => { + const cx = 40 + slotW * i + slotW / 2; + const barH = (count / scale) * plotH; + const y = plotBottom - barH; + if (barH > 0) { + parts.push( + ``, + ); + } + parts.push(text(cx, Math.max(y - 6, 16), String(count), { size: 12, anchor: "middle", weight: "bold" })); + parts.push(text(cx, plotBottom + 18, STAGES[i]!, { size: 12, fill: MUTED, anchor: "middle" })); + }); + + parts.push(``); + parts.push(""); + return parts.join(""); +} + +// ── heatmap: attempts per campaign day ─────────────────────────── + +const DAY_MS = 86_400_000; + +// Layout in CSS pixels. heatmapPng multiplies every one of these by its device +// scale, so the vector and raster pictures cannot drift apart. +const HEAT_W = 560; +const HEAT_H = 160; +const HEAT_GRID_X = 34; +const HEAT_GRID_Y = 24; +const HEAT_CELL = 16; +const HEAT_STEP = 19; // cell + 3px gap + +// Grid row → left-hand label; both renderers print only these three. +const WEEKDAYS: [number, string][] = [ + [0, "Mon"], + [2, "Wed"], + [4, "Fri"], +]; + +/** One grid square: column, Mon-based row, and index into GREENS. */ +interface HeatmapCell { + col: number; + row: number; + level: number; +} + +/** + * The heatmap's whole data model — one cell per campaign day, plus the week + * count that positions the legend — shared by both renderers. + * + * Every per-day date derives from one UTC-midnight timestamp, so local-timezone + * drift never shifts a cell. The campaign starts on a Monday, so day i sits at + * column i/7; the row comes from the real weekday, so an off-Monday start still + * lands correctly. + */ +async function heatmapCells( + db: D1Database, + start: string, + days: number, +): Promise<{ cells: HeatmapCell[]; weeks: number }> { + const [sy, sm, sd] = start.split("-").map(Number); + const base = Date.UTC(sy!, sm! - 1, sd!); + const end = new Date(base + (days - 1) * DAY_MS).toISOString().slice(0, 10); + + const { results } = await db + .prepare(`SELECT date, COUNT(*) AS attempts FROM attempts WHERE date >= ?1 AND date <= ?2 GROUP BY date`) + .bind(start, end) + .all<{ date: string; attempts: number }>(); + + const byDate = new Map(results.map((r) => [r.date, r.attempts])); + + const cells: HeatmapCell[] = []; + for (let i = 0; i < days; i++) { + const day = new Date(base + i * DAY_MS); + const attempts = byDate.get(day.toISOString().slice(0, 10)) ?? 0; + cells.push({ + col: Math.floor(i / 7), + row: (day.getUTCDay() + 6) % 7, // Mon = 0 + level: Math.min(attempts, 4), + }); + } + + return { cells, weeks: Math.ceil(days / 7) }; +} + +export async function heatmapChart(db: D1Database, start: string, days: number): Promise { + const { cells, weeks } = await heatmapCells(db, start, days); + + const parts = [svgOpen(HEAT_W, HEAT_H)]; + + // Week numbers across the top, Mon/Wed/Fri down the left. + for (let w = 0; w < weeks; w++) { + parts.push( + text(HEAT_GRID_X + w * HEAT_STEP + HEAT_CELL / 2, HEAT_GRID_Y - 7, `W${w + 1}`, { + size: 10, + fill: MUTED, + anchor: "middle", + }), + ); + } + for (const [row, label] of WEEKDAYS) { + parts.push( + text(HEAT_GRID_X - 6, HEAT_GRID_Y + row * HEAT_STEP + HEAT_CELL - 4, label, { + size: 10, + fill: MUTED, + anchor: "end", + }), + ); + } + + for (const { col, row, level } of cells) { + parts.push( + ``, + ); + } + + // Less → More ramp fills the space right of the grid. + const legendX = HEAT_GRID_X + weeks * HEAT_STEP + 40; + const legendY = HEAT_GRID_Y + 3 * HEAT_STEP; + parts.push(text(legendX - 6, legendY + HEAT_CELL - 4, "Less", { size: 10, fill: MUTED, anchor: "end" })); + GREENS.forEach((color, i) => { + parts.push( + ``, + ); + }); + parts.push(text(legendX + GREENS.length * HEAT_STEP + 3, legendY + HEAT_CELL - 4, "More", { size: 10, fill: MUTED })); + + parts.push(""); + return parts.join(""); +} + +// The PNG carries no alpha channel, so whatever the rounded card does not cover +// has to be painted with the colour sitting behind the image: the digest +// email's GitHub-dark card. +const EMAIL_CARD = "#0d1117"; + +// Device-pixel multiplier. The email hands the image the full 552px card +// width, so 3x lands a little over 2x density on a retina screen; the labels +// stay 10 CSS px, the size of the SVG's. Flat colour compresses to a few KB +// either way, so the extra resolution is close to free. +const HEAT_SCALE = 3; + +/** + * The same picture as heatmapChart, rastered for email. + * + * Two deliberate departures from the SVG. The canvas is trimmed to the width + * the content actually occupies instead of HEAT_W: the README's SVG sits in a + * narrow table cell and scales up, whereas the email gives the image the full + * 552px card, and the SVG's slack right margin would otherwise shrink the + * grid to nothing. And the bitmap font hangs off a top-left origin rather than + * a baseline, so each SVG baseline becomes "baseline minus one cap height". + */ +export async function heatmapPng(db: D1Database, start: string, days: number): Promise { + const { cells, weeks } = await heatmapCells(db, start, days); + + const s = HEAT_SCALE; + const gridX = HEAT_GRID_X * s; + const gridY = HEAT_GRID_Y * s; + const cell = HEAT_CELL * s; + const step = HEAT_STEP * s; + const capH = 7 * s; + + const legendX = gridX + weeks * step + 40 * s; + const legendY = gridY + 3 * step; + const moreX = legendX + GREENS.length * step + 3 * s; + const width = moreX + textWidth("More", s) + gridX; + const height = HEAT_H * s; + + const canvas = new Canvas(width, height, EMAIL_CARD); + canvas.rect(0, 0, width, height, BG, 6 * s); + + for (let w = 0; w < weeks; w++) { + const label = `W${w + 1}`; + const middle = gridX + w * step + cell / 2; + canvas.text(middle - textWidth(label, s) / 2, gridY - 7 * s - capH, label, MUTED, s); + } + for (const [row, label] of WEEKDAYS) { + canvas.text( + gridX - 6 * s - textWidth(label, s), + gridY + row * step + cell - 4 * s - capH, + label, + MUTED, + s, + ); + } + + for (const { col, row, level } of cells) { + canvas.rect(gridX + col * step, gridY + row * step, cell, cell, GREENS[level]!, 2 * s); + } + + const legendBase = legendY + cell - 4 * s - capH; + canvas.text(legendX - 6 * s - textWidth("Less", s), legendBase, "Less", MUTED, s); + GREENS.forEach((color, i) => { + canvas.rect(legendX + i * step, legendY, cell, cell, color, 2 * s); + }); + canvas.text(moreX, legendBase, "More", MUTED, s); + + return await canvas.encode(); +} + +// ── gate badge: shieldcn-style dark pill ───────────────────────── + +export async function gateBadge(db: D1Database): Promise { + const row = await db + .prepare(`SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1`) + .first<{ pass_rate: number }>(); + + const label = "gate"; + const value = row ? `${Math.round(row.pass_rate * 100)}%` : "none yet"; + const valueFill = row ? (row.pass_rate >= 0.7 ? "#4ade80" : RED) : MUTED; + + // shieldcn geometry: height 32, rx 6, one flat zinc-900 pill. Verdana 13px + // ≈ 7.5px per char; 12px outer padding, 8px between label and value. + const labelW = Math.round(label.length * 7.5); + const valueW = Math.round(value.length * 7.5); + const total = 12 + labelW + 8 + valueW + 12; + + return ( + `` + + `` + + `` + + `${label}` + + `${value}` + + `` + + `` + ); +} diff --git a/apps/api/src/digest.ts b/apps/api/src/digest.ts new file mode 100644 index 0000000..fd8b89d --- /dev/null +++ b/apps/api/src/digest.ts @@ -0,0 +1,180 @@ +/** + * The daily digest email — built from D1 + the bundled schedule, sent at + * 8 AM ET via the send_email binding. This module owns data only: it turns + * D1 rows into a `DigestData` and hands it to email.tsx, which renders both + * the HTML and the plain-text alternative from that single tree (React Email + * `render`), so the two can never drift. + * + * Retrieval rules: review and drill rows carry number + difficulty only — + * never the topic, never a solution link. `DigestRow` has no field for + * either, so the rule holds by construction. The learning day's core list is + * the only labeled section. Reviews + drills ≤ 6, reviews first, overflow + * simply stays due (oldest tomorrow). Sunday is a two-line rest note. + * + * Idempotency: email_log keys sends by ET date — a same-day re-send is a + * no-op unless forced; the body itself is deterministic (drill picks are + * seeded by the date). + */ +import { + CAMPAIGN_DAYS, + type ProblemRow, + SCHEDULE, + addDays, + campaignDay, + campaignWeek, + dueReviews, + isoWeek, + pickDrills, + prettyDate, + streak, + weekdayOf, +} from "./srs.ts"; +import { type DigestData, type DigestRow, renderDigest } from "./email.tsx"; +import { signLink } from "./links.ts"; + +const DAILY_CAP = 6; + +export interface Digest { + subject: string; + html: string; + text: string; +} + +/** A problem row plus its signed one-tap pass/fail URLs. */ +async function tapRow(env: Env, p: ProblemRow, date: string, staged: boolean): Promise { + const [pass, fail] = await Promise.all([ + signLink(env.LINK_KEY, p.lc_number, "pass", date), + signLink(env.LINK_KEY, p.lc_number, "fail", date), + ]); + const base = `${env.PUBLIC_URL}/log?p=${p.lc_number}&d=${date}&r=`; + return { + lc: p.lc_number, + difficulty: p.difficulty, + ...(staged ? { stage: p.stage } : {}), + passUrl: `${base}pass&sig=${pass}`, + failUrl: `${base}fail&sig=${fail}`, + }; +} + +/** Everything the email needs, read straight out of D1 and the schedule. */ +export async function collectDigest(env: Env, date: string): Promise { + const db = env.DB; + const rest = weekdayOf(date) === 0; + + // Reviews take the cap first; drills fill whatever is left. + const due = rest ? [] : await dueReviews(db, date); + const capped = due.slice(0, DAILY_CAP); + const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - capped.length); + + const data: DigestData = { + day: prettyDate(date), + progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`, + streak: await streak(db, date), + rest, + reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))), + carried: due.length - capped.length, + drills: await Promise.all(drills.map((p) => tapRow(env, p, date, false))), + yesterday: { total: 0, failed: [] }, + heatmapUrl: `${env.PUBLIC_URL}/chart/heatmap.png`, + progressUrl: `${env.DOCS_URL}/progress`, + }; + + // New topic — the only section allowed to name problems and link them. + const topicIssue = rest ? undefined : SCHEDULE[date]; + if (topicIssue !== undefined) { + const topic = await db + .prepare("SELECT name FROM topics WHERE issue = ?") + .bind(topicIssue) + .first<{ name: string }>(); + const { results: core } = await db + .prepare( + "SELECT * FROM problems WHERE topic_issue = ? AND set_label = 'core' ORDER BY lc_number", + ) + .bind(topicIssue) + .all(); + data.topic = { + name: topic?.name ?? `#${topicIssue}`, + core: core.map((p) => ({ + lc: p.lc_number, + url: `https://github.com/${env.REPO}/issues/${p.issue}`, + solved: p.stage !== "new", + })), + }; + } + + // Saturday: the review issue already exists (created at midnight ET). + if (weekdayOf(date) === 6) { + const gate = await db + .prepare("SELECT issue FROM gates WHERE week = ? AND issue IS NOT NULL") + .bind(isoWeek(date)) + .first<{ issue: number }>(); + if (gate) { + data.gate = { + week: campaignWeek(date), + url: `https://github.com/${env.REPO}/issues/${gate.issue}`, + }; + } + } + + // Footer: yesterday's log summarised (failures named — they are the + // actionable part) and the gate rate to date. + const { results: logged } = await db + .prepare("SELECT lc_number, result FROM attempts WHERE date = ? ORDER BY id") + .bind(addDays(date, -1)) + .all<{ lc_number: number; result: string }>(); + data.yesterday = { + total: logged.length, + failed: logged.filter((a) => a.result !== "pass").map((a) => a.lc_number), + }; + const lastGate = await db + .prepare("SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1") + .first<{ pass_rate: number }>(); + if (lastGate) data.gateRate = lastGate.pass_rate; + + return data; +} + +export async function buildDigest(env: Env, date: string): Promise { + const data = await collectDigest(env, date); + const { html, text } = await renderDigest(data); + return { + subject: `(Day ${campaignDay(date)}/${CAMPAIGN_DAYS}) LeetCode Daily Digest`, + html, + text, + }; +} + +export interface SendReport { + sent: boolean; + reason: string; + digest: Digest; +} + +/** Send today's digest exactly once per ET date (unless forced). */ +export async function sendDigest( + env: Env, + date: string, + opts: { force?: boolean; dry?: boolean } = {}, +): Promise { + const digest = await buildDigest(env, date); + if (opts.dry) return { sent: false, reason: "dry run", digest }; + + const already = await env.DB.prepare("SELECT sent_at FROM email_log WHERE date = ?") + .bind(date) + .first(); + if (already && !opts.force) return { sent: false, reason: "already sent today", digest }; + + await env.EMAIL.send({ + to: env.TO_EMAIL, + from: { email: env.FROM_EMAIL, name: "SRS" }, + subject: digest.subject, + html: digest.html, + text: digest.text, + }); + await env.DB.prepare( + "INSERT INTO email_log (date, sent_at) VALUES (?, ?) ON CONFLICT(date) DO UPDATE SET sent_at = excluded.sent_at", + ) + .bind(date, new Date().toISOString()) + .run(); + return { sent: true, reason: already ? "forced re-send" : "sent", digest }; +} diff --git a/apps/api/src/email.tsx b/apps/api/src/email.tsx new file mode 100644 index 0000000..f023638 --- /dev/null +++ b/apps/api/src/email.tsx @@ -0,0 +1,451 @@ +/** + * Presentation layer for the daily digest — React Email components rendered + * to HTML (and to the plain-text alternative) inside the Worker. + * + * This file owns *only* layout and wording. Every number, URL and signature + * is computed in digest.ts and handed over as `DigestData`, so the retrieval + * rules (review/drill rows carry number + difficulty only — never the topic, + * never a solution link) are enforced by what the data model can express: + * `DigestRow` has no title and no issue field. + * + * Dark by design. The theme is hard-coded rather than left to the client's + * dark-mode heuristics: `color-scheme: dark` tells Apple Mail and Outlook not + * to re-invert it, and the canvas colour is painted by a full-width
+ * table because Gmail drops styles on . + * + * Other email constraints that shape the markup: inline styles only (clients + * strip