mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
Migrate SRS to Cloudflare Worker: D1 state, email digest, review issues, live charts
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,81 @@
|
||||
# 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.
|
||||
|
||||
## 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 <n> 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 <LINK_KEY>` | 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.
|
||||
|
||||
## 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 api/, or `bun run api:deploy` at the repo root
|
||||
```
|
||||
|
||||
Manual by design — never coupled to the docs deploy. After changing
|
||||
bindings, rerun `bunx wrangler types`.
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "srs-api",
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.125.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
|
||||
|
||||
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260820.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-5F2/t7SVnugG3rscSe9da1LoHst+GiuVGPaE9BP6j5AonlFpiYi0eoJrl3wof9zDBIIgJZ87NjRj9TKjbbYgHg=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260820.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jFnG7715+r9FXRZPpuWWe5Ayd9v/IJKDODARg56ffFJWWtte6bFNi5VY3GazBM04hEmxny6OjXQBh3j2E/wNZw=="],
|
||||
|
||||
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260820.1", "", { "os": "linux", "cpu": "x64" }, "sha512-TbTYaCBht0OaOWmnVpg43hXVYtIti/Kg6lvO819H2DwbxPpK1BH0z2C+Y7ySrVJLlxZQeic5eN7/noqbP80hJg=="],
|
||||
|
||||
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260820.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-FQmri1UF7hBnnpeyC5SZJCAnsSRs3+Ykn9wlO5zxmE2qIgKfbJZ+toJ6qrQyugWlxE65juT33HU6aYLtutLJHw=="],
|
||||
|
||||
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260820.1", "", { "os": "win32", "cpu": "x64" }, "sha512-BPvCuMxIQfA47wtYsGbBG6Bcar57Qs7yHqU2jWkT1+sRnL/741/ZbQGP9kVRMQ5fwBMuqNFmQRzAu8gSm7ri/w=="],
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="],
|
||||
|
||||
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="],
|
||||
|
||||
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
|
||||
|
||||
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
|
||||
|
||||
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||
|
||||
"@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="],
|
||||
|
||||
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"miniflare": ["miniflare@5.20260820.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260820.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-Bv1j2kcKKNwXLWuCx+j0xGt7z318mqQkJmEN6ellM9sCESbPBDTM9ofZMbKqx47jSnoGA3CiaUkAmzGVXUa/wQ=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="],
|
||||
|
||||
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
|
||||
|
||||
"workerd": ["workerd@1.20260820.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260820.1", "@cloudflare/workerd-darwin-arm64": "1.20260820.1", "@cloudflare/workerd-linux-64": "1.20260820.1", "@cloudflare/workerd-linux-arm64": "1.20260820.1", "@cloudflare/workerd-windows-64": "1.20260820.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-/wk4rFNHH6IVMXFe6aPEZsT5YWpWtfKTUMt7+rFkyeGbXcGCy4yxODmJatbqYj0jmqHETdz0+m2mT+vNjXnF7w=="],
|
||||
|
||||
"wrangler": ["wrangler@4.125.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260820.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260820.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260820.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-yFpvggu+xk1Hdm/Uxwaqa19bb7GArME4CrCS3Vov68a2TZq2MPO+wLocKbbnIC9K0oLowcdau7/ycxbbNHKCEg=="],
|
||||
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "srs-api",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev --test-scheduled",
|
||||
"deploy": "wrangler deploy",
|
||||
"types": "wrangler types",
|
||||
"test": "bun test src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.125.0"
|
||||
}
|
||||
}
|
||||
Executable
+94
@@ -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 api/scripts/import-srs.ts # local D1 (wrangler dev state)
|
||||
* bun api/scripts/import-srs.ts --remote # production D1
|
||||
*
|
||||
* Prints row counts; verify them against srs.json before deleting anything.
|
||||
*/
|
||||
import { $ } from "bun";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..", "..");
|
||||
const API = join(ROOT, "api");
|
||||
const REMOTE = process.argv.includes("--remote");
|
||||
|
||||
interface Attempt {
|
||||
date: string;
|
||||
kind: string;
|
||||
result: string;
|
||||
}
|
||||
interface Problem {
|
||||
issue: number;
|
||||
topic: number;
|
||||
difficulty: string;
|
||||
set: string;
|
||||
solved_on?: string;
|
||||
stage: string;
|
||||
next_review?: string;
|
||||
defer_until?: string;
|
||||
history: Attempt[];
|
||||
}
|
||||
interface State {
|
||||
problems: Record<string, Problem>;
|
||||
topics: Record<string, { misses: number; boost: boolean }>;
|
||||
drill_pool_used: number[];
|
||||
}
|
||||
|
||||
const state: State = JSON.parse(
|
||||
await Bun.file(join(ROOT, ".github", "srs", "srs.json")).text(),
|
||||
);
|
||||
|
||||
const q = (v: string | null | undefined) => (v == null ? "NULL" : `'${v}'`);
|
||||
const lines: string[] = ["DELETE FROM attempts WHERE source = 'import';"];
|
||||
|
||||
let attempts = 0;
|
||||
for (const [lc, p] of Object.entries(state.problems)) {
|
||||
lines.push(
|
||||
`UPDATE problems SET stage = ${q(p.stage)}, next_review = ${q(p.next_review ?? null)}, ` +
|
||||
`defer_until = ${q(p.defer_until ?? null)} WHERE lc_number = ${Number(lc)};`,
|
||||
);
|
||||
for (const a of p.history) {
|
||||
lines.push(
|
||||
`INSERT INTO attempts (lc_number, date, kind, result, source) ` +
|
||||
`VALUES (${Number(lc)}, ${q(a.date)}, ${q(a.kind)}, ${q(a.result)}, 'import');`,
|
||||
);
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
for (const [topic, t] of Object.entries(state.topics)) {
|
||||
lines.push(
|
||||
`UPDATE topics SET misses = ${t.misses}, boost = ${t.boost ? 1 : 0} WHERE issue = ${Number(topic)};`,
|
||||
);
|
||||
}
|
||||
for (const lc of state.drill_pool_used) {
|
||||
lines.push(`INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (${lc});`);
|
||||
}
|
||||
|
||||
const sqlPath = join(API, "migrations", ".import.sql");
|
||||
await Bun.write(sqlPath, lines.join("\n") + "\n");
|
||||
const flag = REMOTE ? "--remote" : "--local";
|
||||
await $`bunx wrangler d1 execute srs ${flag} --file ${sqlPath}`.cwd(API);
|
||||
await $`rm ${sqlPath}`;
|
||||
|
||||
const counts =
|
||||
await $`bunx wrangler d1 execute srs ${flag} --json --command ${"SELECT (SELECT COUNT(*) FROM problems) AS problems, (SELECT COUNT(*) FROM problems WHERE stage != 'new') AS laddered, (SELECT COUNT(*) FROM problems WHERE defer_until IS NOT NULL) AS deferred, (SELECT COUNT(*) FROM attempts WHERE source='import') AS imported_attempts, (SELECT COUNT(*) FROM topics) AS topics, (SELECT COUNT(*) FROM drill_pool_used) AS drills_used"}`
|
||||
.cwd(API)
|
||||
.json();
|
||||
|
||||
const expected = {
|
||||
json_problems: Object.keys(state.problems).length,
|
||||
json_attempts: attempts,
|
||||
json_laddered: Object.values(state.problems).filter((p) => p.stage !== "new").length,
|
||||
json_deferred: Object.values(state.problems).filter((p) => p.defer_until).length,
|
||||
json_topics: Object.keys(state.topics).length,
|
||||
json_drills_used: state.drill_pool_used.length,
|
||||
};
|
||||
console.log("expected from srs.json:", JSON.stringify(expected));
|
||||
console.log("in D1:", JSON.stringify(counts[0]?.results?.[0] ?? counts));
|
||||
@@ -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<ReconcileReport> {
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Hand-rolled SVG chart generation for the SRS Worker.
|
||||
*
|
||||
* Feeds four endpoints — /chart/progress.svg, /chart/ladder.svg,
|
||||
* /chart/heatmap.svg, /badge/gate.svg — each served with
|
||||
* `Cache-Control: public, max-age=300`.
|
||||
*
|
||||
* Everything is string-built: no chart library, no runtime imports. 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. GitHub renders these on white; no dark-mode variants.
|
||||
* Every function renders a valid (if empty-looking) SVG on a zero-row DB.
|
||||
*/
|
||||
// D1Database comes from the generated worker-configuration.d.ts runtime types.
|
||||
|
||||
const FONT = "Verdana,DejaVu Sans,sans-serif";
|
||||
|
||||
// Green ramp shared by the heatmap and the ladder (GitHub contribution hues).
|
||||
const GREENS = ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"];
|
||||
|
||||
// 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 white background rect every chart starts with. */
|
||||
function svgOpen(width: number, height: number): string {
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
|
||||
`viewBox="0 0 ${width} ${height}" font-family="${FONT}">` +
|
||||
`<rect width="${width}" height="${height}" fill="#ffffff"/>`
|
||||
);
|
||||
}
|
||||
|
||||
/** A <text> 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 = "#1f2328", anchor = "start", weight } = attrs;
|
||||
const bold = weight ? ` font-weight="${weight}"` : "";
|
||||
return `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" text-anchor="${anchor}"${bold}>${content}</text>`;
|
||||
}
|
||||
|
||||
// ── progress: stacked bar per phase ──────────────────────────────
|
||||
|
||||
interface ProgressRow {
|
||||
milestone: number;
|
||||
set_label: string;
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function progressChart(db: D1Database): Promise<string> {
|
||||
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<ProgressRow>();
|
||||
|
||||
// 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][] = [
|
||||
["#2da44e", "core done"],
|
||||
["#0969da", "optional done"],
|
||||
["#d0d7de", "remaining"],
|
||||
];
|
||||
let lx = barX;
|
||||
for (const [color, label] of legend) {
|
||||
parts.push(`<rect x="${lx}" y="12" width="12" height="12" rx="2" fill="${color}"/>`);
|
||||
parts.push(text(lx + 17, 22, label, { size: 11, fill: "#57606a" }));
|
||||
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, "#2da44e"],
|
||||
[p.optionalDone, "#0969da"],
|
||||
[p.remaining, "#d0d7de"],
|
||||
];
|
||||
for (const [count, color] of segments) {
|
||||
const w = (count / scale) * barMaxW;
|
||||
if (w > 0) {
|
||||
parts.push(`<rect x="${x.toFixed(1)}" y="${y}" width="${w.toFixed(1)}" height="${barH}" fill="${color}"/>`);
|
||||
// Count inside the segment when it fits; tiny slivers stay unlabeled.
|
||||
if (w >= 20) {
|
||||
const labelFill = color === "#d0d7de" ? "#57606a" : "#ffffff";
|
||||
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: "#57606a" }));
|
||||
});
|
||||
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// ── ladder: bar per stage ────────────────────────────────────────
|
||||
|
||||
export async function ladderChart(db: D1Database): Promise<string> {
|
||||
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 = 34;
|
||||
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)];
|
||||
parts.push(text(40, 20, "review ladder", { size: 13, weight: "bold" }));
|
||||
|
||||
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(
|
||||
`<rect x="${(cx - barW / 2).toFixed(1)}" y="${y.toFixed(1)}" width="${barW}" height="${barH.toFixed(1)}" rx="3" fill="${GREENS[i]}"/>`,
|
||||
);
|
||||
}
|
||||
parts.push(text(cx, y - 6, String(count), { size: 12, anchor: "middle", weight: "bold" }));
|
||||
parts.push(text(cx, plotBottom + 18, STAGES[i]!, { size: 12, fill: "#57606a", anchor: "middle" }));
|
||||
});
|
||||
|
||||
parts.push(`<line x1="40" y1="${plotBottom}" x2="${width - 40}" y2="${plotBottom}" stroke="#d0d7de"/>`);
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// ── heatmap: attempts per campaign day ───────────────────────────
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
export async function heatmapChart(db: D1Database, start: string, days: number): Promise<string> {
|
||||
// YYYY-MM-DD → UTC midnight; all per-day dates derive from this timestamp,
|
||||
// so local-timezone drift never shifts a cell.
|
||||
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 width = 560;
|
||||
const height = 160;
|
||||
const gridX = 34;
|
||||
const gridY = 24;
|
||||
const cell = 16;
|
||||
const step = 19; // cell + 3px gap
|
||||
const weeks = Math.ceil(days / 7);
|
||||
|
||||
const parts = [svgOpen(width, height)];
|
||||
|
||||
// Week numbers across the top, Mon/Wed/Fri down the left.
|
||||
for (let w = 0; w < weeks; w++) {
|
||||
parts.push(text(gridX + w * step + cell / 2, gridY - 7, `W${w + 1}`, { size: 10, fill: "#57606a", anchor: "middle" }));
|
||||
}
|
||||
const weekdays: [number, string][] = [
|
||||
[0, "Mon"],
|
||||
[2, "Wed"],
|
||||
[4, "Fri"],
|
||||
];
|
||||
for (const [row, label] of weekdays) {
|
||||
parts.push(text(gridX - 6, gridY + row * step + cell - 4, label, { size: 10, fill: "#57606a", anchor: "end" }));
|
||||
}
|
||||
|
||||
// One cell per campaign day; the start date is a Monday, so day i sits at
|
||||
// column i/7, row i%7 — but the row is derived from the real weekday so an
|
||||
// off-Monday start still lands correctly.
|
||||
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;
|
||||
const col = Math.floor(i / 7);
|
||||
const row = (day.getUTCDay() + 6) % 7; // Mon = 0
|
||||
const color = GREENS[Math.min(attempts, 4)];
|
||||
parts.push(
|
||||
`<rect x="${gridX + col * step}" y="${gridY + row * step}" width="${cell}" height="${cell}" rx="2" fill="${color}"/>`,
|
||||
);
|
||||
}
|
||||
|
||||
// Less → More ramp fills the space right of the grid.
|
||||
const legendX = gridX + weeks * step + 40;
|
||||
const legendY = gridY + 3 * step;
|
||||
parts.push(text(legendX - 6, legendY + cell - 4, "Less", { size: 10, fill: "#57606a", anchor: "end" }));
|
||||
GREENS.forEach((color, i) => {
|
||||
parts.push(`<rect x="${legendX + i * step}" y="${legendY}" width="${cell}" height="${cell}" rx="2" fill="${color}"/>`);
|
||||
});
|
||||
parts.push(text(legendX + GREENS.length * step + 3, legendY + cell - 4, "More", { size: 10, fill: "#57606a" }));
|
||||
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// ── gate badge: shields.io flat pill ─────────────────────────────
|
||||
|
||||
export async function gateBadge(db: D1Database): Promise<string> {
|
||||
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 color = row ? (row.pass_rate >= 0.7 ? "#4c1" : "#e05d44") : "#9f9f9f";
|
||||
|
||||
// Shields flat geometry: height 20, rx 3, verdana 11px, ~6.5px per char
|
||||
// plus 5px padding each side. Each caption is drawn twice — a dark
|
||||
// 30%-opacity copy 1px low as the shadow, then white on top.
|
||||
const labelW = Math.round(label.length * 6.5) + 10;
|
||||
const valueW = Math.round(value.length * 6.5) + 10;
|
||||
const total = labelW + valueW;
|
||||
const labelX = labelW / 2;
|
||||
const valueX = labelW + valueW / 2;
|
||||
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="20" role="img" aria-label="${label}: ${value}">` +
|
||||
`<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>` +
|
||||
`<clipPath id="r"><rect width="${total}" height="20" rx="3" fill="#fff"/></clipPath>` +
|
||||
`<g clip-path="url(#r)">` +
|
||||
`<rect width="${labelW}" height="20" fill="#555"/>` +
|
||||
`<rect x="${labelW}" width="${valueW}" height="20" fill="${color}"/>` +
|
||||
`<rect width="${total}" height="20" fill="url(#s)"/>` +
|
||||
`</g>` +
|
||||
`<g fill="#fff" text-anchor="middle" font-family="${FONT}" font-size="11">` +
|
||||
`<text x="${labelX}" y="15" fill="#010101" fill-opacity=".3">${label}</text>` +
|
||||
`<text x="${labelX}" y="14">${label}</text>` +
|
||||
`<text x="${valueX}" y="15" fill="#010101" fill-opacity=".3">${value}</text>` +
|
||||
`<text x="${valueX}" y="14">${value}</text>` +
|
||||
`</g>` +
|
||||
`</svg>`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* The daily digest email — built from D1 + the bundled schedule, sent at
|
||||
* 8 AM ET via the send_email binding. Plain inline-styled HTML that renders
|
||||
* clean in Gmail mobile; always sent with a text alternative.
|
||||
*
|
||||
* Retrieval rules: review and drill lines carry number + difficulty only —
|
||||
* never the topic, never a solution link. 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 { signLink } from "./links.ts";
|
||||
|
||||
const DAILY_CAP = 6;
|
||||
|
||||
function cap(difficulty: string): string {
|
||||
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
|
||||
}
|
||||
|
||||
export interface Digest {
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** One-tap pass/fail anchor pair for a problem line. */
|
||||
async function tapLinks(env: Env, lc: number, date: string): Promise<string> {
|
||||
const pass = await signLink(env.LINK_KEY, lc, "pass", date);
|
||||
const fail = await signLink(env.LINK_KEY, lc, "fail", date);
|
||||
const url = (r: string, sig: string) =>
|
||||
`${env.PUBLIC_URL}/log?p=${lc}&r=${r}&d=${date}&sig=${sig}`;
|
||||
return (
|
||||
`<a href="${url("pass", pass)}" style="color:#1a7f37;font-weight:bold">pass</a>` +
|
||||
` · <a href="${url("fail", fail)}" style="color:#cf222e;font-weight:bold">fail</a>`
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildDigest(env: Env, date: string): Promise<Digest> {
|
||||
const db = env.DB;
|
||||
const day = campaignDay(date);
|
||||
const week = campaignWeek(date);
|
||||
const header = `${prettyDate(date)} — Day ${day}/${CAMPAIGN_DAYS} · Week ${week}`;
|
||||
|
||||
if (weekdayOf(date) === 0) {
|
||||
return {
|
||||
subject: `SRS — ${prettyDate(date)} — rest day`,
|
||||
html: `<p><strong>${header}</strong></p><p>Rest day. Nothing is due. Overdue reviews moved to Monday.</p>`,
|
||||
text: `${header}\nRest day. Nothing is due. Overdue reviews moved to Monday.`,
|
||||
};
|
||||
}
|
||||
|
||||
const due = await dueReviews(db, date);
|
||||
const reviews = due.slice(0, DAILY_CAP);
|
||||
const carried = due.length - reviews.length;
|
||||
const drills = await pickDrills(db, date, DAILY_CAP - reviews.length);
|
||||
const currentStreak = await streak(db, date);
|
||||
|
||||
const htmlParts: string[] = [];
|
||||
const textParts: string[] = [];
|
||||
htmlParts.push(
|
||||
`<h2 style="margin:0 0 4px">${header}</h2>`,
|
||||
`<p style="margin:0 0 16px;color:#57606a">🔥 streak: ${currentStreak} day${currentStreak === 1 ? "" : "s"}</p>`,
|
||||
);
|
||||
textParts.push(header, `streak: ${currentStreak}`);
|
||||
|
||||
// New topic — the only labeled section.
|
||||
const topicIssue = 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<ProblemRow>();
|
||||
const links = core
|
||||
.map(
|
||||
(p) =>
|
||||
`<a href="https://github.com/${env.REPO}/issues/${p.issue}">LC ${p.lc_number}</a>${p.stage === "new" ? "" : " ✓"}`,
|
||||
)
|
||||
.join(" · ");
|
||||
htmlParts.push(
|
||||
`<h3 style="margin:16px 0 4px">New topic: ${topic?.name ?? `#${topicIssue}`}</h3>`,
|
||||
`<p style="margin:0">Core: ${links}</p>`,
|
||||
);
|
||||
textParts.push(`New topic: ${topic?.name ?? topicIssue} — core: ${core.map((p) => `LC ${p.lc_number}`).join(", ")}`);
|
||||
}
|
||||
|
||||
// Reviews due — unlabeled lines, one-tap links.
|
||||
htmlParts.push(`<h3 style="margin:16px 0 4px">Reviews due (${reviews.length})</h3>`);
|
||||
textParts.push(`Reviews due (${reviews.length})`);
|
||||
if (reviews.length) {
|
||||
htmlParts.push(
|
||||
`<p style="margin:0 0 4px;color:#57606a">Solve each from scratch. Do not open your old solution first.</p>`,
|
||||
);
|
||||
for (const p of reviews) {
|
||||
htmlParts.push(
|
||||
`<p style="margin:2px 0">LC ${p.lc_number} — ${cap(p.difficulty)} — stage ${p.stage} — ${await tapLinks(env, p.lc_number, date)}</p>`,
|
||||
);
|
||||
textParts.push(`- LC ${p.lc_number} — ${cap(p.difficulty)} — stage ${p.stage}`);
|
||||
}
|
||||
if (carried > 0) {
|
||||
htmlParts.push(`<p style="margin:4px 0;color:#57606a">${carried} more carried to tomorrow (cap ${DAILY_CAP}).</p>`);
|
||||
textParts.push(`${carried} more carried to tomorrow.`);
|
||||
}
|
||||
} else {
|
||||
htmlParts.push(`<p style="margin:0">None.</p>`);
|
||||
textParts.push("none");
|
||||
}
|
||||
|
||||
// Blind drills — unlabeled.
|
||||
htmlParts.push(`<h3 style="margin:16px 0 4px">Blind drills (${drills.length})</h3>`);
|
||||
textParts.push(`Blind drills (${drills.length})`);
|
||||
if (drills.length) {
|
||||
htmlParts.push(
|
||||
`<p style="margin:0 0 4px;color:#57606a">No topic given. Name the pattern out loud before you code.</p>`,
|
||||
);
|
||||
for (const p of drills) {
|
||||
htmlParts.push(
|
||||
`<p style="margin:2px 0">LC ${p.lc_number} — ${cap(p.difficulty)} — ${await tapLinks(env, p.lc_number, date)}</p>`,
|
||||
);
|
||||
textParts.push(`- LC ${p.lc_number} — ${cap(p.difficulty)}`);
|
||||
}
|
||||
} else {
|
||||
htmlParts.push(`<p style="margin:0">None today.</p>`);
|
||||
textParts.push("none");
|
||||
}
|
||||
|
||||
// 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) {
|
||||
htmlParts.push(
|
||||
`<h3 style="margin:16px 0 4px">Saturday gate</h3>`,
|
||||
`<p style="margin:0"><a href="https://github.com/${env.REPO}/issues/${gate.issue}">Review — Week ${campaignWeek(date)} (#${gate.issue})</a> — timed, blind, then close the issue.</p>`,
|
||||
);
|
||||
textParts.push(`Saturday gate: https://github.com/${env.REPO}/issues/${gate.issue}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer: yesterday's log, gate rate to date, progress page.
|
||||
const yesterday = addDays(date, -1);
|
||||
const { results: logged } = await db
|
||||
.prepare("SELECT lc_number, kind, result FROM attempts WHERE date = ? ORDER BY id")
|
||||
.bind(yesterday)
|
||||
.all<{ lc_number: number; kind: string; result: string }>();
|
||||
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 }>();
|
||||
const footerBits = [
|
||||
logged.length
|
||||
? `Yesterday: ${logged.map((a) => `LC ${a.lc_number} ${a.result === "pass" ? "✅" : "❌"}`).join(" · ")}`
|
||||
: "Yesterday: no attempts logged.",
|
||||
lastGate ? `Gate pass rate: ${Math.round(lastGate.pass_rate * 100)}%` : "No gates scored yet.",
|
||||
];
|
||||
htmlParts.push(
|
||||
`<hr style="border:none;border-top:1px solid #d0d7de;margin:16px 0">`,
|
||||
`<p style="margin:0;color:#57606a;font-size:13px">${footerBits.join(" · ")} · <a href="${env.DOCS_URL}/progress">progress page</a></p>`,
|
||||
);
|
||||
textParts.push(...footerBits, `progress: ${env.DOCS_URL}/progress`);
|
||||
|
||||
const load = reviews.length + drills.length;
|
||||
return {
|
||||
subject: `SRS — ${prettyDate(date)} — ${load} on deck`,
|
||||
html: `<div style="font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;max-width:600px;margin:0 auto;padding:12px">${htmlParts.join("\n")}</div>`,
|
||||
text: textParts.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
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<SendReport> {
|
||||
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 };
|
||||
}
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Saturday review issue: created at midnight ET so the 8 AM digest can link
|
||||
* to it; scored when the issue closes (webhook).
|
||||
*
|
||||
* 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,
|
||||
* and re-assigning them on Saturday would be massed practice.
|
||||
*
|
||||
* Label is `review` (the retired Actions system owned `gate`). Creation is
|
||||
* idempotent by week; deferred Hards never appear.
|
||||
*/
|
||||
import {
|
||||
type ProblemRow,
|
||||
type Result,
|
||||
SCHEDULE,
|
||||
campaignWeek,
|
||||
isoWeek,
|
||||
rng,
|
||||
sample,
|
||||
streak,
|
||||
weekMonday,
|
||||
} from "./srs.ts";
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
const PASS_TARGET = 0.7;
|
||||
const REVIEW_LABEL = "review";
|
||||
const MAX_GATE_PROBLEMS = 5;
|
||||
|
||||
function capitalized(difficulty: string): string {
|
||||
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
|
||||
}
|
||||
|
||||
/** Unsolved problems of a topic in a set — never attempted, never drilled. */
|
||||
async function freshPool(db: D1Database, topic: number, set: string): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM problems
|
||||
WHERE topic_issue = ?1 AND set_label = ?2 AND stage = 'new' AND defer_until IS NULL
|
||||
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = problems.lc_number)
|
||||
ORDER BY lc_number`,
|
||||
)
|
||||
.bind(topic, set)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface CreateReport {
|
||||
created: boolean;
|
||||
issue?: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export async function createReviewIssue(env: Env, gh: GitHub, date: string): Promise<CreateReport> {
|
||||
const db = env.DB;
|
||||
const week = campaignWeek(date);
|
||||
const iso = isoWeek(date);
|
||||
const title = `Review — Week ${week}`;
|
||||
|
||||
const existing = await db
|
||||
.prepare("SELECT issue FROM gates WHERE week = ? AND issue IS NOT NULL")
|
||||
.bind(iso)
|
||||
.first<{ issue: number }>();
|
||||
if (existing) {
|
||||
return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` };
|
||||
}
|
||||
|
||||
// Topics scheduled this week; review-only weeks (Sep 23 onward) sample
|
||||
// across every topic that still has unsolved optional problems.
|
||||
let topics = Object.entries(SCHEDULE)
|
||||
.filter(([d]) => campaignWeek(d) === week)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([, t]) => t);
|
||||
if (topics.length === 0) {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT DISTINCT topic_issue AS t FROM problems
|
||||
WHERE set_label = 'optional' AND stage = 'new'
|
||||
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||
ORDER BY topic_issue`,
|
||||
)
|
||||
.all<{ t: number }>();
|
||||
topics = sample(results.map((r) => r.t), MAX_GATE_PROBLEMS, rng(`gate-topics-${iso}`)).sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
}
|
||||
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
|
||||
|
||||
const random = rng(`gate-${iso}`);
|
||||
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
||||
for (const topic of topics) {
|
||||
const optional = await freshPool(db, topic, "optional");
|
||||
if (optional.length > 0) {
|
||||
picks.push({ p: sample(optional, 1, random)[0]!, fallback: false });
|
||||
continue;
|
||||
}
|
||||
const core = await freshPool(db, topic, "core");
|
||||
if (core.length > 0) picks.push({ p: sample(core, 1, random)[0]!, fallback: true });
|
||||
// Both pools exhausted: the topic is fully solved; nothing to quiz.
|
||||
}
|
||||
if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` };
|
||||
|
||||
// Recap: everything attempted this week, plus streak — data, not tasks.
|
||||
const monday = weekMonday(date);
|
||||
const { results: attempts } = await db
|
||||
.prepare(
|
||||
`SELECT a.lc_number, a.date, a.kind, a.result FROM attempts a
|
||||
WHERE a.date >= ? AND a.date < ? AND a.source != 'import'
|
||||
ORDER BY a.date, a.id`,
|
||||
)
|
||||
.bind(monday, date)
|
||||
.all<{ lc_number: number; date: string; kind: string; result: string }>();
|
||||
const reviewsDone = attempts.filter((a) => a.kind === "review" && a.result === "pass").length;
|
||||
const currentStreak = await streak(db, date);
|
||||
|
||||
const gateLines = picks.map(
|
||||
({ p, fallback }) =>
|
||||
`- LC ${p.lc_number} — ${capitalized(p.difficulty)}${fallback ? " *(core fallback — optional pool exhausted)*" : ""}`,
|
||||
);
|
||||
const recapLines = attempts.length
|
||||
? attempts.map(
|
||||
(a) => `| LC ${a.lc_number} | ${a.kind} | ${a.result === "pass" ? "✅" : "❌"} | ${a.date} |`,
|
||||
)
|
||||
: ["| — | no attempts logged this week | | |"];
|
||||
const body = [
|
||||
"## Gate — blind set",
|
||||
`**Target: ${Math.round(PASS_TARGET * 100)}% first-attempt pass rate. Timed. Narrate out loud.**`,
|
||||
"No topics given. Log with `/done <n> pass|fail`, then close this issue.",
|
||||
...gateLines,
|
||||
"",
|
||||
`## Week ${week} recap`,
|
||||
`${attempts.length} attempts · ${reviewsDone} reviews passed · streak ${currentStreak}`,
|
||||
"",
|
||||
"| Problem | Kind | Result | Day |",
|
||||
"| --- | --- | --- | --- |",
|
||||
...recapLines,
|
||||
].join("\n");
|
||||
|
||||
// Ensure the `review` label exists (422 = already there).
|
||||
try {
|
||||
await gh.rest(`/repos/${gh.repo}/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: REVIEW_LABEL,
|
||||
color: "5319e7",
|
||||
description: "Weekly blind gate + recap",
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!String(err).includes("422")) throw err;
|
||||
}
|
||||
|
||||
const milestone = await db
|
||||
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
|
||||
.bind(topics[0])
|
||||
.first<{ milestone: number }>();
|
||||
|
||||
const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
body,
|
||||
labels: [REVIEW_LABEL],
|
||||
milestone: milestone?.milestone,
|
||||
}),
|
||||
})) as { number: number };
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO gates (week, issue, problems) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(week) DO UPDATE SET issue = ?2, problems = ?3`,
|
||||
)
|
||||
.bind(iso, created.number, JSON.stringify(picks.map(({ p }) => p.lc_number)))
|
||||
.run();
|
||||
|
||||
// Last week's boosted topics had their remedial week — clear the flags.
|
||||
await db.prepare("UPDATE topics SET boost = 0 WHERE boost = 1").run();
|
||||
|
||||
return { created: true, issue: created.number, reason: `created #${created.number}` };
|
||||
}
|
||||
|
||||
export interface ScoreReport {
|
||||
scored: boolean;
|
||||
rate?: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grade a closed review issue: first-attempt pass rate over its gate set.
|
||||
* Unlogged problems count as failures — skipping a gate problem is not a
|
||||
* pass. Boosts topics that failed a gate problem or reached 2 drill misses;
|
||||
* miss counters reset once consumed. The badge/charts read D1 live, so
|
||||
* "refreshing the badge" is this row update.
|
||||
*/
|
||||
export async function scoreReview(
|
||||
env: Env,
|
||||
gh: GitHub,
|
||||
issueNumber: number,
|
||||
date: string,
|
||||
): Promise<ScoreReport> {
|
||||
const db = env.DB;
|
||||
const gate = await db
|
||||
.prepare("SELECT week, problems, pass_rate FROM gates WHERE issue = ?")
|
||||
.bind(issueNumber)
|
||||
.first<{ week: number; problems: string; pass_rate: number | null }>();
|
||||
if (!gate) return { scored: false, reason: `issue #${issueNumber} has no gate record` };
|
||||
if (gate.pass_rate !== null) return { scored: false, rate: gate.pass_rate, reason: "already scored" };
|
||||
|
||||
const lcs: number[] = JSON.parse(gate.problems);
|
||||
const results: Record<number, Result | undefined> = {};
|
||||
for (const lc of lcs) {
|
||||
const row = await db
|
||||
.prepare("SELECT result FROM attempts WHERE lc_number = ? AND kind = 'gate' ORDER BY id LIMIT 1")
|
||||
.bind(lc)
|
||||
.first<{ result: Result }>();
|
||||
results[lc] = row?.result;
|
||||
}
|
||||
const passes = lcs.filter((lc) => results[lc] === "pass");
|
||||
const unlogged = lcs.filter((lc) => results[lc] === undefined);
|
||||
const rate = passes.length / lcs.length;
|
||||
|
||||
await db
|
||||
.prepare("UPDATE gates SET pass_rate = ?, closed_on = ? WHERE issue = ?")
|
||||
.bind(rate, date, issueNumber)
|
||||
.run();
|
||||
|
||||
const boosted: number[] = [];
|
||||
for (const lc of lcs) {
|
||||
if (results[lc] !== "fail") continue;
|
||||
const p = await db
|
||||
.prepare("SELECT topic_issue FROM problems WHERE lc_number = ?")
|
||||
.bind(lc)
|
||||
.first<{ topic_issue: number }>();
|
||||
if (p) boosted.push(p.topic_issue);
|
||||
}
|
||||
const { results: missed } = await db
|
||||
.prepare("SELECT issue FROM topics WHERE misses >= 2")
|
||||
.all<{ issue: number }>();
|
||||
boosted.push(...missed.map((m) => m.issue));
|
||||
const boostSet = [...new Set(boosted)];
|
||||
if (boostSet.length) {
|
||||
await db.batch(
|
||||
boostSet.map((t) => db.prepare("UPDATE topics SET boost = 1, misses = 0 WHERE issue = ?").bind(t)),
|
||||
);
|
||||
}
|
||||
|
||||
const passed = rate >= PASS_TARGET;
|
||||
const summary = [
|
||||
`## Gate — Week ${gate.week}: **${Math.round(rate * 100)}%** first-attempt (target ${Math.round(PASS_TARGET * 100)}%) — ${passed ? "✅ pass" : "❌ fail"}`,
|
||||
"",
|
||||
...lcs.map((lc) => {
|
||||
const r = results[lc];
|
||||
return `- LC ${lc} — ${r === "pass" ? "✅ pass" : r === "fail" ? "❌ fail" : "⬜ not logged (counted as fail)"}`;
|
||||
}),
|
||||
"",
|
||||
unlogged.length ? `${unlogged.length} problem(s) were never logged.` : "",
|
||||
boostSet.length
|
||||
? `Boosted topics for next week's drills: ${boostSet.map((t) => `#${t}`).join(", ")}.`
|
||||
: "No topics boosted.",
|
||||
"",
|
||||
`Live charts: ${env.PUBLIC_URL}/chart/progress.svg · progress: ${env.DOCS_URL}/progress`,
|
||||
]
|
||||
.filter((l) => l !== "")
|
||||
.join("\n");
|
||||
await gh.comment(issueNumber, summary);
|
||||
|
||||
return { scored: true, rate, reason: `scored ${Math.round(rate * 100)}%` };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* GitHub REST + GraphQL client on GH_PAT. Hand-rolled fetch, no SDK —
|
||||
* matching the repo's dependency-free automation rule.
|
||||
*/
|
||||
|
||||
export interface GitHub {
|
||||
repo: string;
|
||||
rest(path: string, init?: RequestInit): Promise<unknown>;
|
||||
graphql(query: string, variables?: Record<string, unknown>): Promise<unknown>;
|
||||
list(path: string): AsyncGenerator<unknown, void, void>;
|
||||
/** Comment first, then close — a failed close still leaves a visible note. */
|
||||
closeIssue(issue: number, comment: string): Promise<void>;
|
||||
comment(issue: number, body: string): Promise<void>;
|
||||
react(commentId: number, content: string): Promise<void>;
|
||||
}
|
||||
|
||||
const PER_PAGE = 100;
|
||||
|
||||
export function github(token: string, repo: string): GitHub {
|
||||
async function rest(path: string, init: RequestInit = {}): Promise<unknown> {
|
||||
const res = await fetch(`https://api.github.com${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
accept: "application/vnd.github+json",
|
||||
authorization: `Bearer ${token}`,
|
||||
"user-agent": "srs-api",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
...(init.body ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`);
|
||||
}
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
async function graphql(
|
||||
query: string,
|
||||
variables: Record<string, unknown> = {},
|
||||
): Promise<unknown> {
|
||||
const res = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
"user-agent": "srs-api",
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
|
||||
const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
|
||||
if (payload.errors?.length) throw new Error(payload.errors.map((e) => e.message).join("; "));
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
async function* list(path: string): AsyncGenerator<unknown, void, void> {
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
for (let page = 1; ; page++) {
|
||||
const batch = await rest(`${path}${sep}per_page=${PER_PAGE}&page=${page}`);
|
||||
if (!Array.isArray(batch)) throw new Error(`unexpected ${path} payload: not an array`);
|
||||
yield* batch;
|
||||
if (batch.length < PER_PAGE) return;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repo,
|
||||
rest,
|
||||
graphql,
|
||||
list,
|
||||
async closeIssue(issue, comment) {
|
||||
await rest(`/repos/${repo}/issues/${issue}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: comment }),
|
||||
});
|
||||
await rest(`/repos/${repo}/issues/${issue}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
|
||||
});
|
||||
},
|
||||
async comment(issue, body) {
|
||||
await rest(`/repos/${repo}/issues/${issue}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
},
|
||||
async react(commentId, content) {
|
||||
await rest(`/repos/${repo}/issues/comments/${commentId}/reactions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* SRS Worker entry: fetch routes + DST-proof cron dispatch.
|
||||
*
|
||||
* Direction of truth: D1 owns SRS state; GitHub issues own the catalog;
|
||||
* the bundled schedule.json owns the calendar. Every handler writes D1
|
||||
* first and runs GitHub/Project side effects afterwards via ctx.waitUntil —
|
||||
* a mirror failure logs a warning and never loses a state write.
|
||||
*
|
||||
* Writes are owner-only (webhook author check, HMAC one-tap links, bearer
|
||||
* admin routes); everything else is read-only public.
|
||||
*/
|
||||
import { reconcileCatalog } from "./catalog.ts";
|
||||
import { gateBadge, heatmapChart, ladderChart, progressChart } from "./charts.ts";
|
||||
import { sendDigest } from "./digest.ts";
|
||||
import { createReviewIssue, scoreReview } from "./gate.ts";
|
||||
import { type GitHub, github } from "./github.ts";
|
||||
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
|
||||
import { type Mirror, projectMirror } from "./mirror.ts";
|
||||
import {
|
||||
CAMPAIGN_START,
|
||||
type LogOutcome,
|
||||
daysBetween,
|
||||
etDate,
|
||||
etHour,
|
||||
logAttempt,
|
||||
weekdayOf,
|
||||
} from "./srs.ts";
|
||||
import { buildStats } from "./stats.ts";
|
||||
|
||||
// ── shared side effects after a state write ──────────────────────
|
||||
|
||||
/**
|
||||
* Mirror one logged attempt into GitHub: Project fields always; on a
|
||||
* first-ever pass also close the problem's sub-issue (comment first — repo
|
||||
* convention). Runs inside ctx.waitUntil; failures are warnings.
|
||||
*/
|
||||
async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Promise<void> {
|
||||
if (outcome.error || outcome.duplicate) return;
|
||||
const gh = github(env.GH_PAT, env.REPO);
|
||||
const mirror: Mirror = projectMirror(gh, env.REPO.split("/")[0]!);
|
||||
await mirror.setStage(outcome.issue, outcome.stage);
|
||||
await mirror.setTargetDate(outcome.issue, outcome.next_review);
|
||||
if (outcome.first) await mirror.setFirstAttempt(outcome.issue, outcome.result);
|
||||
if (outcome.first && outcome.result === "pass") {
|
||||
try {
|
||||
await gh.closeIssue(
|
||||
outcome.issue,
|
||||
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +2. Logged by the SRS Worker.`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`close #${outcome.issue}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function outcomeLine(o: LogOutcome): string {
|
||||
if (o.error) return o.error;
|
||||
if (o.duplicate) return `LC ${o.lc}: already logged today — nothing changed`;
|
||||
return (
|
||||
`LC ${o.lc}: ${o.kind} ${o.result} → stage ${o.stage}` +
|
||||
(o.next_review ? `, review ${o.next_review}` : " — retired 🎉")
|
||||
);
|
||||
}
|
||||
|
||||
// ── one-tap email links ──────────────────────────────────────────
|
||||
|
||||
function page(title: string, body: string, status = 200): Response {
|
||||
return new Response(
|
||||
`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<body style="font-family:-apple-system,Segoe UI,sans-serif;max-width:420px;margin:15vh auto;padding:0 16px;text-align:center">
|
||||
<h2>${title}</h2><p style="color:#57606a">${body}</p></body>`,
|
||||
{ status, headers: { "content-type": "text/html; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
|
||||
async function handleTap(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const lc = Number(url.searchParams.get("p"));
|
||||
const result = url.searchParams.get("r");
|
||||
const date = url.searchParams.get("d") ?? "";
|
||||
const sig = url.searchParams.get("sig") ?? "";
|
||||
if (!Number.isFinite(lc) || (result !== "pass" && result !== "fail") || !date) {
|
||||
return page("Bad link", "Missing or malformed parameters.", 400);
|
||||
}
|
||||
if (!(await verifyLink(env.LINK_KEY, lc, result, date, sig))) {
|
||||
return page("Bad signature", "This link was not signed by the SRS.", 403);
|
||||
}
|
||||
const today = etDate(new Date());
|
||||
if (daysBetween(date, today) > 3 || daysBetween(today, date) > 1) {
|
||||
return page("Link expired", "Older than 3 days — log it with a /done comment instead.", 410);
|
||||
}
|
||||
|
||||
const outcome = await logAttempt(env.DB, { lc, date, result, source: "email" });
|
||||
if (outcome.error) return page("Not logged", outcome.error, 422);
|
||||
if (outcome.duplicate) {
|
||||
return page("Already logged ✓", `LC ${lc} was already recorded today. Nothing changed.`);
|
||||
}
|
||||
ctx.waitUntil(mirrorOutcome(env, outcome, "email"));
|
||||
return page(
|
||||
result === "pass" ? "Logged ✅" : "Logged — back to +2",
|
||||
outcomeLine(outcome),
|
||||
);
|
||||
}
|
||||
|
||||
// ── GitHub webhook ───────────────────────────────────────────────
|
||||
|
||||
interface CommentEvent {
|
||||
action: string;
|
||||
issue: { number: number; title: string; labels: { name: string }[] };
|
||||
comment: { id: number; body: string; user: { login: string } };
|
||||
}
|
||||
|
||||
async function handleWebhook(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const raw = await request.arrayBuffer();
|
||||
const ok = await verifyWebhook(env.WEBHOOK_SECRET, raw, request.headers.get("x-hub-signature-256"));
|
||||
if (!ok) return new Response("bad signature", { status: 401 });
|
||||
|
||||
const event = request.headers.get("x-github-event");
|
||||
const payload: unknown = JSON.parse(new TextDecoder().decode(raw));
|
||||
const owner = env.REPO.split("/")[0]!;
|
||||
const today = etDate(new Date());
|
||||
|
||||
if (event === "issues") {
|
||||
if (!payload || typeof payload !== "object" || !("action" in payload) || !("issue" in payload)) {
|
||||
return new Response("ignored");
|
||||
}
|
||||
const p = payload as { action: string; issue: { number: number; labels: { name: string }[] } };
|
||||
if (p.action === "closed" && p.issue.labels.some((l) => l.name === "review")) {
|
||||
const gh = github(env.GH_PAT, env.REPO);
|
||||
const report = await scoreReview(env, gh, p.issue.number, today);
|
||||
return Response.json(report);
|
||||
}
|
||||
return new Response("ignored");
|
||||
}
|
||||
|
||||
if (event !== "issue_comment") return new Response("ignored");
|
||||
const p = payload as CommentEvent;
|
||||
if (p.action !== "created") return new Response("ignored");
|
||||
if (p.comment.user.login !== owner) return new Response("ignored (owner only)");
|
||||
|
||||
const commands: { lc: number; result: "pass" | "fail" }[] = [];
|
||||
const errors: string[] = [];
|
||||
for (const rawLine of p.comment.body.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith("/done")) continue;
|
||||
const m = line.match(/^\/done\s+(\d+)\s+(pass|fail)\s*$/);
|
||||
if (m) commands.push({ lc: Number(m[1]), result: m[2] as "pass" | "fail" });
|
||||
else errors.push(`cannot parse \`${line}\` — expected \`/done <number> <pass|fail>\``);
|
||||
}
|
||||
if (commands.length === 0 && errors.length === 0) return new Response("no commands");
|
||||
|
||||
// Comments on a review issue log its gate problems as kind='gate'.
|
||||
const gateRow = await env.DB.prepare("SELECT problems FROM gates WHERE issue = ?")
|
||||
.bind(p.issue.number)
|
||||
.first<{ problems: string }>();
|
||||
const gateLcs = new Set<number>(gateRow ? JSON.parse(gateRow.problems) : []);
|
||||
|
||||
const outcomes: LogOutcome[] = [];
|
||||
for (const cmd of commands) {
|
||||
const outcome = await logAttempt(env.DB, {
|
||||
lc: cmd.lc,
|
||||
date: today,
|
||||
result: cmd.result,
|
||||
source: "webhook",
|
||||
gate: gateLcs.has(cmd.lc),
|
||||
});
|
||||
if (outcome.error) errors.push(outcome.error);
|
||||
else outcomes.push(outcome);
|
||||
}
|
||||
|
||||
const gh = github(env.GH_PAT, env.REPO);
|
||||
ctx.waitUntil(
|
||||
(async () => {
|
||||
for (const outcome of outcomes) await mirrorOutcome(env, outcome, "webhook");
|
||||
try {
|
||||
if (errors.length === 0) {
|
||||
await gh.react(p.comment.id, "+1");
|
||||
} else {
|
||||
await gh.comment(
|
||||
p.issue.number,
|
||||
`Could not log everything:\n\n${errors.map((e) => `- ${e}`).join("\n")}` +
|
||||
(outcomes.length
|
||||
? `\n\nApplied anyway:\n\n${outcomes.map((o) => `- ${outcomeLine(o)}`).join("\n")}`
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`webhook feedback: ${err}`);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
return Response.json({ applied: outcomes.map(outcomeLine), errors });
|
||||
}
|
||||
|
||||
// ── admin (bearer LINK_KEY, timing-safe) ─────────────────────────
|
||||
|
||||
async function adminAuthorized(request: Request, env: Env): Promise<boolean> {
|
||||
const header = request.headers.get("authorization") ?? "";
|
||||
if (!header.startsWith("Bearer ")) return false;
|
||||
return timingSafeEqual(header.slice(7), env.LINK_KEY);
|
||||
}
|
||||
|
||||
async function handleAdmin(request: Request, env: Env, path: string): Promise<Response> {
|
||||
if (!(await adminAuthorized(request, env))) return new Response("unauthorized", { status: 401 });
|
||||
const url = new URL(request.url);
|
||||
const date = url.searchParams.get("date") ?? etDate(new Date());
|
||||
const gh = () => github(env.GH_PAT, env.REPO);
|
||||
|
||||
if (path === "/admin/reconcile") {
|
||||
return Response.json(await reconcileCatalog(env.DB, gh()));
|
||||
}
|
||||
if (path === "/admin/digest") {
|
||||
const report = await sendDigest(env, date, {
|
||||
dry: url.searchParams.get("dry") === "1",
|
||||
force: url.searchParams.get("force") === "1",
|
||||
});
|
||||
if (url.searchParams.get("dry") === "1") {
|
||||
return new Response(report.digest.html, { headers: { "content-type": "text/html" } });
|
||||
}
|
||||
return Response.json({ sent: report.sent, reason: report.reason, subject: report.digest.subject });
|
||||
}
|
||||
if (path === "/admin/review") {
|
||||
return Response.json(await createReviewIssue(env, gh(), date));
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
|
||||
// ── router ───────────────────────────────────────────────────────
|
||||
|
||||
const SVG_HEADERS = {
|
||||
"content-type": "image/svg+xml",
|
||||
// GitHub Camo honors this; 5 minutes is the freshness floor for README charts.
|
||||
"cache-control": "public, max-age=300",
|
||||
};
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
try {
|
||||
if (path === "/log" && request.method === "GET") return await handleTap(request, env, ctx);
|
||||
if (path === "/webhook/github" && request.method === "POST") {
|
||||
return await handleWebhook(request, env, ctx);
|
||||
}
|
||||
if (path.startsWith("/admin/") && request.method === "POST") {
|
||||
return await handleAdmin(request, env, path);
|
||||
}
|
||||
|
||||
if (path === "/chart/progress.svg") {
|
||||
return new Response(await progressChart(env.DB), { headers: SVG_HEADERS });
|
||||
}
|
||||
if (path === "/chart/ladder.svg") {
|
||||
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
|
||||
}
|
||||
if (path === "/chart/heatmap.svg") {
|
||||
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, 56), { headers: SVG_HEADERS });
|
||||
}
|
||||
if (path === "/badge/gate.svg") {
|
||||
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
|
||||
}
|
||||
|
||||
if (path === "/api/stats") {
|
||||
const cors = {
|
||||
"access-control-allow-origin": env.DOCS_ORIGIN,
|
||||
"access-control-allow-methods": "GET",
|
||||
vary: "Origin",
|
||||
};
|
||||
if (request.method === "OPTIONS") return new Response(null, { headers: cors });
|
||||
const stats = await buildStats(env.DB, etDate(new Date()));
|
||||
return Response.json(stats, { headers: cors });
|
||||
}
|
||||
|
||||
return new Response("srs-api", { status: path === "/" ? 200 : 404 });
|
||||
} catch (err) {
|
||||
console.error(`unhandled ${request.method} ${path}: ${err}`);
|
||||
return new Response("internal error", { status: 500 });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* DST-proof cron dispatch: crons fire at both possible UTC hours; the
|
||||
* computed ET hour decides. 8 AM daily → catalog reconcile + digest;
|
||||
* midnight Saturday → review issue (so the 8 AM digest can link to it).
|
||||
*/
|
||||
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||
const now = new Date(controller.scheduledTime);
|
||||
const hour = etHour(now);
|
||||
const date = etDate(now);
|
||||
|
||||
if (hour === 8) {
|
||||
try {
|
||||
const report = await reconcileCatalog(env.DB, github(env.GH_PAT, env.REPO));
|
||||
console.log(`catalog: ${report.topics} topics, ${report.problems} problems`);
|
||||
} catch (err) {
|
||||
console.warn(`catalog reconcile failed (digest still goes out): ${err}`);
|
||||
}
|
||||
const report = await sendDigest(env, date);
|
||||
console.log(`digest ${date}: ${report.reason}`);
|
||||
return;
|
||||
}
|
||||
if (hour === 0 && weekdayOf(date) === 6) {
|
||||
const report = await createReviewIssue(env, github(env.GH_PAT, env.REPO), date);
|
||||
console.log(`review issue ${date}: ${report.reason}`);
|
||||
return;
|
||||
}
|
||||
console.log(`cron at ET hour ${hour} on ${date}: no-op (DST guard)`);
|
||||
void ctx;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* One-tap email links: `GET /log?p=704&r=pass&d=2026-08-31&sig=<hmac>`.
|
||||
* The sig is HMAC-SHA256 over `p|r|d` with LINK_KEY (WebCrypto, hex).
|
||||
* Verification is constant-time; links older than 3 days are rejected.
|
||||
*/
|
||||
|
||||
async function hmacKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
}
|
||||
|
||||
export async function signLink(
|
||||
key: string,
|
||||
p: number,
|
||||
r: string,
|
||||
d: string,
|
||||
): Promise<string> {
|
||||
const mac = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
await hmacKey(key),
|
||||
new TextEncoder().encode(`${p}|${r}|${d}`),
|
||||
);
|
||||
return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function verifyLink(
|
||||
key: string,
|
||||
p: number,
|
||||
r: string,
|
||||
d: string,
|
||||
sig: string,
|
||||
): Promise<boolean> {
|
||||
if (!/^[0-9a-f]{64}$/.test(sig)) return false;
|
||||
const bytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) bytes[i] = Number.parseInt(sig.slice(i * 2, i * 2 + 2), 16);
|
||||
// crypto.subtle.verify is constant-time; never compare hex strings directly.
|
||||
return crypto.subtle.verify(
|
||||
"HMAC",
|
||||
await hmacKey(key),
|
||||
bytes,
|
||||
new TextEncoder().encode(`${p}|${r}|${d}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Constant-time equality for webhook signatures and admin bearer keys. */
|
||||
export async function timingSafeEqual(a: string, b: string): Promise<boolean> {
|
||||
// HMAC both sides with a random key: unequal-length inputs and content
|
||||
// differences are equally invisible to timing.
|
||||
const key = await crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, false, [
|
||||
"sign",
|
||||
]);
|
||||
const enc = new TextEncoder();
|
||||
const [ma, mb] = await Promise.all([
|
||||
crypto.subtle.sign("HMAC", key, enc.encode(a)),
|
||||
crypto.subtle.sign("HMAC", key, enc.encode(b)),
|
||||
]);
|
||||
const va = new Uint8Array(ma);
|
||||
const vb = new Uint8Array(mb);
|
||||
let diff = 0;
|
||||
for (let i = 0; i < va.length; i++) diff |= va[i]! ^ vb[i]!;
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/** GitHub webhook `X-Hub-Signature-256: sha256=<hex>` verification. */
|
||||
export async function verifyWebhook(
|
||||
secret: string,
|
||||
body: ArrayBuffer,
|
||||
header: string | null,
|
||||
): Promise<boolean> {
|
||||
if (!header?.startsWith("sha256=")) return false;
|
||||
const mac = await crypto.subtle.sign("HMAC", await hmacKey(secret), body);
|
||||
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return timingSafeEqual(header.slice(7), expected);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Best-effort mirror into the "Interview Prep" user Project — ported from
|
||||
* scripts/srs-project.ts. D1 is the truth; a GraphQL failure here becomes a
|
||||
* console warning and never blocks a state write. Only problem issues are
|
||||
* ever passed in, so topic rows' Target Date is never written.
|
||||
*
|
||||
* Field/option IDs are resolved by NAME per invocation (a Worker isolate is
|
||||
* short-lived; the two extra queries per mirror burst are irrelevant at this
|
||||
* volume and survive field re-creation).
|
||||
*/
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
const PROJECT_TITLE = "Interview Prep";
|
||||
|
||||
interface SelectField {
|
||||
id: string;
|
||||
options: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ProjectInfo {
|
||||
id: string;
|
||||
targetDate: string;
|
||||
srsStage: SelectField;
|
||||
firstAttempt: SelectField;
|
||||
}
|
||||
|
||||
export interface Mirror {
|
||||
setTargetDate(issue: number, date: string | null): Promise<void>;
|
||||
setStage(issue: number, stage: string): Promise<void>;
|
||||
setFirstAttempt(issue: number, result: string): Promise<void>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function projectMirror(gh: GitHub, owner: string): Mirror {
|
||||
const warnings: string[] = [];
|
||||
let info: ProjectInfo | undefined;
|
||||
const itemIds = new Map<number, string | undefined>();
|
||||
|
||||
async function resolve(): Promise<ProjectInfo> {
|
||||
if (info) return info;
|
||||
const data = (await gh.graphql(
|
||||
`query($owner: String!, $title: String!) {
|
||||
user(login: $owner) {
|
||||
projectsV2(first: 10, query: $title) {
|
||||
nodes {
|
||||
id title
|
||||
fields(first: 30) {
|
||||
nodes {
|
||||
... on ProjectV2FieldCommon { id name dataType }
|
||||
... on ProjectV2SingleSelectField { id name options { id name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner, title: PROJECT_TITLE },
|
||||
)) as {
|
||||
user: {
|
||||
projectsV2: {
|
||||
nodes: {
|
||||
id: string;
|
||||
title: string;
|
||||
fields: {
|
||||
nodes: { id: string; name: string; options?: { id: string; name: string }[] }[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
|
||||
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
|
||||
const select = (name: string): SelectField => {
|
||||
const f = node.fields.nodes.find((n) => n.name === name);
|
||||
if (!f?.options) throw new Error(`single-select field "${name}" missing`);
|
||||
return { id: f.id, options: Object.fromEntries(f.options.map((o) => [o.name, o.id])) };
|
||||
};
|
||||
const date = node.fields.nodes.find((n) => n.name === "Target Date");
|
||||
if (!date) throw new Error(`date field "Target Date" missing`);
|
||||
info = {
|
||||
id: node.id,
|
||||
targetDate: date.id,
|
||||
srsStage: select("SRS Stage"),
|
||||
firstAttempt: select("First Attempt"),
|
||||
};
|
||||
return info;
|
||||
}
|
||||
|
||||
async function itemId(issue: number): Promise<string> {
|
||||
if (itemIds.has(issue)) {
|
||||
const cached = itemIds.get(issue);
|
||||
if (!cached) throw new Error(`issue #${issue} is not in the project`);
|
||||
return cached;
|
||||
}
|
||||
const project = await resolve();
|
||||
const [repoOwner, repoName] = gh.repo.split("/");
|
||||
const data = (await gh.graphql(
|
||||
`query($owner: String!, $name: String!, $issue: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issue(number: $issue) {
|
||||
projectItems(first: 10, includeArchived: true) {
|
||||
nodes { id project { id } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner: repoOwner, name: repoName, issue },
|
||||
)) as {
|
||||
repository: { issue: { projectItems: { nodes: { id: string; project: { id: string } }[] } } };
|
||||
};
|
||||
const item = data.repository.issue.projectItems.nodes.find((n) => n.project.id === project.id);
|
||||
itemIds.set(issue, item?.id);
|
||||
if (!item) throw new Error(`issue #${issue} is not in the project`);
|
||||
return item.id;
|
||||
}
|
||||
|
||||
async function setField(issue: number, fieldId: string, value: object): Promise<void> {
|
||||
const project = await resolve();
|
||||
await gh.graphql(
|
||||
`mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
|
||||
updateProjectV2ItemFieldValue(
|
||||
input: { projectId: $project, itemId: $item, fieldId: $field, value: $value }
|
||||
) { projectV2Item { id } }
|
||||
}`,
|
||||
{ project: project.id, item: await itemId(issue), field: fieldId, value },
|
||||
);
|
||||
}
|
||||
|
||||
async function attempt(what: string, op: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await op();
|
||||
} catch (err) {
|
||||
const message = `mirror ${what}: ${err instanceof Error ? err.message : String(err)}`;
|
||||
warnings.push(message);
|
||||
console.warn(message); // observability picks this up; never rethrow
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
warnings,
|
||||
setTargetDate: (issue, date) =>
|
||||
attempt(`Target Date #${issue}`, async () => {
|
||||
const project = await resolve();
|
||||
if (date === null) {
|
||||
await gh.graphql(
|
||||
`mutation($project: ID!, $item: ID!, $field: ID!) {
|
||||
clearProjectV2ItemFieldValue(
|
||||
input: { projectId: $project, itemId: $item, fieldId: $field }
|
||||
) { projectV2Item { id } }
|
||||
}`,
|
||||
{ project: project.id, item: await itemId(issue), field: project.targetDate },
|
||||
);
|
||||
} else {
|
||||
await setField(issue, project.targetDate, { date });
|
||||
}
|
||||
}),
|
||||
setStage: (issue, stage) =>
|
||||
attempt(`SRS Stage #${issue}`, async () => {
|
||||
const project = await resolve();
|
||||
const option = project.srsStage.options[stage];
|
||||
if (!option) throw new Error(`no option "${stage}"`);
|
||||
await setField(issue, project.srsStage.id, { singleSelectOptionId: option });
|
||||
}),
|
||||
setFirstAttempt: (issue, result) =>
|
||||
attempt(`First Attempt #${issue}`, async () => {
|
||||
const project = await resolve();
|
||||
const option = project.firstAttempt.options[result];
|
||||
if (!option) throw new Error(`no option "${result}"`);
|
||||
await setField(issue, project.firstAttempt.id, { singleSelectOptionId: option });
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* DST-guard and date-math checks with fixed instants — the acceptance proof
|
||||
* that the double crons fire exactly once at 8 AM ET (digest) and midnight
|
||||
* Saturday ET (review issue) on BOTH UTC offsets.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { addDays, campaignDay, campaignWeek, etDate, etHour, isoWeek, rng, sample, weekdayOf } from "./srs.ts";
|
||||
|
||||
describe("DST-proof cron guard", () => {
|
||||
test("digest fires only at ET hour 8 — EDT (UTC-4)", () => {
|
||||
// Cron pair 12:00 / 13:00 UTC during EDT (2026-08-29 is EDT).
|
||||
expect(etHour(new Date("2026-08-29T12:00:00Z"))).toBe(8); // fires
|
||||
expect(etHour(new Date("2026-08-29T13:00:00Z"))).toBe(9); // no-op
|
||||
});
|
||||
test("digest fires only at ET hour 8 — EST (UTC-5)", () => {
|
||||
// DST ends 2026-11-01; 2026-11-05 is EST.
|
||||
expect(etHour(new Date("2026-11-05T12:00:00Z"))).toBe(7); // no-op
|
||||
expect(etHour(new Date("2026-11-05T13:00:00Z"))).toBe(8); // fires
|
||||
});
|
||||
test("saturday review fires only at ET midnight — both offsets", () => {
|
||||
// EDT Saturday: 4 UTC = 0 ET fires, 5 UTC = 1 ET no-op.
|
||||
expect(etHour(new Date("2026-08-29T04:00:00Z"))).toBe(0);
|
||||
expect(weekdayOf(etDate(new Date("2026-08-29T04:00:00Z")))).toBe(6);
|
||||
expect(etHour(new Date("2026-08-29T05:00:00Z"))).toBe(1);
|
||||
// EST Saturday (2026-11-07): 5 UTC = 0 ET fires, 4 UTC = 11 PM FRIDAY ET.
|
||||
expect(etHour(new Date("2026-11-07T05:00:00Z"))).toBe(0);
|
||||
expect(weekdayOf(etDate(new Date("2026-11-07T05:00:00Z")))).toBe(6);
|
||||
expect(etHour(new Date("2026-11-07T04:00:00Z"))).toBe(23);
|
||||
expect(etDate(new Date("2026-11-07T04:00:00Z"))).toBe("2026-11-06"); // still Friday ET
|
||||
});
|
||||
});
|
||||
|
||||
describe("date math (noon-UTC anchored)", () => {
|
||||
test("addDays crosses the DST-end boundary without drift", () => {
|
||||
expect(addDays("2026-10-31", 2)).toBe("2026-11-02");
|
||||
expect(addDays("2026-08-24", 2)).toBe("2026-08-26");
|
||||
});
|
||||
test("campaign math", () => {
|
||||
expect(campaignDay("2026-08-17")).toBe(1);
|
||||
expect(campaignDay("2026-08-24")).toBe(8);
|
||||
expect(campaignWeek("2026-08-24")).toBe(2);
|
||||
expect(campaignWeek("2026-08-30")).toBe(2);
|
||||
expect(campaignWeek("2026-08-31")).toBe(3);
|
||||
});
|
||||
test("iso week is stable across a week", () => {
|
||||
expect(isoWeek("2026-08-24")).toBe(isoWeek("2026-08-29"));
|
||||
expect(isoWeek("2026-08-30")).toBe(isoWeek("2026-08-24")); // Sun ends the ISO week
|
||||
expect(isoWeek("2026-08-31")).toBe(isoWeek("2026-08-24") + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deterministic sampling", () => {
|
||||
test("same seed, same picks", () => {
|
||||
const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
expect(sample(pool, 3, rng("drill-2026-08-31"))).toEqual(sample(pool, 3, rng("drill-2026-08-31")));
|
||||
expect(sample(pool, 3, rng("drill-2026-09-01"))).not.toEqual(sample(pool, 3, rng("drill-2026-08-31")));
|
||||
});
|
||||
});
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* SRS domain: ET dates, the interval ladder, deterministic sampling, and the
|
||||
* one write path for attempts — ported from scripts/srs.ts, re-homed on D1.
|
||||
*
|
||||
* D1 is the single source of truth. The stage names the review a problem must
|
||||
* pass NEXT (`+2` = due 2 days after last clean solve). Passing advances
|
||||
* new → +2 → +5 → +10 → retired; any failure resets to +2. A problem's
|
||||
* FIRST-ever log enters the ladder at +2 regardless of result: a pass earns
|
||||
* a +2 review, a fail must be re-solved just as soon.
|
||||
*
|
||||
* All dates are America/New_York calendar strings (YYYY-MM-DD); arithmetic is
|
||||
* anchored at noon UTC so DST edges cannot shift a date. Never raw Date math.
|
||||
*/
|
||||
import scheduleJson from "../data/schedule.json";
|
||||
|
||||
// ── dates (America/New_York) ─────────────────────────────────────
|
||||
|
||||
export const CAMPAIGN_START = "2026-08-17";
|
||||
export const CAMPAIGN_DAYS = 56;
|
||||
|
||||
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "America/New_York",
|
||||
dateStyle: "short",
|
||||
});
|
||||
const ET_HOUR = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "America/New_York",
|
||||
hour: "numeric",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
|
||||
/** ET calendar date of an instant. */
|
||||
export function etDate(now: Date): string {
|
||||
return ET_DATE.format(now);
|
||||
}
|
||||
|
||||
/** ET hour 0–23 of an instant — the DST-proof cron guard. */
|
||||
export function etHour(now: Date): number {
|
||||
return Number(ET_HOUR.format(now));
|
||||
}
|
||||
|
||||
/** Noon-UTC anchor: date-only arithmetic immune to DST edges. */
|
||||
function atNoon(date: string): Date {
|
||||
return new Date(`${date}T12:00:00Z`);
|
||||
}
|
||||
|
||||
export function addDays(date: string, days: number): string {
|
||||
return new Date(atNoon(date).getTime() + days * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function daysBetween(from: string, to: string): number {
|
||||
return Math.round((atNoon(to).getTime() - atNoon(from).getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/** 0 = Sunday … 6 = Saturday. */
|
||||
export function weekdayOf(date: string): number {
|
||||
return atNoon(date).getUTCDay();
|
||||
}
|
||||
|
||||
export function campaignDay(date: string): number {
|
||||
return daysBetween(CAMPAIGN_START, date) + 1;
|
||||
}
|
||||
|
||||
/** 1-based campaign week (Mon–Sun), aligned to CAMPAIGN_START. */
|
||||
export function campaignWeek(date: string): number {
|
||||
return Math.floor(daysBetween(CAMPAIGN_START, date) / 7) + 1;
|
||||
}
|
||||
|
||||
/** Monday of the date's campaign week. */
|
||||
export function weekMonday(date: string): string {
|
||||
return addDays(CAMPAIGN_START, (campaignWeek(date) - 1) * 7);
|
||||
}
|
||||
|
||||
export function isoWeek(date: string): number {
|
||||
const d = atNoon(date);
|
||||
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||
const jan1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((d.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7);
|
||||
}
|
||||
|
||||
export function prettyDate(date: string): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "UTC",
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(atNoon(date));
|
||||
}
|
||||
|
||||
// ── the schedule (bundled; humans edit api/data/schedule.json) ───
|
||||
|
||||
export const SCHEDULE: Record<string, number> = scheduleJson;
|
||||
|
||||
/** Week in which a topic was (or will be) taught. */
|
||||
export function topicWeek(topic: number): number | undefined {
|
||||
for (const [date, t] of Object.entries(SCHEDULE)) {
|
||||
if (t === topic) return campaignWeek(date);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── deterministic sampling ───────────────────────────────────────
|
||||
|
||||
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
|
||||
export function rng(seed: string): () => number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return () => {
|
||||
h = Math.imul(h ^ (h >>> 15), h | 1);
|
||||
h ^= h + Math.imul(h ^ (h >>> 7), h | 61);
|
||||
return ((h ^ (h >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** Up to n elements, Fisher–Yates order driven by the seeded PRNG. */
|
||||
export function sample<T>(pool: T[], n: number, random: () => number): T[] {
|
||||
const copy = [...pool];
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j]!, copy[i]!];
|
||||
}
|
||||
return copy.slice(0, n);
|
||||
}
|
||||
|
||||
// ── rows ─────────────────────────────────────────────────────────
|
||||
|
||||
export type Stage = "new" | "+2" | "+5" | "+10" | "retired";
|
||||
export type Result = "pass" | "fail";
|
||||
export type Kind = "first" | "review" | "drill" | "gate";
|
||||
|
||||
export interface ProblemRow {
|
||||
lc_number: number;
|
||||
issue: number;
|
||||
topic_issue: number;
|
||||
title: string;
|
||||
difficulty: string;
|
||||
set_label: string;
|
||||
stage: Stage;
|
||||
next_review: string | null;
|
||||
defer_until: string | null;
|
||||
}
|
||||
|
||||
export const INTERVAL: Record<string, number> = { "+2": 2, "+5": 5, "+10": 10 };
|
||||
const NEXT_STAGE: Record<string, Stage> = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" };
|
||||
|
||||
// ── queries ──────────────────────────────────────────────────────
|
||||
|
||||
export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow | null> {
|
||||
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
||||
}
|
||||
|
||||
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
||||
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM problems
|
||||
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||
AND (defer_until IS NULL OR defer_until <= ?1)
|
||||
ORDER BY next_review, lc_number`,
|
||||
)
|
||||
.bind(date)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blind drills: unsolved optional problems from topics taught in EARLIER
|
||||
* weeks (the current week's optional pool is reserved for Saturday's gate),
|
||||
* never repeated, seeded by date. Boosted topics contribute up to 2 extra.
|
||||
*/
|
||||
export async function pickDrills(
|
||||
db: D1Database,
|
||||
date: string,
|
||||
budget: number,
|
||||
): Promise<ProblemRow[]> {
|
||||
if (budget <= 0) return [];
|
||||
const week = campaignWeek(date);
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT p.*, t.boost AS boost FROM problems p
|
||||
JOIN topics t ON t.issue = p.topic_issue
|
||||
WHERE p.set_label = 'optional' AND p.stage = 'new'
|
||||
AND p.lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = p.lc_number)
|
||||
ORDER BY p.lc_number`,
|
||||
)
|
||||
.all<ProblemRow & { boost: number }>();
|
||||
const pool = results.filter((p) => {
|
||||
const w = topicWeek(p.topic_issue);
|
||||
return w !== undefined && w < week;
|
||||
});
|
||||
const boosted = pool.filter((p) => p.boost === 1);
|
||||
const regular = pool.filter((p) => p.boost !== 1);
|
||||
const boostPicks = sample(boosted, Math.min(2, budget), rng(`boost-${date}`));
|
||||
const regularPicks = sample(
|
||||
regular,
|
||||
Math.min(2, Math.max(0, budget - boostPicks.length)),
|
||||
rng(`drill-${date}`),
|
||||
);
|
||||
return [...boostPicks, ...regularPicks].slice(0, budget);
|
||||
}
|
||||
|
||||
/** Consecutive days with ≥1 attempt, ending today or yesterday. */
|
||||
export async function streak(db: D1Database, today: string): Promise<number> {
|
||||
const { results } = await db
|
||||
.prepare("SELECT DISTINCT date FROM attempts ORDER BY date DESC LIMIT 90")
|
||||
.all<{ date: string }>();
|
||||
const days = new Set(results.map((r) => r.date));
|
||||
let cursor = days.has(today) ? today : addDays(today, -1);
|
||||
let n = 0;
|
||||
while (days.has(cursor)) {
|
||||
n++;
|
||||
cursor = addDays(cursor, -1);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── the one write path ───────────────────────────────────────────
|
||||
|
||||
export interface LogOutcome {
|
||||
lc: number;
|
||||
title: string;
|
||||
kind: Kind;
|
||||
result: Result;
|
||||
stage: Stage;
|
||||
next_review: string | null;
|
||||
/** First-ever attempt — caller closes the sub-issue on pass. */
|
||||
first: boolean;
|
||||
issue: number;
|
||||
/** Email one-tap replay: nothing changed. */
|
||||
duplicate: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an attempt and move the ladder. Ladder semantics live here and only
|
||||
* here — email one-taps, webhook /done lines, and gate scoring all converge.
|
||||
*
|
||||
* Email idempotency comes from the partial unique index on
|
||||
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
||||
* nothing and must not touch the ladder. Webhook corrections (pass then fail
|
||||
* on the same day) remain legal — every webhook attempt appends.
|
||||
*/
|
||||
export async function logAttempt(
|
||||
db: D1Database,
|
||||
opts: { lc: number; date: string; result: Result; source: "email" | "webhook"; gate?: boolean },
|
||||
): Promise<LogOutcome> {
|
||||
const p = await getProblem(db, opts.lc);
|
||||
const nothing: LogOutcome = {
|
||||
lc: opts.lc,
|
||||
title: "",
|
||||
kind: "review",
|
||||
result: opts.result,
|
||||
stage: "new",
|
||||
next_review: null,
|
||||
first: false,
|
||||
issue: 0,
|
||||
duplicate: false,
|
||||
};
|
||||
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
|
||||
if (p.stage === "retired") {
|
||||
return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` };
|
||||
}
|
||||
|
||||
const attempted = await db
|
||||
.prepare("SELECT 1 AS x FROM attempts WHERE lc_number = ? LIMIT 1")
|
||||
.bind(opts.lc)
|
||||
.first();
|
||||
const first = !attempted && p.stage === "new";
|
||||
const kind: Kind = opts.gate ? "gate" : first ? (p.set_label === "optional" ? "drill" : "first") : "review";
|
||||
|
||||
const inserted = await db
|
||||
.prepare(
|
||||
`INSERT INTO attempts (lc_number, date, kind, result, source) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
)
|
||||
.bind(opts.lc, opts.date, kind, opts.result, opts.source)
|
||||
.run();
|
||||
if (opts.source === "email" && inserted.meta.changes === 0) {
|
||||
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.
|
||||
let stage: Stage;
|
||||
if (first) {
|
||||
stage = "+2";
|
||||
} else if (opts.result === "pass") {
|
||||
stage = NEXT_STAGE[p.stage] ?? "retired";
|
||||
} else {
|
||||
stage = "+2";
|
||||
}
|
||||
const next = stage === "retired" ? null : addDays(opts.date, INTERVAL[stage]!);
|
||||
|
||||
const writes = [
|
||||
db.prepare(
|
||||
"UPDATE problems SET stage = ?, next_review = ?, defer_until = NULL WHERE lc_number = ?",
|
||||
).bind(stage, next, opts.lc),
|
||||
];
|
||||
if (first && kind === "drill") {
|
||||
writes.push(
|
||||
db.prepare("INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (?)").bind(opts.lc),
|
||||
);
|
||||
if (opts.result === "fail") {
|
||||
writes.push(
|
||||
db.prepare("UPDATE topics SET misses = misses + 1 WHERE issue = ?").bind(p.topic_issue),
|
||||
);
|
||||
}
|
||||
}
|
||||
await db.batch(writes);
|
||||
|
||||
return {
|
||||
lc: opts.lc,
|
||||
title: p.title,
|
||||
kind,
|
||||
result: opts.result,
|
||||
stage,
|
||||
next_review: next,
|
||||
first,
|
||||
issue: p.issue,
|
||||
duplicate: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* GET /api/stats — the one JSON document behind the docs progress page.
|
||||
* Read-only aggregation over D1; shape is the page's contract, change both
|
||||
* together. A problem counts as "done" once it entered the ladder
|
||||
* (stage != 'new').
|
||||
*/
|
||||
import {
|
||||
CAMPAIGN_DAYS,
|
||||
CAMPAIGN_START,
|
||||
addDays,
|
||||
campaignDay,
|
||||
campaignWeek,
|
||||
streak,
|
||||
} from "./srs.ts";
|
||||
|
||||
const PHASE_NAMES: Record<number, string> = {
|
||||
1: "I — Linear",
|
||||
2: "II — Nodal & Grid",
|
||||
3: "III — Hierarchical",
|
||||
4: "IV — Relational",
|
||||
5: "V — Decision Space",
|
||||
};
|
||||
|
||||
export async function buildStats(db: D1Database, today: string): Promise<object> {
|
||||
const { results: phaseRows } = await db
|
||||
.prepare(
|
||||
`SELECT t.milestone AS milestone, p.set_label AS set_label,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done
|
||||
FROM problems p JOIN topics t ON t.issue = p.topic_issue
|
||||
WHERE t.milestone IS NOT NULL
|
||||
GROUP BY t.milestone, p.set_label
|
||||
ORDER BY t.milestone`,
|
||||
)
|
||||
.all<{ milestone: number; set_label: string; total: number; done: number }>();
|
||||
const phases = new Map<
|
||||
number,
|
||||
{ milestone: number; name: string } & Record<string, number | string>
|
||||
>();
|
||||
for (const row of phaseRows) {
|
||||
const phase =
|
||||
phases.get(row.milestone) ??
|
||||
({
|
||||
milestone: row.milestone,
|
||||
name: PHASE_NAMES[row.milestone] ?? `Phase ${row.milestone}`,
|
||||
core_done: 0,
|
||||
core_total: 0,
|
||||
optional_done: 0,
|
||||
optional_total: 0,
|
||||
deferred_done: 0,
|
||||
deferred_total: 0,
|
||||
} as { milestone: number; name: string } & Record<string, number | string>);
|
||||
phase[`${row.set_label}_done`] = row.done;
|
||||
phase[`${row.set_label}_total`] = row.total;
|
||||
phases.set(row.milestone, phase);
|
||||
}
|
||||
|
||||
const { results: ladderRows } = await db
|
||||
.prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage")
|
||||
.all<{ stage: string; n: number }>();
|
||||
const ladder: Record<string, number> = { new: 0, "+2": 0, "+5": 0, "+10": 0, retired: 0 };
|
||||
for (const row of ladderRows) ladder[row.stage] = row.n;
|
||||
|
||||
const { results: gates } = await db
|
||||
.prepare("SELECT week, issue, pass_rate, closed_on FROM gates ORDER BY week")
|
||||
.all<{ week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }>();
|
||||
|
||||
// Review-queue depth for the next 14 days: everything due by that day.
|
||||
const queue: { date: string; due: number }[] = [];
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const date = addDays(today, i);
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM problems
|
||||
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||
AND (defer_until IS NULL OR defer_until <= ?1)`,
|
||||
)
|
||||
.bind(date)
|
||||
.first<{ n: number }>();
|
||||
queue.push({ date, due: row?.n ?? 0 });
|
||||
}
|
||||
|
||||
const recent: { date: string; attempts: number; passes: number }[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = addDays(today, -i);
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS attempts,
|
||||
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes
|
||||
FROM attempts WHERE date = ?`,
|
||||
)
|
||||
.bind(date)
|
||||
.first<{ attempts: number; passes: number | null }>();
|
||||
recent.push({ date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 });
|
||||
}
|
||||
|
||||
return {
|
||||
generated: new Date().toISOString(),
|
||||
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
|
||||
phases: [...phases.values()],
|
||||
ladder,
|
||||
gates,
|
||||
streak: await streak(db, today),
|
||||
queue,
|
||||
recent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["esnext"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["./worker-configuration.d.ts", "bun"]
|
||||
},
|
||||
"include": ["src", "scripts"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// SRS Worker — daily digest email, one-tap logging, weekly review issues,
|
||||
// live SVG charts, and the /api/stats feed for the docs progress page.
|
||||
//
|
||||
// D1 is the source of truth for SRS state; GitHub issues own the catalog;
|
||||
// api/data/schedule.json (bundled) owns the schedule. JSONC over TOML per
|
||||
// Workers best practices (newer features are JSON-only).
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "srs-api",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2026-08-24",
|
||||
"observability": { "enabled": true },
|
||||
|
||||
"d1_databases": [
|
||||
{ "binding": "DB", "database_name": "srs", "database_id": "2ee2c34a-c2e2-4ebb-934e-c9e7dc8aa4f1" }
|
||||
],
|
||||
|
||||
// Sender domain prdlk.com is onboarded to Email Sending; the binding is
|
||||
// restricted to the one verified destination inbox.
|
||||
"send_email": [
|
||||
{ "name": "EMAIL", "allowed_destination_addresses": ["prnk28@gmail.com"] }
|
||||
],
|
||||
|
||||
// Double-cron per event makes each DST-proof: the code fires only when the
|
||||
// computed ET hour matches (8 AM daily digest, midnight-Saturday review).
|
||||
// 8 AM ET = 12:00 UTC in EDT, 13:00 UTC in EST; midnight ET = 4/5 UTC.
|
||||
"triggers": {
|
||||
"crons": ["0 12 * * *", "0 13 * * *", "0 4 * * 6", "0 5 * * 6"]
|
||||
},
|
||||
|
||||
"vars": {
|
||||
"REPO": "prdlk/leetcode",
|
||||
"FROM_EMAIL": "srs@prdlk.com",
|
||||
"TO_EMAIL": "prnk28@gmail.com",
|
||||
"DOCS_ORIGIN": "https://prdlk.github.io",
|
||||
"DOCS_URL": "https://prdlk.github.io/leetcode",
|
||||
// Filled in after first deploy; used for one-tap links in the digest.
|
||||
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev"
|
||||
}
|
||||
|
||||
// Secrets (wrangler secret put): GH_PAT, WEBHOOK_SECRET, LINK_KEY.
|
||||
}
|
||||
Reference in New Issue
Block a user