From 7213cc1c8a1a3280fd6c0ee42d99611c1a814374 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Mon, 24 Aug 2026 19:11:07 -0400 Subject: [PATCH] Migrate SRS to Cloudflare Worker: D1 state, email digest, review issues, live charts --- .github/srs/badge.json | 6 - .github/srs/srs.json | 563 ------------------------ .github/workflows/gate.yml | 67 --- .github/workflows/srs-logger.yml | 43 -- .github/workflows/srs-scheduler.yml | 30 -- .github/workflows/srs-setup.yml | 29 -- .gitignore | 6 + AGENTS.md | 58 +-- README.md | 8 +- api/.dev.vars.example | 3 + api/README.md | 81 ++++ api/bun.lock | 195 ++++++++ {.github/srs => api/data}/schedule.json | 0 api/migrations/0001_init.sql | 53 +++ api/package.json | 14 + api/scripts/import-srs.ts | 94 ++++ api/src/catalog.ts | 92 ++++ api/src/charts.ts | 289 ++++++++++++ api/src/digest.ts | 226 ++++++++++ api/src/gate.ts | 270 ++++++++++++ api/src/github.ts | 94 ++++ api/src/index.ts | 310 +++++++++++++ api/src/links.ts | 79 ++++ api/src/mirror.ts | 172 ++++++++ api/src/srs.test.ts | 59 +++ api/src/srs.ts | 324 ++++++++++++++ api/src/stats.ts | 107 +++++ api/tsconfig.json | 17 + api/wrangler.jsonc | 42 ++ blume.config.ts | 4 + docs/gate-log.md | 7 - docs/progress.mdx | 9 + islands/ProgressDashboard.tsx | 351 +++++++++++++++ package.json | 7 +- scripts/srs-gate.ts | 261 ----------- scripts/srs-logger.ts | 226 ---------- scripts/srs-project.ts | 222 ---------- scripts/srs-scheduler.ts | 201 --------- scripts/srs-setup.ts | 146 ------ scripts/srs.ts | 300 ------------- 40 files changed, 2933 insertions(+), 2132 deletions(-) delete mode 100644 .github/srs/badge.json delete mode 100644 .github/srs/srs.json delete mode 100644 .github/workflows/gate.yml delete mode 100644 .github/workflows/srs-logger.yml delete mode 100644 .github/workflows/srs-scheduler.yml delete mode 100644 .github/workflows/srs-setup.yml create mode 100644 api/.dev.vars.example create mode 100644 api/README.md create mode 100644 api/bun.lock rename {.github/srs => api/data}/schedule.json (100%) create mode 100644 api/migrations/0001_init.sql create mode 100644 api/package.json create mode 100755 api/scripts/import-srs.ts create mode 100644 api/src/catalog.ts create mode 100644 api/src/charts.ts create mode 100644 api/src/digest.ts create mode 100644 api/src/gate.ts create mode 100644 api/src/github.ts create mode 100644 api/src/index.ts create mode 100644 api/src/links.ts create mode 100644 api/src/mirror.ts create mode 100644 api/src/srs.test.ts create mode 100644 api/src/srs.ts create mode 100644 api/src/stats.ts create mode 100644 api/tsconfig.json create mode 100644 api/wrangler.jsonc delete mode 100644 docs/gate-log.md create mode 100644 docs/progress.mdx create mode 100644 islands/ProgressDashboard.tsx delete mode 100755 scripts/srs-gate.ts delete mode 100755 scripts/srs-logger.ts delete mode 100644 scripts/srs-project.ts delete mode 100755 scripts/srs-scheduler.ts delete mode 100755 scripts/srs-setup.ts delete mode 100644 scripts/srs.ts diff --git a/.github/srs/badge.json b/.github/srs/badge.json deleted file mode 100644 index 9248027..0000000 --- a/.github/srs/badge.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "schemaVersion": 1, - "label": "gate pass rate", - "message": "no gates yet", - "color": "lightgrey" -} diff --git a/.github/srs/srs.json b/.github/srs/srs.json deleted file mode 100644 index af75acb..0000000 --- a/.github/srs/srs.json +++ /dev/null @@ -1,563 +0,0 @@ -{ - "problems": { - "1": { - "issue": 29, - "topic": 3, - "difficulty": "easy", - "set": "core", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "4": { - "issue": 66, - "topic": 7, - "difficulty": "hard", - "set": "deferred", - "stage": "new", - "next_review": "2026-09-29", - "defer_until": "2026-09-29", - "history": [] - }, - "11": { - "issue": 44, - "topic": 5, - "difficulty": "medium", - "set": "core", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "15": { - "issue": 43, - "topic": 5, - "difficulty": "medium", - "set": "core", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "33": { - "issue": 60, - "topic": 7, - "difficulty": "medium", - "set": "core", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "42": { - "issue": 49, - "topic": 5, - "difficulty": "hard", - "set": "deferred", - "stage": "new", - "next_review": "2026-09-28", - "defer_until": "2026-09-28", - "history": [] - }, - "49": { - "issue": 30, - "topic": 3, - "difficulty": "medium", - "set": "core", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "52": { - "issue": 156, - "topic": 22, - "difficulty": "hard", - "set": "deferred", - "stage": "new", - "next_review": "2026-10-03", - "defer_until": "2026-10-03", - "history": [] - }, - "72": { - "issue": 171, - "topic": 24, - "difficulty": "medium", - "set": "deferred", - "stage": "new", - "next_review": "2026-10-03", - "defer_until": "2026-10-03", - "history": [] - }, - "76": { - "issue": 57, - "topic": 6, - "difficulty": "hard", - "set": "deferred", - "stage": "new", - "next_review": "2026-09-28", - "defer_until": "2026-09-28", - "history": [] - }, - "84": { - "issue": 76, - "topic": 9, - "difficulty": "hard", - "set": "deferred", - "stage": "new", - "next_review": "2026-09-30", - "defer_until": "2026-09-30", - "history": [] - }, - "97": { - "issue": 172, - "topic": 24, - "difficulty": "medium", - "set": "deferred", - "stage": "new", - "next_review": "2026-10-04", - "defer_until": "2026-10-04", - "history": [] - }, - "121": { - "issue": 50, - "topic": 6, - "difficulty": "easy", - "set": "core", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "124": { - "issue": 105, - "topic": 13, - "difficulty": "hard", - "set": "deferred", - "stage": "new", - "next_review": "2026-09-30", - "defer_until": "2026-09-30", - "history": [] - }, - "125": { - "issue": 41, - "topic": 5, - "difficulty": "easy", - "set": "core", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "167": { - "issue": 42, - "topic": 5, - "difficulty": "medium", - "set": "core", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "212": { - "issue": 129, - "topic": 17, - "difficulty": "hard", - "set": "deferred", - "stage": "new", - "next_review": "2026-10-02", - "defer_until": "2026-10-02", - "history": [] - }, - "217": { - "issue": 27, - "topic": 3, - "difficulty": "easy", - "set": "core", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "221": { - "issue": 173, - "topic": 24, - "difficulty": "medium", - "set": "deferred", - "stage": "new", - "next_review": "2026-10-04", - "defer_until": "2026-10-04", - "history": [] - }, - "224": { - "issue": 71, - "topic": 8, - "difficulty": "hard", - "set": "deferred", - "stage": "new", - "next_review": "2026-09-29", - "defer_until": "2026-09-29", - "history": [] - }, - "238": { - "issue": 37, - "topic": 4, - "difficulty": "medium", - "set": "core", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "242": { - "issue": 28, - "topic": 3, - "difficulty": "easy", - "set": "core", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "269": { - "issue": 145, - "topic": 20, - "difficulty": "hard", - "set": "deferred", - "stage": "new", - "next_review": "2026-10-02", - "defer_until": "2026-10-02", - "history": [] - }, - "295": { - "issue": 125, - "topic": 16, - "difficulty": "hard", - "set": "deferred", - "stage": "new", - "next_review": "2026-10-01", - "defer_until": "2026-10-01", - "history": [] - }, - "303": { - "issue": 36, - "topic": 4, - "difficulty": "easy", - "set": "core", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "347": { - "issue": 31, - "topic": 3, - "difficulty": "medium", - "set": "core", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "383": { - "issue": 32, - "topic": 3, - "difficulty": "easy", - "set": "optional", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "392": { - "issue": 45, - "topic": 5, - "difficulty": "easy", - "set": "optional", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "502": { - "issue": 126, - "topic": 16, - "difficulty": "hard", - "set": "deferred", - "stage": "new", - "next_review": "2026-10-01", - "defer_until": "2026-10-01", - "history": [] - }, - "560": { - "issue": 38, - "topic": 4, - "difficulty": "medium", - "set": "core", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "704": { - "issue": 58, - "topic": 7, - "difficulty": "easy", - "set": "core", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "875": { - "issue": 59, - "topic": 7, - "difficulty": "medium", - "set": "core", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - }, - "977": { - "issue": 46, - "topic": 5, - "difficulty": "easy", - "set": "optional", - "solved_on": "2026-08-24", - "stage": "+2", - "next_review": "2026-08-26", - "history": [ - { - "date": "2026-08-24", - "kind": "first", - "result": "pass" - } - ] - } - }, - "topics": { - "3": { - "misses": 0, - "boost": false - }, - "4": { - "misses": 0, - "boost": false - }, - "5": { - "misses": 0, - "boost": false - }, - "6": { - "misses": 0, - "boost": false - }, - "7": { - "misses": 0, - "boost": false - }, - "8": { - "misses": 0, - "boost": false - }, - "9": { - "misses": 0, - "boost": false - }, - "10": { - "misses": 0, - "boost": false - }, - "11": { - "misses": 0, - "boost": false - }, - "12": { - "misses": 0, - "boost": false - }, - "13": { - "misses": 0, - "boost": false - }, - "14": { - "misses": 0, - "boost": false - }, - "15": { - "misses": 0, - "boost": false - }, - "16": { - "misses": 0, - "boost": false - }, - "17": { - "misses": 0, - "boost": false - }, - "18": { - "misses": 0, - "boost": false - }, - "19": { - "misses": 0, - "boost": false - }, - "20": { - "misses": 0, - "boost": false - }, - "21": { - "misses": 0, - "boost": false - }, - "22": { - "misses": 0, - "boost": false - }, - "23": { - "misses": 0, - "boost": false - }, - "24": { - "misses": 0, - "boost": false - }, - "25": { - "misses": 0, - "boost": false - }, - "26": { - "misses": 0, - "boost": false - } - }, - "drill_pool_used": [], - "gates": [ - { - "week": 2, - "issue": 208, - "date": "2026-08-24", - "problems": [ - 189, - 209, - 162, - 71, - 496 - ], - "fallbacks": [], - "results": {} - } - ] -} diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml deleted file mode 100644 index d821751..0000000 --- a/.github/workflows/gate.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Gate - -on: - schedule: - - cron: "0 12 * * 6" # Saturday 8 AM ET β€” create this week's gate - workflow_dispatch: - issues: - types: [closed] # grade a gate when it closes - -permissions: - contents: write - issues: write - -# Shared with srs-scheduler / srs-logger / srs-setup: never race on srs.json. -concurrency: - group: srs-state - cancel-in-progress: false - -jobs: - create: - # Trigger A: the only issue-creating workflow in the SRS. The script - # no-ops when this week's gate already exists. - if: github.event_name != 'issues' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 - - - run: bun scripts/srs-gate.ts create - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Commit state - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add .github/srs - git diff --cached --quiet && { echo "no state change"; exit 0; } - git commit -m "srs: create weekly gate [skip ci]" - for i in 1 2 3; do git push && exit 0; git pull --rebase; done - git push - - close: - # Trigger B: grade, log, boost, badge. - if: >- - github.event_name == 'issues' && - contains(github.event.issue.labels.*.name, 'gate') - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 - - - run: bun scripts/srs-gate.ts close - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Commit state - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add .github/srs docs/gate-log.md - git diff --cached --quiet && { echo "no state change"; exit 0; } - git commit -m "srs: grade gate #${{ github.event.issue.number }} [skip ci]" - for i in 1 2 3; do git push && exit 0; git pull --rebase; done - git push diff --git a/.github/workflows/srs-logger.yml b/.github/workflows/srs-logger.yml deleted file mode 100644 index 7f19dcc..0000000 --- a/.github/workflows/srs-logger.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: SRS Logger - -on: - issue_comment: - types: [created] - -permissions: - contents: write - issues: write - -# Shared with srs-scheduler / gate / srs-setup: never race on srs.json. -concurrency: - group: srs-state - cancel-in-progress: false - -jobs: - log: - # Owner-only, and only on the daily brief or a gate issue. Put every - # /done line in ONE comment β€” queued duplicate runs may be superseded. - if: >- - github.event.comment.user.login == github.repository_owner && - (github.event.issue.title == 'πŸ“‹ Today' || - contains(github.event.issue.labels.*.name, 'gate')) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 - - - run: bun scripts/srs-logger.ts - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PROJECT_PAT: ${{ secrets.PROJECT_PAT }} - - - name: Commit state - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add .github/srs - git diff --cached --quiet && { echo "no state change"; exit 0; } - git commit -m "srs: log results from #${{ github.event.issue.number }} [skip ci]" - for i in 1 2 3; do git push && exit 0; git pull --rebase; done - git push diff --git a/.github/workflows/srs-scheduler.yml b/.github/workflows/srs-scheduler.yml deleted file mode 100644 index a6b9c2e..0000000 --- a/.github/workflows/srs-scheduler.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: SRS Scheduler - -on: - schedule: - - cron: "0 10 * * *" # 6 AM ET β€” before the day starts - workflow_dispatch: - -permissions: - contents: read - issues: write - -# Shared with srs-logger / gate / srs-setup: never race on srs.json. -concurrency: - group: srs-state - cancel-in-progress: false - -jobs: - brief: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 - - # Stateless: reads srs.json + schedule.json, rewrites the pinned - # πŸ“‹ Today issue, mirrors Target Date. Commits nothing. - - run: bun scripts/srs-scheduler.ts - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PROJECT_PAT: ${{ secrets.PROJECT_PAT }} diff --git a/.github/workflows/srs-setup.yml b/.github/workflows/srs-setup.yml deleted file mode 100644 index bc97f97..0000000 --- a/.github/workflows/srs-setup.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: SRS Setup - -on: - workflow_dispatch: - -permissions: - contents: read - issues: read - -# Shared with the other SRS workflows: never race on srs.json. -concurrency: - group: srs-state - cancel-in-progress: false - -jobs: - setup: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 - - # Verifies PROJECT_PAT against the user project, creates the SRS - # single-select fields when missing, and re-mirrors every laddered - # problem's Target Date / SRS Stage / First Attempt. Idempotent. - - run: bun scripts/srs-setup.ts - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PROJECT_PAT: ${{ secrets.PROJECT_PAT }} diff --git a/.gitignore b/.gitignore index 6546344..6b894f2 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,9 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .blume/ /docs/.wrangler/state/v3 .blume-verify/ + +# api worker +api/.dev.vars +api/.wrangler/ +api/worker-configuration.d.ts +api/node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 54c35ae..a28c39d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,27 +2,31 @@ ## Project Overview -LeetCode interview-prep campaign (8 weeks, 2026-08-17 β†’ Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `docs/`, and GitHub Actions automation that reconciles issues, publishes docs, and drives a spaced-repetition system (SRS). Issues model the curriculum: ~24 `topic` issues own ~161 `problem` sub-issues (`set:core|optional|deferred`, `diff:*`); milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state. +LeetCode interview-prep campaign (8 weeks, 2026-08-17 β†’ Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`api/`) that runs the spaced-repetition system (SRS) β€” daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues own ~161 `problem` sub-issues (`set:core|optional|deferred`, `diff:*`); milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state. ## Architecture & Data Flow -Two subsystems, one direction of truth: +Three owners, one direction of truth: ```mermaid flowchart LR work[work/ solutions] -->|bun run sync| docs[docs/solutions/*.mdx] work -->|close-solved.yml| PI[problem issues] PI -->|close-topics.yml| TI[topic issues] - SRS[.github/srs/srs.json] -->|srs-scheduler| Today[πŸ“‹ Today issue] - Today -->|/done comments| Logger[srs-logger] --> SRS - SRS -->|mirror, best-effort| Proj[GitHub Project fields] - SRS -->|gate.yml| Gate[weekly gate issue + docs/gate-log.md] + GH[GitHub issues = catalog] -->|nightly reconcile| D1[(D1 = SRS state)] + D1 -->|8 AM ET cron| Mail[digest email + one-tap links] + Mail -->|GET /log| D1 + PI -->|/done webhook| D1 + D1 -->|best-effort mirror| Proj[GitHub Project fields] + D1 -->|Sat midnight ET| Review[Review β€” Week N issue] + D1 --> Charts[SVG charts + /api/stats] ``` -- **Reconcile, don't react.** `close-solved.ts`, `close-topics.ts`, and the SRS scripts recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional (nothing ever reopens). -- **`.github/srs/srs.json` is the single source of truth.** Project fields (`Target Date`, `SRS Stage`, `First Attempt`) are a best-effort mirror via `scripts/srs-project.ts`: every GraphQL failure becomes a warning, never an error β€” an srs.json update must never be lost to a mirror failure. Topic rows' `Target Date` is never written (problem rows only). -- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `srs.ts`, FNV-1a β†’ mulberry32, seed = date / ISO week), so running a workflow twice picks identical problems. -- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. +- **Reconcile, don't react.** `close-solved.ts`, `close-topics.ts`, and the Worker's catalog sync recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional. +- **D1 owns SRS state; GitHub issues own the catalog; `api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). Project fields (`Target Date`, `SRS Stage`, `First Attempt`) are a best-effort mirror (`api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written. +- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `api/src/srs.ts`, FNV-1a β†’ mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind). +- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `api/src/srs.test.ts`. +- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally. ## Key Directories @@ -31,8 +35,8 @@ flowchart LR | `work///..{js,py}` | Solutions, e.g. `work/Easy/Array/1.two-sum.py`. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) β€” preserve its exact shape | | `scripts/` | Flat Bun TS: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import) | | `docs/` | Blume site. `docs/solutions/()/-.mdx` generated by sync; `(algorithms)/`, `(data-structures)/` hand-written explainers | -| `.github/srs/` | Machine state: `srs.json` (bot-committed), `schedule.json` (day β†’ topic issue, human-edited), `badge.json` (shields endpoint) | -| `.github/workflows/` | deploy, close-solved, close-topics, srs-scheduler, srs-logger, gate, srs-setup | +| `api/` | Cloudflare Worker (own Bun workspace): `src/` modules, `data/schedule.json` (day β†’ topic issue, human-edited, bundled at deploy), `migrations/`, `scripts/import-srs.ts` | +| `.github/workflows/` | deploy (docs), close-solved, close-topics β€” Worker deploys are manual (`bun run api:deploy`) | ## Development Commands @@ -43,38 +47,40 @@ bun run sync # work/ β†’ docs/solutions/ pages bun run dev|build # Blume docs site bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1) bun run close-topics -- --dry-run -SRS_DRY=1 SRS_TODAY=2026-08-30 bun scripts/srs-scheduler.ts # preview a brief -bun scripts/srs-logger.ts --issue N --author prdlk --body "/done 704 pass" -bun scripts/srs-gate.ts create|close --issue N +bun run api:dev # wrangler dev on :8787 (local D1); api:test = DST guard tests +bun run api:deploy # deploy the Worker (manual by design) +curl -X POST -H "Authorization: Bearer $LINK_KEY" \ + "localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing ``` ## Code Conventions & Common Patterns -- **Scripts are either entries or libraries.** Entries (`close-*.ts`, `srs-scheduler|logger|gate|setup.ts`) use shebang + top-level `await` and run their pipeline linearly. Libraries (`github.ts`, `report.ts`, `srs.ts`, `srs-project.ts`) must stay side-effect-free on import β€” nothing touches network/credentials until a factory (`github()`, `projectMirror()`) is awaited. +- **Scripts are either entries or libraries.** Entries (`scripts/close-*.ts`) use shebang + top-level `await`. Libraries (`scripts/github.ts`, `scripts/report.ts`, everything in `api/src/`) are side-effect-free on import β€” nothing touches network/credentials until a factory (`github()`, `projectMirror()`) is called. - **Shared plumbing:** `github()` gives `repo`, `api()` (throws `METHOD path -> status body`), `list()` (paginating async generator), `closeIssue()` (comment **first**, then close β€” a failed PATCH still leaves a trace). `report.ts` gives `printTable`/`markdownTable`/`writeStepSummary`. - **Narrow API payloads with guards**, not inline casts: `if (!("number" in value) || typeof value.number !== "number") return;` (see `readProblemIssue` in `close-solved.ts`). Named-const casts only with a reason. -- **Dry-run everything:** reconcilers take `--dry-run`/`DRY_RUN=1`; SRS takes `SRS_DRY=1` plus overrides `SRS_TODAY`, `SRS_STATE`, `SRS_SCHEDULE`, `SRS_GATE_LOG`, `SRS_BADGE` β€” point `SRS_STATE` at a scratch copy to exercise logic safely. -- **Dates:** always ET calendar strings (`YYYY-MM-DD`), arithmetic anchored at noon UTC (`atNoon` in `srs.ts`) to dodge DST. Never `new Date()` math directly. +- **Dry-run everything:** reconcilers take `--dry-run`/`DRY_RUN=1`; Worker admin routes take `?dry=1&force=1&date=` (the `SRS_TODAY` equivalent) and `wrangler dev` runs against local D1 β€” exercise logic there before touching production state. +- **Dates:** always ET calendar strings (`YYYY-MM-DD`), arithmetic anchored at noon UTC (`atNoon` in `api/src/srs.ts`) to dodge DST. Never `new Date()` math directly. - **Style:** section-divider comments (`// ── name ───`), file-top doc comments explaining the *why* and invariants, 2-space JSON with trailing newline, `Map` for dynamic keys / `Record` for static tables, no tiny one-expression wrapper functions. - **Known intentional duplication:** `close-solved.ts` re-implements header stripping instead of importing from `sync.ts` because `sync.ts` runs its pipeline on import. Don't "deduplicate" it. ## Important Files -- `scripts/srs.ts` β€” SRS domain: ladder (`new β†’ +2 β†’ +5 β†’ +10 β†’ retired`; fail resets to `+2`), `fetchCatalog()` (walks topic sub-issues, parses `LC Β· Β· Β· ` titles), campaign math (`CAMPAIGN_START`). +- `api/src/srs.ts` β€” SRS domain: ladder (`new β†’ +2 β†’ +5 β†’ +10 β†’ retired`; fail resets to `+2`; first-ever log enters at `+2`), ET date math, seeded sampling, `logAttempt()` (the ONE write path β€” email taps, webhook, gate scoring all converge here). +- `api/src/index.ts` β€” router + cron dispatch; `api/wrangler.jsonc` β€” bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`. - `scripts/sync.ts` β€” workβ†’docs contract: only `## Solution` onward is script-owned on existing pages; human prose is never touched; never hand-write solution pages or edit inside `## Solution`. -- `.github/srs/srs.json`, `docs/gate-log.md`, `.github/srs/badge.json` β€” **bot-committed** (`github-actions[bot]`, `[skip ci]`). Don't hand-edit; fix via scripts. +- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the docs `/progress` page fetches `/api/stats` client-side (`islands/ProgressDashboard.tsx`). - `blume.config.ts` β€” site base `/leetcode`; `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`). -- Workflows share concurrency group `srs-state` (never race on srs.json); `PROJECT_PAT` secret is required for user-Project GraphQL (default `GITHUB_TOKEN` cannot), used by srs-scheduler/logger/setup only. +- Old `.github/srs/` JSON state and the `srs-*` Actions are RETIRED β€” do not resurrect; `api/scripts/import-srs.ts` documents the migration. ## Runtime/Tooling Preferences -- **Bun only**: run scripts with `bun scripts/.ts`, install with `bun install --frozen-lockfile`; `bun.lock` is the sole lockfile. Node 22 appears only in `deploy.yml` because Blume requires it. -- **No tsconfig, no ESLint, no Prettier** β€” match surrounding style by hand. `@types/bun` is the only type dependency. -- Automation workflows skip `bun install` (scripts use Bun builtins + `fetch` only) β€” keep new automation dependency-free. +- **Bun only**: run scripts with `bun scripts/.ts`, install with `bun install --frozen-lockfile`; `api/` is its own workspace with its own lockfile. Node 22 appears only in `deploy.yml` because Blume requires it. +- **No root tsconfig, no ESLint, no Prettier** β€” match surrounding style by hand. `api/` has a strict `tsconfig.json`; `Env` types are generated (`bunx wrangler types`), never hand-written. +- **Zero runtime npm dependencies in automation and the Worker** β€” SVG, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. - Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions. ## Testing & QA -- **No test framework, no `*.test.*` files** β€” `bun run test` submits one solution to LeetCode's judge. -- QA is dry-runs + scratch state: verify schedulers/reconcilers with `--dry-run`/`SRS_DRY=1` and a copied `SRS_STATE` before live runs; idempotency check = run twice, `cmp` output. +- Repo-wide: no test framework β€” `bun run test` submits one solution to LeetCode's judge. Exception: `api/src/srs.test.ts` (`bun:test`) proves the DST cron guards with fixed dates; run via `bun run api:test`. +- QA is dry-runs + local state: Worker logic against `wrangler dev` + local D1 with `?dry=1&date=`; reconcilers with `--dry-run`; idempotency check = run twice, `cmp` output. - Docs changes: `bunx blume build --isolated` must pass with no new warnings (then `rm -rf .blume-verify`); after sync, review only *created* pages (prose transforms are heuristic). diff --git a/README.md b/README.md index 99b3bd5..bc3e06f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,13 @@

follow badge - gate pass rate + gate pass rate +

+ +

+ phase progress
+ SRS ladder
+ attempt heatmap

diff --git a/api/.dev.vars.example b/api/.dev.vars.example new file mode 100644 index 0000000..e927b90 --- /dev/null +++ b/api/.dev.vars.example @@ -0,0 +1,3 @@ +GH_PAT=github_pat_or_gho_token_with_issues_and_projects_rw +WEBHOOK_SECRET=random_hex_matching_the_repo_webhook +LINK_KEY=random_hex_signing_one_tap_links diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..34af509 --- /dev/null +++ b/api/README.md @@ -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 pass\|fail` comments (owner only, any issue); `review`-issue close β†’ gate scoring | +| `GET /chart/{progress,ladder,heatmap}.svg`, `GET /badge/gate.svg` | public | hand-rolled SVGs, `max-age=300` (GitHub Camo's freshness floor) | +| `GET /api/stats` | public, CORS-pinned to the docs origin | one JSON document for `/progress` | +| `POST /admin/{digest,review,reconcile}` | `Authorization: Bearer ` | manual triggers; `digest` takes `?dry=1&force=1&date=` | + +## Crons (DST-proof) + +Each event has two UTC crons; code fires only when the computed ET hour +matches (`etHour` in `src/srs.ts`, unit-checked in `src/srs.test.ts`): + +- `0 12,13 * * *` β†’ 8 AM ET: catalog reconcile, then the digest. +- `0 4,5 * * 6` β†’ midnight ET Saturday: create `Review β€” Week N` (so the + 8 AM digest can link to it). + +## Secrets & bindings + +`wrangler secret put` β€” `GH_PAT` (Issues + Projects RW), `WEBHOOK_SECRET` +(matches the repo webhook), `LINK_KEY` (signs one-tap links, gates admin +routes). Bindings in `wrangler.jsonc`: `DB` (D1 `srs`), `EMAIL` +(`send_email`, restricted to the verified destination). Sender domain +`prdlk.com` is onboarded to Email Sending. + +## 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`. diff --git a/api/bun.lock b/api/bun.lock new file mode 100644 index 0000000..474d0bf --- /dev/null +++ b/api/bun.lock @@ -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=="], + } +} diff --git a/.github/srs/schedule.json b/api/data/schedule.json similarity index 100% rename from .github/srs/schedule.json rename to api/data/schedule.json diff --git a/api/migrations/0001_init.sql b/api/migrations/0001_init.sql new file mode 100644 index 0000000..a16615e --- /dev/null +++ b/api/migrations/0001_init.sql @@ -0,0 +1,53 @@ +-- SRS schema. D1 owns SRS state (stage, next_review, attempts, gates, +-- boosts); GitHub issues own the catalog columns, reconciled nightly. +CREATE TABLE problems ( + lc_number INTEGER PRIMARY KEY, + issue INTEGER NOT NULL, + topic_issue INTEGER NOT NULL, + title TEXT NOT NULL, + difficulty TEXT NOT NULL, -- easy|medium|hard + set_label TEXT NOT NULL, -- core|optional|deferred + stage TEXT NOT NULL DEFAULT 'new', -- new|+2|+5|+10|retired + next_review TEXT, -- YYYY-MM-DD ET, NULL when retired/unsolved + defer_until TEXT -- deferred Hards: 2026-09-28+ +); + +CREATE TABLE attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lc_number INTEGER NOT NULL REFERENCES problems(lc_number), + date TEXT NOT NULL, + kind TEXT NOT NULL, -- first|review|drill|gate + result TEXT NOT NULL, -- pass|fail + source TEXT NOT NULL -- email|webhook|import +); + +-- One-tap email links must be idempotent (same link twice = no-op), but +-- /done webhook corrections (pass then fail, same day) must stay legal β€” +-- so uniqueness applies to email-sourced attempts only. +CREATE UNIQUE INDEX attempts_email_once + ON attempts (lc_number, date, kind) WHERE source = 'email'; +CREATE INDEX attempts_by_date ON attempts (date); + +CREATE TABLE topics ( + issue INTEGER PRIMARY KEY, + name TEXT NOT NULL, + misses INTEGER NOT NULL DEFAULT 0, + boost INTEGER NOT NULL DEFAULT 0, + milestone INTEGER -- phase; from the topic issue +); + +CREATE TABLE gates ( + week INTEGER PRIMARY KEY, -- ISO week + issue INTEGER, + problems TEXT NOT NULL, -- JSON array of lc_numbers + pass_rate REAL, + closed_on TEXT +); + +CREATE TABLE drill_pool_used (lc_number INTEGER PRIMARY KEY); + +-- Digest idempotency: one email per ET date. +CREATE TABLE email_log ( + date TEXT PRIMARY KEY, -- YYYY-MM-DD ET + sent_at TEXT NOT NULL +); diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..c39c251 --- /dev/null +++ b/api/package.json @@ -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" + } +} diff --git a/api/scripts/import-srs.ts b/api/scripts/import-srs.ts new file mode 100755 index 0000000..33e4576 --- /dev/null +++ b/api/scripts/import-srs.ts @@ -0,0 +1,94 @@ +#!/usr/bin/env bun +/** + * One-shot migration: .github/srs/srs.json β†’ D1. + * + * Run AFTER the catalog reconcile has filled `problems` (the import only + * overlays SRS-owned fields β€” stage, next_review, defer_until β€” and inserts + * attempts/topic counters). Re-runnable without duplicates: import-sourced + * attempts are wiped and re-inserted, everything else upserts. + * + * bun 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; + topics: Record; + drill_pool_used: number[]; +} + +const state: State = JSON.parse( + await Bun.file(join(ROOT, ".github", "srs", "srs.json")).text(), +); + +const q = (v: string | null | undefined) => (v == null ? "NULL" : `'${v}'`); +const lines: string[] = ["DELETE FROM attempts WHERE source = 'import';"]; + +let attempts = 0; +for (const [lc, p] of Object.entries(state.problems)) { + lines.push( + `UPDATE problems SET stage = ${q(p.stage)}, next_review = ${q(p.next_review ?? null)}, ` + + `defer_until = ${q(p.defer_until ?? null)} WHERE lc_number = ${Number(lc)};`, + ); + for (const a of p.history) { + lines.push( + `INSERT INTO attempts (lc_number, date, kind, result, source) ` + + `VALUES (${Number(lc)}, ${q(a.date)}, ${q(a.kind)}, ${q(a.result)}, 'import');`, + ); + attempts++; + } +} +for (const [topic, t] of Object.entries(state.topics)) { + lines.push( + `UPDATE topics SET misses = ${t.misses}, boost = ${t.boost ? 1 : 0} WHERE issue = ${Number(topic)};`, + ); +} +for (const lc of state.drill_pool_used) { + lines.push(`INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (${lc});`); +} + +const sqlPath = join(API, "migrations", ".import.sql"); +await Bun.write(sqlPath, lines.join("\n") + "\n"); +const flag = REMOTE ? "--remote" : "--local"; +await $`bunx wrangler d1 execute srs ${flag} --file ${sqlPath}`.cwd(API); +await $`rm ${sqlPath}`; + +const counts = + await $`bunx wrangler d1 execute srs ${flag} --json --command ${"SELECT (SELECT COUNT(*) FROM problems) AS problems, (SELECT COUNT(*) FROM problems WHERE stage != 'new') AS laddered, (SELECT COUNT(*) FROM problems WHERE defer_until IS NOT NULL) AS deferred, (SELECT COUNT(*) FROM attempts WHERE source='import') AS imported_attempts, (SELECT COUNT(*) FROM topics) AS topics, (SELECT COUNT(*) FROM drill_pool_used) AS drills_used"}` + .cwd(API) + .json(); + +const expected = { + json_problems: Object.keys(state.problems).length, + json_attempts: attempts, + json_laddered: Object.values(state.problems).filter((p) => p.stage !== "new").length, + json_deferred: Object.values(state.problems).filter((p) => p.defer_until).length, + json_topics: Object.keys(state.topics).length, + json_drills_used: state.drill_pool_used.length, +}; +console.log("expected from srs.json:", JSON.stringify(expected)); +console.log("in D1:", JSON.stringify(counts[0]?.results?.[0] ?? counts)); diff --git a/api/src/catalog.ts b/api/src/catalog.ts new file mode 100644 index 0000000..0f1afc4 --- /dev/null +++ b/api/src/catalog.ts @@ -0,0 +1,92 @@ +/** + * Nightly GitHub β†’ D1 catalog reconcile. GitHub issues own the catalog + * (topics, problems, set/diff labels, milestones); this recomputes desired + * rows from scratch on every run β€” re-runs are no-ops. SRS-owned columns + * (stage, next_review) are NEVER overwritten; defer_until is set once, on + * first sight of a deferred problem. The Worker never invents catalog rows. + */ +import { addDays } from "./srs.ts"; +import type { GitHub } from "./github.ts"; + +const TITLE_RE = /^LC (\d+) Β· (.+) Β· (Easy|Medium|Hard) Β· (core|optional|deferred)$/; + +/** Deferred Hards enter the queue from this date, two per day. */ +const DEFER_FROM = "2026-09-28"; + +export interface ReconcileReport { + topics: number; + problems: number; +} + +export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise { + interface TopicIssue { + number: number; + title: string; + milestone: number | null; + } + const topics: TopicIssue[] = []; + for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) { + if (!raw || typeof raw !== "object") continue; + if ("pull_request" in raw) continue; + if (!("number" in raw) || typeof raw.number !== "number") continue; + if (!("title" in raw) || typeof raw.title !== "string") continue; + let milestone: number | null = null; + if ( + "milestone" in raw && + raw.milestone && + typeof raw.milestone === "object" && + "number" in raw.milestone && + typeof raw.milestone.number === "number" + ) { + milestone = raw.milestone.number; + } + topics.push({ number: raw.number, title: raw.title, milestone }); + } + + const statements: D1PreparedStatement[] = []; + for (const t of topics) { + statements.push( + db + .prepare( + `INSERT INTO topics (issue, name, milestone) VALUES (?1, ?2, ?3) + ON CONFLICT(issue) DO UPDATE SET name = ?2, milestone = ?3`, + ) + .bind(t.number, t.title.replace(/^Topic \d+ β€” /, ""), t.milestone), + ); + } + + let problems = 0; + let deferredSeen = 0; + for (const t of topics) { + for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) { + if (!raw || typeof raw !== "object") continue; + if (!("number" in raw) || typeof raw.number !== "number") continue; + if (!("title" in raw) || typeof raw.title !== "string") continue; + const m = raw.title.match(TITLE_RE); + if (!m) continue; // non-curriculum sub-issue + const lc = Number(m[1]); + const set = m[4]!; + // Two deferred Hards per day from DEFER_FROM, in catalog walk order β€” + // applied only when the row is first created (SRS owns it afterwards). + const defer = set === "deferred" ? addDays(DEFER_FROM, Math.floor(deferredSeen / 2)) : null; + if (set === "deferred") deferredSeen++; + statements.push( + db + .prepare( + `INSERT INTO problems (lc_number, issue, topic_issue, title, difficulty, set_label, defer_until, next_review) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) + ON CONFLICT(lc_number) DO UPDATE SET + issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`, + ) + .bind(lc, raw.number, t.number, m[2]!, m[3]!.toLowerCase(), set, defer), + ); + problems++; + } + } + + // D1 batches are transactional; chunk to stay under statement limits. + for (let i = 0; i < statements.length; i += 50) { + await db.batch(statements.slice(i, i + 50)); + } + return { topics: topics.length, problems }; +} diff --git a/api/src/charts.ts b/api/src/charts.ts new file mode 100644 index 0000000..35ddf65 --- /dev/null +++ b/api/src/charts.ts @@ -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 ( + `` + + `` + ); +} + +/** A element; charts place dozens of these with the same defaults. */ +function text( + x: number, + y: number, + content: string, + attrs: { size?: number; fill?: string; anchor?: string; weight?: string } = {}, +): string { + const { size = 12, fill = "#1f2328", anchor = "start", weight } = attrs; + const bold = weight ? ` font-weight="${weight}"` : ""; + return `${content}`; +} + +// ── progress: stacked bar per phase ────────────────────────────── + +interface ProgressRow { + milestone: number; + set_label: string; + done: number; + total: number; +} + +export async function progressChart(db: D1Database): Promise { + const { results } = await db + .prepare( + `SELECT t.milestone AS milestone, p.set_label AS set_label, + SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done, + COUNT(*) AS total + FROM problems p JOIN topics t ON p.topic_issue = t.issue + WHERE t.milestone IS NOT NULL + GROUP BY t.milestone, p.set_label`, + ) + .all(); + + // Per phase: core done / optional done / remaining (all-set total βˆ’ all done). + const phases = PHASES.map((phase) => { + const rows = results.filter((r) => r.milestone === phase.milestone); + const total = rows.reduce((n, r) => n + r.total, 0); + const done = rows.reduce((n, r) => n + r.done, 0); + return { + name: phase.name, + coreDone: rows.find((r) => r.set_label === "core")?.done ?? 0, + optionalDone: rows.find((r) => r.set_label === "optional")?.done ?? 0, + remaining: total - done, + done, + total, + }; + }); + + const width = 640; + const height = 300; + const barX = 168; + const barMaxW = 400; + const barH = 22; + const rowStep = 48; + const scale = Math.max(1, ...phases.map((p) => p.total)); + + const parts = [svgOpen(width, height)]; + + // Legend on top. + const legend: [string, string][] = [ + ["#2da44e", "core done"], + ["#0969da", "optional done"], + ["#d0d7de", "remaining"], + ]; + let lx = barX; + for (const [color, label] of legend) { + parts.push(``); + 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(``); + // 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(""); + return parts.join(""); +} + +// ── ladder: bar per stage ──────────────────────────────────────── + +export async function ladderChart(db: D1Database): Promise { + const { results } = await db + .prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`) + .all<{ stage: string; count: number }>(); + + const counts = STAGES.map((s) => results.find((r) => r.stage === s)?.count ?? 0); + + const width = 640; + const height = 220; + const plotTop = 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( + ``, + ); + } + 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(``); + parts.push(""); + 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 { + // 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( + ``, + ); + } + + // 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(``); + }); + parts.push(text(legendX + GREENS.length * step + 3, legendY + cell - 4, "More", { size: 10, fill: "#57606a" })); + + parts.push(""); + return parts.join(""); +} + +// ── gate badge: shields.io flat pill ───────────────────────────── + +export async function gateBadge(db: D1Database): Promise { + const row = await db + .prepare(`SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1`) + .first<{ pass_rate: number }>(); + + const label = "gate"; + const value = row ? `${Math.round(row.pass_rate * 100)}%` : "none yet"; + const 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 ( + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `${label}` + + `${label}` + + `${value}` + + `${value}` + + `` + + `` + ); +} diff --git a/api/src/digest.ts b/api/src/digest.ts new file mode 100644 index 0000000..6a09dbf --- /dev/null +++ b/api/src/digest.ts @@ -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 { + 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 ( + `pass` + + ` Β· fail` + ); +} + +export async function buildDigest(env: Env, date: string): Promise { + 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: `

${header}

Rest day. Nothing is due. Overdue reviews moved to Monday.

`, + 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( + `

${header}

`, + `

πŸ”₯ streak: ${currentStreak} day${currentStreak === 1 ? "" : "s"}

`, + ); + 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(); + const links = core + .map( + (p) => + `LC ${p.lc_number}${p.stage === "new" ? "" : " βœ“"}`, + ) + .join(" Β· "); + htmlParts.push( + `

New topic: ${topic?.name ?? `#${topicIssue}`}

`, + `

Core: ${links}

`, + ); + 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(`

Reviews due (${reviews.length})

`); + textParts.push(`Reviews due (${reviews.length})`); + if (reviews.length) { + htmlParts.push( + `

Solve each from scratch. Do not open your old solution first.

`, + ); + for (const p of reviews) { + htmlParts.push( + `

LC ${p.lc_number} β€” ${cap(p.difficulty)} β€” stage ${p.stage} β€” ${await tapLinks(env, p.lc_number, date)}

`, + ); + textParts.push(`- LC ${p.lc_number} β€” ${cap(p.difficulty)} β€” stage ${p.stage}`); + } + if (carried > 0) { + htmlParts.push(`

${carried} more carried to tomorrow (cap ${DAILY_CAP}).

`); + textParts.push(`${carried} more carried to tomorrow.`); + } + } else { + htmlParts.push(`

None.

`); + textParts.push("none"); + } + + // Blind drills β€” unlabeled. + htmlParts.push(`

Blind drills (${drills.length})

`); + textParts.push(`Blind drills (${drills.length})`); + if (drills.length) { + htmlParts.push( + `

No topic given. Name the pattern out loud before you code.

`, + ); + for (const p of drills) { + htmlParts.push( + `

LC ${p.lc_number} β€” ${cap(p.difficulty)} β€” ${await tapLinks(env, p.lc_number, date)}

`, + ); + textParts.push(`- LC ${p.lc_number} β€” ${cap(p.difficulty)}`); + } + } else { + htmlParts.push(`

None today.

`); + 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( + `

Saturday gate

`, + `

Review β€” Week ${campaignWeek(date)} (#${gate.issue}) β€” timed, blind, then close the issue.

`, + ); + 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( + `
`, + `

${footerBits.join(" Β· ")} Β· progress page

`, + ); + textParts.push(...footerBits, `progress: ${env.DOCS_URL}/progress`); + + const load = reviews.length + drills.length; + return { + subject: `SRS β€” ${prettyDate(date)} β€” ${load} on deck`, + html: `
${htmlParts.join("\n")}
`, + 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 { + const digest = await buildDigest(env, date); + if (opts.dry) return { sent: false, reason: "dry run", digest }; + + const already = await env.DB.prepare("SELECT sent_at FROM email_log WHERE date = ?") + .bind(date) + .first(); + if (already && !opts.force) return { sent: false, reason: "already sent today", digest }; + + await env.EMAIL.send({ + to: env.TO_EMAIL, + from: { email: env.FROM_EMAIL, name: "SRS" }, + subject: digest.subject, + html: digest.html, + text: digest.text, + }); + await env.DB.prepare( + "INSERT INTO email_log (date, sent_at) VALUES (?, ?) ON CONFLICT(date) DO UPDATE SET sent_at = excluded.sent_at", + ) + .bind(date, new Date().toISOString()) + .run(); + return { sent: true, reason: already ? "forced re-send" : "sent", digest }; +} diff --git a/api/src/gate.ts b/api/src/gate.ts new file mode 100644 index 0000000..928b010 --- /dev/null +++ b/api/src/gate.ts @@ -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 { + 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(); + return results; +} + +export interface CreateReport { + created: boolean; + issue?: number; + reason: string; +} + +export async function createReviewIssue(env: Env, gh: GitHub, date: string): Promise { + 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 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 { + 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 = {}; + 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)}%` }; +} diff --git a/api/src/github.ts b/api/src/github.ts new file mode 100644 index 0000000..81ac1e1 --- /dev/null +++ b/api/src/github.ts @@ -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; + graphql(query: string, variables?: Record): Promise; + list(path: string): AsyncGenerator; + /** Comment first, then close β€” a failed close still leaves a visible note. */ + closeIssue(issue: number, comment: string): Promise; + comment(issue: number, body: string): Promise; + react(commentId: number, content: string): Promise; +} + +const PER_PAGE = 100; + +export function github(token: string, repo: string): GitHub { + async function rest(path: string, init: RequestInit = {}): Promise { + 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 = {}, + ): Promise { + 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 { + 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 }), + }); + }, + }; +} diff --git a/api/src/index.ts b/api/src/index.ts new file mode 100644 index 0000000..0b92603 --- /dev/null +++ b/api/src/index.ts @@ -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 { + 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( + ` + +

${title}

${body}

`, + { status, headers: { "content-type": "text/html; charset=utf-8" } }, + ); +} + +async function handleTap(request: Request, env: Env, ctx: ExecutionContext): Promise { + 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 { + 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 \``); + } + 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(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 { + 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 { + 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 { + 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 { + 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; + }, +}; diff --git a/api/src/links.ts b/api/src/links.ts new file mode 100644 index 0000000..03696d7 --- /dev/null +++ b/api/src/links.ts @@ -0,0 +1,79 @@ +/** + * One-tap email links: `GET /log?p=704&r=pass&d=2026-08-31&sig=`. + * 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 { + 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 { + 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 { + 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 { + // 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=` verification. */ +export async function verifyWebhook( + secret: string, + body: ArrayBuffer, + header: string | null, +): Promise { + 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); +} diff --git a/api/src/mirror.ts b/api/src/mirror.ts new file mode 100644 index 0000000..9899513 --- /dev/null +++ b/api/src/mirror.ts @@ -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; +} + +interface ProjectInfo { + id: string; + targetDate: string; + srsStage: SelectField; + firstAttempt: SelectField; +} + +export interface Mirror { + setTargetDate(issue: number, date: string | null): Promise; + setStage(issue: number, stage: string): Promise; + setFirstAttempt(issue: number, result: string): Promise; + warnings: string[]; +} + +export function projectMirror(gh: GitHub, owner: string): Mirror { + const warnings: string[] = []; + let info: ProjectInfo | undefined; + const itemIds = new Map(); + + async function resolve(): Promise { + 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 { + 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 { + 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): Promise { + 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 }); + }), + }; +} diff --git a/api/src/srs.test.ts b/api/src/srs.test.ts new file mode 100644 index 0000000..3c53d10 --- /dev/null +++ b/api/src/srs.test.ts @@ -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"))); + }); +}); diff --git a/api/src/srs.ts b/api/src/srs.ts new file mode 100644 index 0000000..ffd9078 --- /dev/null +++ b/api/src/srs.ts @@ -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 = 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(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 = { "+2": 2, "+5": 5, "+10": 10 }; +const NEXT_STAGE: Record = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" }; + +// ── queries ────────────────────────────────────────────────────── + +export async function getProblem(db: D1Database, lc: number): Promise { + return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first(); +} + +/** Reviews due on/before `date`, oldest first β€” the overflow carry order. */ +export async function dueReviews(db: D1Database, date: string): Promise { + 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(); + 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 { + 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(); + 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 { + 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 { + 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, + }; +} diff --git a/api/src/stats.ts b/api/src/stats.ts new file mode 100644 index 0000000..7a44e76 --- /dev/null +++ b/api/src/stats.ts @@ -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 = { + 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 { + 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 + >(); + 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); + 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 = { 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, + }; +} diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 0000000..46833e8 --- /dev/null +++ b/api/tsconfig.json @@ -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"] +} diff --git a/api/wrangler.jsonc b/api/wrangler.jsonc new file mode 100644 index 0000000..b742832 --- /dev/null +++ b/api/wrangler.jsonc @@ -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. +} diff --git a/blume.config.ts b/blume.config.ts index 1a2368d..09a3f5d 100644 --- a/blume.config.ts +++ b/blume.config.ts @@ -13,6 +13,10 @@ export default defineConfig({ label: "Solutions", path: "/solutions", }, + { + label: "Progress", + path: "/progress", + }, ], }, }); diff --git a/docs/gate-log.md b/docs/gate-log.md deleted file mode 100644 index c484e51..0000000 --- a/docs/gate-log.md +++ /dev/null @@ -1,7 +0,0 @@ -# Gate log - -Weekly topic-blind gates. Target: 70% first-attempt pass rate. Rows appended -automatically by `gate.yml` when a gate issue closes. - -| Week | Date | Problems | First-attempt rate | Result | -| --- | --- | --- | --- | --- | diff --git a/docs/progress.mdx b/docs/progress.mdx new file mode 100644 index 0000000..fa87d8f --- /dev/null +++ b/docs/progress.mdx @@ -0,0 +1,9 @@ +--- +title: Progress +description: Live SRS campaign dashboard β€” phases, ladder, review queue, gates, and recent attempts, fetched from the SRS Worker. +--- + +Live view of the 56-day campaign. Everything below is fetched client-side +from the SRS Worker, so it is always current β€” no rebuild required. + + diff --git a/islands/ProgressDashboard.tsx b/islands/ProgressDashboard.tsx new file mode 100644 index 0000000..1fb3e78 --- /dev/null +++ b/islands/ProgressDashboard.tsx @@ -0,0 +1,351 @@ +/** + * Progress dashboard island β€” the expanded, always-current version of the + * daily email footer. Rendered on /progress; fetches campaign state from the + * SRS Worker at page load, entirely client-side, so `blume build` never + * depends on the API being up. API_BASE is a placeholder stamped in after the + * Worker deploys β€” do not inline it anywhere else. + * + * Invariants: the component never throws on malformed API data (a shape guard + * downgrades to the error state), and the first frame is always the loading + * state so server and client render identically. + */ +import { useEffect, useState, type CSSProperties } from "react"; + +export const client = "load"; + +const API_BASE = "https://srs-api.prdlk.workers.dev"; + +// ── /api/stats response shape ──────────────────────────────────── + +const STAGES = ["new", "+2", "+5", "+10", "retired"] as const; + +type Stage = (typeof STAGES)[number]; + +interface Stats { + generated: string; + campaign: { day: number; week: number; start: string; days: number }; + phases: { + milestone: number; + name: string; + core_done: number; + core_total: number; + optional_done: number; + optional_total: number; + deferred_done: number; + deferred_total: number; + }[]; + ladder: Record; + gates: { + week: number; + issue: number | null; + pass_rate: number | null; + closed_on: string | null; + }[]; + streak: number; + queue: { date: string; due: number }[]; + recent: { date: string; attempts: number; passes: number }[]; +} + +function isStats(value: unknown): value is Stats { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return ( + typeof v.campaign === "object" && + v.campaign !== null && + typeof v.ladder === "object" && + v.ladder !== null && + Array.isArray(v.phases) && + Array.isArray(v.gates) && + Array.isArray(v.queue) && + Array.isArray(v.recent) && + typeof v.streak === "number" + ); +} + +// ── formatting ─────────────────────────────────────────────────── + +/** Legacy gates stored integer percent; the Worker may emit a 0–1 fraction. */ +function percent(rate: number): string { + return `${Math.round(rate <= 1 ? rate * 100 : rate)}%`; +} + +/** "2026-08-24" β†’ "Mon 24" without timezone drift. */ +function dayLabel(iso: string): string { + const d = new Date(`${iso}T00:00:00`); + const weekday = d.toLocaleDateString("en-US", { weekday: "short" }); + return `${weekday} ${iso.slice(8)}`; +} + +// ── shared styles (theme tokens only β€” no hand-rolled palette) ─── + +const card: CSSProperties = { + border: "1px solid var(--blume-border)", + borderRadius: "var(--blume-radius)", + padding: "0.75rem 1rem", + marginBottom: "1rem", +}; + +const muted: CSSProperties = { + color: "var(--blume-muted-foreground)", + fontSize: "0.85em", +}; + +const heading: CSSProperties = { + fontWeight: 600, + marginBottom: "0.5rem", +}; + +function Bar({ done, total }: { done: number; total: number }) { + const ratio = total > 0 ? Math.min(done / total, 1) : 0; + return ( + + + + ); +} + +// ── sections ───────────────────────────────────────────────────── + +function Header({ stats }: { stats: Stats }) { + const { campaign, streak } = stats; + return ( +
+ + + Day {campaign.day}/{campaign.days} + + + Week {campaign.week} + + Streak: {streak} day{streak === 1 ? "" : "s"} + + started {campaign.start} +
+ ); +} + +function Phases({ stats }: { stats: Stats }) { + return ( +
+
Phases
+ + + {stats.phases.map((p) => ( + + + + + + ))} + +
{p.name} + {" "} + + core {p.core_done}/{p.core_total} + + + + optional {p.optional_done}/{p.optional_total} + {p.deferred_total > 0 && + ` Β· deferred ${p.deferred_done}/${p.deferred_total}`} + +
+
+ ); +} + +function Ladder({ stats }: { stats: Stats }) { + return ( +
+
SRS ladder
+
+ {STAGES.map((stage) => ( + + {stage}{" "} + {stats.ladder[stage] ?? 0} + + ))} +
+
+ ); +} + +function Queue({ stats }: { stats: Stats }) { + const max = Math.max(1, ...stats.queue.map((q) => q.due)); + return ( +
+
Review queue β€” next 14 days
+ + + {stats.queue.map((q) => ( + + + + + + ))} + +
+ {dayLabel(q.date)} + + + {q.due}
+
+ ); +} + +function Gates({ stats }: { stats: Stats }) { + return ( +
+
Gate log
+ {stats.gates.length === 0 ? ( + No gates yet. + ) : ( + + + + {["Week", "Issue", "Pass rate", "Closed"].map((h) => ( + + ))} + + + + {stats.gates.map((g) => ( + + + + + + + ))} + +
+ {h} +
{g.week} + {g.issue === null ? ( + β€” + ) : ( + + #{g.issue} + + )} + + {g.pass_rate === null ? ( + open + ) : ( + percent(g.pass_rate) + )} + + {g.closed_on ?? β€”} +
+ )} +
+ ); +} + +function Recent({ stats }: { stats: Stats }) { + return ( +
+
Last 7 days
+
+ {stats.recent.map((r) => ( + + {dayLabel(r.date)} + {r.passes} + /{r.attempts} + + ))} +
+
passes / attempts
+
+ ); +} + +// ── dashboard ──────────────────────────────────────────────────── + +type Load = + | { phase: "loading" } + | { phase: "error" } + | { phase: "ready"; stats: Stats }; + +export default function ProgressDashboard() { + const [load, setLoad] = useState({ phase: "loading" }); + + useEffect(() => { + const controller = new AbortController(); + fetch(`${API_BASE}/api/stats`, { signal: controller.signal }) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((body: unknown) => { + setLoad(isStats(body) ? { phase: "ready", stats: body } : { phase: "error" }); + }) + .catch(() => { + if (!controller.signal.aborted) setLoad({ phase: "error" }); + }); + return () => controller.abort(); + }, []); + + if (load.phase === "loading") { + return

Loading progress…

; + } + if (load.phase === "error") { + return ( +

+ API unreachable β€” stats are served live from the SRS Worker and it did + not respond. +

+ ); + } + + const { stats } = load; + return ( +
+
+ + + + + +

Generated {stats.generated}

+
+ ); +} diff --git a/package.json b/package.json index bb592d8..f7075ca 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,9 @@ "doctor": "blume doctor", "close-solved": "bun scripts/close-solved.ts", "close-topics": "bun scripts/close-topics.ts", - "srs-scheduler": "bun scripts/srs-scheduler.ts", - "srs-logger": "bun scripts/srs-logger.ts", - "srs-gate": "bun scripts/srs-gate.ts", - "srs-setup": "bun scripts/srs-setup.ts", + "api:dev": "cd api && bun run dev", + "api:deploy": "cd api && bun run deploy", + "api:test": "cd api && bun test src", "pick": "bun scripts/pick.ts", "sync": "bun scripts/sync.ts", "test": "bun scripts/test.ts" diff --git a/scripts/srs-gate.ts b/scripts/srs-gate.ts deleted file mode 100755 index 5d6817d..0000000 --- a/scripts/srs-gate.ts +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env bun -/** - * Weekly gate: create Saturday's topic-blind quiz, and grade it on close. - * - * bun scripts/srs-gate.ts create # Trigger A (cron Sat / dispatch) - * bun scripts/srs-gate.ts close --issue N # Trigger B (issues: closed) - * - * create β€” samples ONE unsolved `set:optional` problem per topic scheduled - * THIS week (daily drills only ever draw from earlier weeks, so every gate - * problem is a first encounter). Seeded by ISO week number: re-runs pick the - * same problems, and an existing `Gate β€” Week N` issue makes the run a no-op. - * Exhausted optional pools fall back to an unsolved core problem, and the - * body says so. Boost flags + miss counters from LAST week's gate are cleared - * here β€” the boosted topics had their remedial week. - * - * close β€” reads this gate's `/done` results (recorded by srs-logger) from - * srs.json, computes the first-attempt pass rate (unlogged problems count as - * failures β€” skipping a gate problem is not a pass), appends docs/gate-log.md, - * comments a summary, sets `boost: true` on every topic that failed a gate - * problem or accumulated β‰₯ 2 drill misses, and refreshes the README badge - * endpoint at .github/srs/badge.json. - */ -import { join } from "node:path"; -import { parseArgs } from "node:util"; - -import { github } from "./github.ts"; -import { - campaignWeek, - fetchCatalog, - isoWeek, - loadSchedule, - loadState, - rng, - sample, - saveState, - todayET, -} from "./srs.ts"; - -const ROOT = join(import.meta.dir, ".."); -const GATE_LOG = process.env.SRS_GATE_LOG ?? join(ROOT, "docs", "gate-log.md"); -const BADGE = process.env.SRS_BADGE ?? join(ROOT, ".github", "srs", "badge.json"); -const PASS_TARGET = 70; // percent, first-attempt - -const { positionals, values } = parseArgs({ - allowPositionals: true, - options: { issue: { type: "string" } }, -}); -const mode = positionals[0]; - -const gh = await github(); -const state = await loadState(); -const today = todayET(); - -// ── Trigger A: create this week's gate ─────────────────────────── - -async function create(): Promise { - const week = campaignWeek(today); - const title = `Gate β€” Week ${week}`; - - for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=gate&state=all`)) { - const issue = raw as { number: number; title: string }; - if (issue.title === title) { - console.log(`${title} already exists (#${issue.number}); nothing to do`); - return; - } - } - - const schedule = await loadSchedule(); - const topics = Object.entries(schedule) - .filter(([date]) => campaignWeek(date) === week) - .sort(([a], [b]) => (a < b ? -1 : 1)) - .map(([, topic]) => topic); - if (topics.length === 0) { - console.log(`no topics scheduled in week ${week}; no gate to create`); - return; - } - - const catalog = await fetchCatalog(gh); - const random = rng(`gate-${isoWeek(today)}`); - const picks: { lc: number; fallback: boolean }[] = []; - for (const topic of topics) { - const fresh = (set: string) => - [...catalog.problems.values()] - .filter( - (p) => - p.topic === topic && - p.set === set && - p.open && - !state.problems[p.lc] && - !state.drill_pool_used.includes(p.lc), - ) - .sort((a, b) => a.lc - b.lc); - const optional = fresh("optional"); - if (optional.length > 0) { - picks.push({ lc: sample(optional, 1, random)[0]!.lc, fallback: false }); - continue; - } - const core = fresh("core"); - if (core.length > 0) picks.push({ lc: sample(core, 1, random)[0]!.lc, fallback: true }); - // Both pools exhausted: the topic is fully solved; nothing to quiz. - } - if (picks.length === 0) { - console.log(`every pool for week ${week} is exhausted; no gate to create`); - return; - } - - const lines = picks.map(({ lc, fallback }) => { - const p = catalog.problems.get(lc)!; - return `- [LC ${p.lc}](${p.url}) β€” ${p.difficulty}${fallback ? " *(core fallback β€” optional pool exhausted)*" : ""}`; - }); - const body = [ - `**Target: ${PASS_TARGET}% first-attempt pass rate. Timed. Narrate out loud.**`, - "", - ...lines, - "", - "Log with `/done ` comments, then close this issue.", - ].join("\n"); - - const milestone = catalog.topics.get(topics[0]!)?.milestone; - const created = (await gh.api(`/repos/${gh.repo}/issues`, { - method: "POST", - body: JSON.stringify({ title, body, labels: ["gate"], milestone }), - })) as { number: number }; - - state.gates.push({ - week, - issue: created.number, - date: today, - problems: picks.map((p) => p.lc), - fallbacks: picks.filter((p) => p.fallback).map((p) => p.lc), - results: {}, - }); - - // Last week's boosted topics had their remedial week: clear flags/counters. - for (const t of Object.values(state.topics)) { - if (t.boost) { - t.boost = false; - t.misses = 0; - } - } - - await saveState(state); - console.log(`created #${created.number} ${title}: ${picks.map((p) => `LC ${p.lc}`).join(", ")}`); -} - -// ── Trigger B: grade a closed gate ─────────────────────────────── - -async function close(issueNumber: number): Promise { - const gate = state.gates.find((g) => g.issue === issueNumber); - if (!gate) { - console.log(`issue #${issueNumber} has no gate record in srs.json; ignoring`); - return; - } - if (gate.rate !== undefined) { - console.log(`gate #${issueNumber} already graded (${gate.rate}%); nothing to do`); - return; - } - - const catalog = await fetchCatalog(gh); - const passes = gate.problems.filter((lc) => gate.results[lc] === "pass"); - const unlogged = gate.problems.filter((lc) => gate.results[lc] === undefined); - const rate = Math.round((passes.length / gate.problems.length) * 100); - gate.rate = rate; - const passed = rate >= PASS_TARGET; - - // Boost: explicit gate failures, plus topics with β‰₯ 2 recognition misses. - const boosted: number[] = []; - for (const lc of gate.problems) { - if (gate.results[lc] === "fail") { - const topic = catalog.problems.get(lc)?.topic; - if (topic !== undefined) { - (state.topics[topic] ??= { misses: 0, boost: false }).boost = true; - boosted.push(topic); - } - } - } - for (const [topic, t] of Object.entries(state.topics)) { - if (t.misses >= 2 && !t.boost) { - t.boost = true; - boosted.push(Number(topic)); - } - } - - // Gate log row. - const log = await Bun.file(GATE_LOG).text(); - const problems = gate.problems.map((lc) => `LC ${lc}`).join(" Β· "); - await Bun.write( - GATE_LOG, - `${log.trimEnd()}\n| ${gate.week} | ${today} | ${problems} | ${rate}% | ${passed ? "pass" : "fail"} |\n`, - ); - - // Badge endpoint (shields.io schema). - await Bun.write( - BADGE, - `${JSON.stringify( - { - schemaVersion: 1, - label: "gate pass rate", - message: `${rate}%`, - color: passed ? "brightgreen" : "red", - }, - null, - 2, - )}\n`, - ); - - await saveState(state); - - const summary = [ - `## Gate β€” Week ${gate.week}: **${rate}%** first-attempt (target ${PASS_TARGET}%) β€” ${passed ? "βœ… pass" : "❌ fail"}`, - "", - ...gate.problems.map((lc) => { - const r = gate.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.` : "", - boosted.length - ? `Boosted topics for next week's drills: ${[...new Set(boosted)].map((t) => `#${t}`).join(", ")}.` - : "No topics boosted.", - "", - "Logged to `docs/gate-log.md`.", - ] - .filter((l) => l !== "") - .join("\n"); - await gh.api(`/repos/${gh.repo}/issues/${issueNumber}/comments`, { - method: "POST", - body: JSON.stringify({ body: summary }), - }); - console.log(`graded gate #${issueNumber}: ${rate}% (${passed ? "pass" : "fail"})`); -} - -// ── dispatch ───────────────────────────────────────────────────── - -async function eventIssueNumber(): Promise { - if (values.issue) return Number(values.issue); - if (!process.env.GITHUB_EVENT_PATH) return NaN; - const event: unknown = JSON.parse(await Bun.file(process.env.GITHUB_EVENT_PATH).text()); - if ( - event && - typeof event === "object" && - "issue" in event && - event.issue && - typeof event.issue === "object" && - "number" in event.issue && - typeof event.issue.number === "number" - ) { - return event.issue.number; - } - return NaN; -} - -if (mode === "create") { - await create(); -} else if (mode === "close") { - const issueNumber = await eventIssueNumber(); - if (!Number.isFinite(issueNumber)) throw new Error("close mode needs --issue N or an event"); - await close(issueNumber); -} else { - throw new Error(`usage: srs-gate.ts create | close --issue N (got "${mode}")`); -} diff --git a/scripts/srs-logger.ts b/scripts/srs-logger.ts deleted file mode 100755 index 5428ddb..0000000 --- a/scripts/srs-logger.ts +++ /dev/null @@ -1,226 +0,0 @@ -#!/usr/bin/env bun -/** - * Process `/done ` comments on the `πŸ“‹ Today` brief and on - * gate issues, and move problems along the interval ladder. - * - * Transitions (see srs.ts): - * - pass: new β†’ +2 β†’ +5 β†’ +10 β†’ retired; next_review = today + new interval. - * - fail: back to +2, due in 2 days. - * - First-ever log of a problem enters the ladder at +2 either way; a failed - * blind drill additionally increments its topic's recognition-miss counter - * (a signal the gate's boost logic reads). Drills join drill_pool_used so - * they are never served twice. - * - A clean first PASS also closes the problem's sub-issue. - * - * Feedback is never silent: a clean parse gets a πŸ‘ reaction, anything else - * gets a reply naming the exact parse error. Only the repo owner is heard. - * - * Input: GITHUB_EVENT_PATH (issue_comment payload) in Actions, or - * --issue N --author LOGIN --comment-id N --body "..." locally. - */ -import { parseArgs } from "node:util"; - -import { github } from "./github.ts"; -import { projectMirror, reportMirror } from "./srs-project.ts"; -import { - type Attempt, - addDays, - fail, - fetchCatalog, - loadState, - pass, - saveState, - TODAY_TITLE, - todayET, -} from "./srs.ts"; - -const DRY = process.env.SRS_DRY === "1"; - -// ── event input ────────────────────────────────────────────────── - -interface Input { - issue: number; - issueTitle: string; - issueLabels: string[]; - author: string; - commentId: number; - body: string; -} - -async function readInput(): Promise { - if (process.env.GITHUB_EVENT_PATH) { - const event = JSON.parse(await Bun.file(process.env.GITHUB_EVENT_PATH).text()); - return { - issue: event.issue.number, - issueTitle: event.issue.title, - issueLabels: (event.issue.labels ?? []).map((l: { name: string }) => l.name), - author: event.comment.user.login, - commentId: event.comment.id, - body: event.comment.body ?? "", - }; - } - const { values } = parseArgs({ - options: { - issue: { type: "string" }, - author: { type: "string" }, - "comment-id": { type: "string" }, - body: { type: "string" }, - title: { type: "string" }, - labels: { type: "string" }, - }, - }); - return { - issue: Number(values.issue), - issueTitle: values.title ?? TODAY_TITLE, - issueLabels: values.labels?.split(",") ?? [], - author: values.author ?? "", - commentId: Number(values["comment-id"] ?? 0), - body: values.body ?? "", - }; -} - -const gh = await github(); -const input = await readInput(); -const owner = gh.repo.split("/")[0]!; - -if (input.author !== owner) { - console.log(`ignoring comment by @${input.author} (owner only)`); - process.exit(0); -} -const isGate = input.issueLabels.includes("gate"); -const isToday = input.issueTitle === TODAY_TITLE; -if (!isGate && !isToday) { - console.log("not the Today brief or a gate issue; ignoring"); - process.exit(0); -} - -// ── parse ──────────────────────────────────────────────────────── - -interface Command { - lc: number; - result: "pass" | "fail"; -} - -const commands: Command[] = []; -const errors: string[] = []; -for (const raw of input.body.split("\n")) { - const line = raw.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 Command["result"] }); - else errors.push(`cannot parse \`${line}\` β€” expected \`/done \``); -} - -if (commands.length === 0 && errors.length === 0) { - console.log("no /done lines; nothing to do"); - process.exit(0); -} - -// ── apply ──────────────────────────────────────────────────────── - -const state = await loadState(); -const catalog = await fetchCatalog(gh); -const today = todayET(); -const mirror = await projectMirror(owner, gh.repo); -const applied: string[] = []; - -const gate = isGate ? state.gates.find((g) => g.issue === input.issue) : undefined; -if (isGate && !gate) errors.push(`no gate record for issue #${input.issue} in srs.json`); - -for (const cmd of commands) { - const known = catalog.problems.get(cmd.lc); - const entry = state.problems[cmd.lc]; - - if (!entry && !known) { - errors.push(`LC ${cmd.lc} is not in the curriculum`); - continue; - } - - if (gate && known && gate.problems.includes(cmd.lc)) { - gate.results[cmd.lc] = cmd.result; - } - - if (!entry) { - // First-ever attempt: enters the ladder at +2 regardless of result β€” a - // pass earns a +2 review, a fail must be re-solved just as soon. - const kind: Attempt["kind"] = gate ? "gate" : known!.set === "optional" ? "drill" : "first"; - const p: (typeof state.problems)[string] = { - issue: known!.issue, - topic: known!.topic, - difficulty: known!.difficulty.toLowerCase(), - set: known!.set, - solved_on: today, - stage: "+2", - next_review: addDays(today, 2), - history: [{ date: today, kind, result: cmd.result }], - }; - state.problems[cmd.lc] = p; - if (kind === "drill") { - if (!state.drill_pool_used.includes(cmd.lc)) state.drill_pool_used.push(cmd.lc); - if (cmd.result === "fail") { - const t = (state.topics[known!.topic] ??= { misses: 0, boost: false }); - t.misses++; - applied.push(`LC ${cmd.lc}: recognition miss β€” topic #${known!.topic} misses=${t.misses}`); - } - } - if (!DRY) { - await mirror.setFirstAttempt(known!.issue, cmd.result); - await mirror.setStage(known!.issue, "+2"); - await mirror.setTargetDate(known!.issue, p.next_review!); - if (cmd.result === "pass" && known!.open) { - await gh.closeIssue( - known!.issue, - `First attempt passed (${kind}) β€” entering the review ladder at +2. Logged by \`srs-logger\`.`, - ); - } - } - applied.push(`LC ${cmd.lc}: first ${kind} ${cmd.result} β†’ stage +2, review ${p.next_review}`); - continue; - } - - // Already on the ladder: a review (or a gate/drill re-encounter). - if (entry.stage === "retired") { - errors.push(`LC ${cmd.lc} is already retired`); - continue; - } - entry.history.push({ date: today, kind: "review", result: cmd.result }); - if (cmd.result === "pass") { - pass(entry, today); - } else { - fail(entry, today); - } - delete entry.defer_until; // once touched, it lives by next_review alone - if (!DRY) { - await mirror.setStage(entry.issue, entry.stage); - await mirror.setTargetDate(entry.issue, entry.next_review ?? null); - } - applied.push( - `LC ${cmd.lc}: review ${cmd.result} β†’ stage ${entry.stage}` + - (entry.next_review ? `, review ${entry.next_review}` : " β€” retired πŸŽ‰"), - ); -} - -// ── persist + respond ──────────────────────────────────────────── - -if (!DRY) await saveState(state); - -for (const line of applied) console.log(line); -for (const line of errors) console.log(`error: ${line}`); - -if (!DRY && input.commentId) { - if (errors.length === 0) { - await gh.api(`/repos/${gh.repo}/issues/comments/${input.commentId}/reactions`, { - method: "POST", - body: JSON.stringify({ content: "+1" }), - }); - } else { - await gh.api(`/repos/${gh.repo}/issues/${input.issue}/comments`, { - method: "POST", - body: JSON.stringify({ - body: `Could not log everything:\n\n${errors.map((e) => `- ${e}`).join("\n")}${applied.length ? `\n\nApplied anyway:\n\n${applied.map((a) => `- ${a}`).join("\n")}` : ""}`, - }), - }); - } -} - -await reportMirror(mirror); diff --git a/scripts/srs-project.ts b/scripts/srs-project.ts deleted file mode 100644 index da0c0f0..0000000 --- a/scripts/srs-project.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * Best-effort mirror of SRS state into the user-level GitHub Project - * ("Interview Prep"). - * - * The JSON state file is the truth; these fields only make the project's - * table and roadmap views double as a live review calendar. Every operation - * is wrapped: a failure records a warning and the run continues β€” a workflow - * must never lose an srs.json update because a GraphQL mutation failed. - * - * Auth: PROJECT_PAT (the default GITHUB_TOKEN cannot touch user projects), - * falling back to GH_TOKEN / `gh auth token` for local runs. - * - * Guardrail: only problem issues are ever passed in here, so topic rows' - * Target Date is never written. - */ -import { $ } from "bun"; - -const PROJECT_TITLE = "Interview Prep"; - -interface SelectField { - id: string; - options: Record; // option name -> option id -} - -interface ProjectInfo { - id: string; - targetDate: string; // field id - srsStage: SelectField; - firstAttempt: SelectField; -} - -export interface ProjectMirror { - /** Set (or clear, with null) a problem row's Target Date. */ - setTargetDate(issue: number, date: string | null): Promise; - setStage(issue: number, stage: string): Promise; - setFirstAttempt(issue: number, result: string): Promise; - /** Accumulated failures; print + step-summarize these, never throw. */ - warnings: string[]; -} - -async function resolveToken(): Promise { - const env = process.env.PROJECT_PAT || process.env.GH_TOKEN || process.env.GITHUB_TOKEN; - if (env) return env; - return (await $`gh auth token`.text()).trim(); -} - -export async function projectMirror(owner: string, repo: string): Promise { - const warnings: string[] = []; - let token = ""; - let project: ProjectInfo | undefined; - const itemIds = new Map(); - - async function graphql(query: string, variables: Record): Promise { - const res = await fetch("https://api.github.com/graphql", { - method: "POST", - headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, - 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; - } - - /** Resolve project + field/option ids once; by name, never hardcoded. */ - async function resolve(): Promise { - if (project) return project; - token ||= await resolveToken(); - if (!token) throw new Error("no PROJECT_PAT / token available"); - - const data = (await 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 β€” run srs-setup`); - 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 from project`); - - project = { - id: node.id, - targetDate: date.id, - srsStage: select("SRS Stage"), - firstAttempt: select("First Attempt"), - }; - return project; - } - - /** The issue's item id in THIS project (an issue can be in several). */ - async function itemId(issue: number): Promise { - if (itemIds.has(issue)) { - const cached = itemIds.get(issue); - if (!cached) throw new Error(`issue #${issue} is not in the project`); - return cached; - } - const info = await resolve(); - const [repoOwner, repoName] = repo.split("/"); - const data = (await 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 === info.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 { - const info = await resolve(); - await graphql( - `mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) { - updateProjectV2ItemFieldValue( - input: { projectId: $project, itemId: $item, fieldId: $field, value: $value } - ) { projectV2Item { id } } - }`, - { project: info.id, item: await itemId(issue), field: fieldId, value }, - ); - } - - async function clearField(issue: number, fieldId: string): Promise { - const info = await resolve(); - await graphql( - `mutation($project: ID!, $item: ID!, $field: ID!) { - clearProjectV2ItemFieldValue( - input: { projectId: $project, itemId: $item, fieldId: $field } - ) { projectV2Item { id } } - }`, - { project: info.id, item: await itemId(issue), field: fieldId }, - ); - } - - /** Run op; on failure record a warning instead of propagating. */ - async function attempt(what: string, op: () => Promise): Promise { - try { - await op(); - } catch (err) { - warnings.push(`${what}: ${err instanceof Error ? err.message : String(err)}`); - } - } - - return { - warnings, - setTargetDate: (issue, date) => - attempt(`Target Date #${issue}`, async () => { - const info = await resolve(); - if (date === null) await clearField(issue, info.targetDate); - else await setField(issue, info.targetDate, { date }); - }), - setStage: (issue, stage) => - attempt(`SRS Stage #${issue}`, async () => { - const info = await resolve(); - const option = info.srsStage.options[stage]; - if (!option) throw new Error(`no option "${stage}"`); - await setField(issue, info.srsStage.id, { singleSelectOptionId: option }); - }), - setFirstAttempt: (issue, result) => - attempt(`First Attempt #${issue}`, async () => { - const info = await resolve(); - const option = info.firstAttempt.options[result]; - if (!option) throw new Error(`no option "${result}"`); - await setField(issue, info.firstAttempt.id, { singleSelectOptionId: option }); - }), - }; -} - -/** Print mirror warnings and surface them in the Actions run summary. */ -export async function reportMirror(mirror: ProjectMirror): Promise { - if (mirror.warnings.length === 0) return; - console.warn(`\nproject mirror: ${mirror.warnings.length} warning(s) β€” srs.json is the truth`); - for (const w of mirror.warnings) console.warn(` ⚠ ${w}`); - if (process.env.GITHUB_STEP_SUMMARY) { - await Bun.write( - process.env.GITHUB_STEP_SUMMARY, - `### ⚠ project mirror warnings\n\nsrs.json was updated; these project mutations failed:\n\n${mirror.warnings.map((w) => `- ${w}`).join("\n")}\n`, - ); - } -} diff --git a/scripts/srs-scheduler.ts b/scripts/srs-scheduler.ts deleted file mode 100755 index 3a55cd8..0000000 --- a/scripts/srs-scheduler.ts +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env bun -/** - * Rewrite the pinned `πŸ“‹ Today` daily-brief issue from SRS state. - * - * Runs every day at 10:00 UTC (6 AM ET) and on demand. Stateless by design: - * it reads srs.json + schedule.json and rewrites one issue body, so running - * it twice in a day is a byte-identical no-op (drill picks are seeded by the - * date). Overflow needs no bookkeeping either β€” anything past the daily cap - * simply stays due and is the oldest entry tomorrow. - * - * Retrieval rules enforced here: - * - Review and drill lines carry number + difficulty only. Never the topic, - * never a link β€” recognizing the pattern unaided is the exercise. - * - Blind drills come only from optional pools of topics covered in EARLIER - * weeks; the current week's optional pool is reserved for Saturday's gate. - * - Boosted topics (failed gate problem / repeated drill misses) inject up to - * 2 extra drills, inside the same daily cap. - * - * bun scripts/srs-scheduler.ts # rewrite the live issue - * SRS_DRY=1 SRS_TODAY=2026-08-30 bun ... # print the body, touch nothing - */ -import { github } from "./github.ts"; -import { projectMirror, reportMirror } from "./srs-project.ts"; -import { - CAMPAIGN_DAYS, - type Catalog, - type State, - TODAY_TITLE, - campaignDay, - campaignWeek, - dueReviews, - fetchCatalog, - loadSchedule, - loadState, - prettyDate, - rng, - sample, - todayET, - weekdayOf, -} from "./srs.ts"; - -const DRY = process.env.SRS_DRY === "1"; -const DAILY_CAP = 6; - -const gh = await github(); -const state = await loadState(); -const schedule = await loadSchedule(); -const today = todayET(); -const week = campaignWeek(today); - -// ── compose the body ───────────────────────────────────────────── - -/** Week in which a topic was (or will be) taught, from the schedule file. */ -function topicWeek(topic: number): number | undefined { - for (const [date, t] of Object.entries(schedule)) { - if (t === topic) return campaignWeek(date); - } - return undefined; -} - -interface Drill { - lc: number; - difficulty: string; -} - -function pickDrills(catalog: Catalog, budget: number): Drill[] { - // Optional problems from earlier weeks, unseen by both ladder and drills. - const pool = [...catalog.problems.values()] - .filter((p) => { - if (p.set !== "optional" || !p.open) return false; - if (state.problems[p.lc] || state.drill_pool_used.includes(p.lc)) return false; - const w = topicWeek(p.topic); - return w !== undefined && w < week; - }) - .sort((a, b) => a.lc - b.lc); // stable base order for the seeded shuffle - - const boosted = pool.filter((p) => state.topics[p.topic]?.boost); - const regular = pool.filter((p) => !state.topics[p.topic]?.boost); - - const picks = [ - ...sample(boosted, Math.min(2, budget), rng(`boost-${today}`)), - ...sample(regular, Math.min(2, Math.max(0, budget - Math.min(2, boosted.length))), rng(`drill-${today}`)), - ].slice(0, budget); - return picks.map((p) => ({ lc: p.lc, difficulty: p.difficulty })); -} - -function composeBody(catalog: Catalog): string { - if (weekdayOf(today) === 0) { - return "Rest day. Nothing is due. Overdue reviews moved to Monday."; - } - - const due = dueReviews(state, today); - const reviews = due.slice(0, DAILY_CAP); - const carried = due.length - reviews.length; - const drills = pickDrills(catalog, DAILY_CAP - reviews.length); - - const lines: string[] = [ - `## ${prettyDate(today)} β€” Day ${campaignDay(today)}/${CAMPAIGN_DAYS} Β· Week ${week}`, - "", - ]; - - const topicIssue = schedule[today]; - const topic = topicIssue === undefined ? undefined : catalog.topics.get(topicIssue); - if (topic) { - const core = [...catalog.problems.values()] - .filter((p) => p.topic === topic.issue && p.set === "core") - .sort((a, b) => a.lc - b.lc) - .map((p) => `[LC ${p.lc}](${p.url})${p.open ? "" : " βœ“"}`); - lines.push(`### New topic: ${topic.name} (#${topic.issue})`, `Core: ${core.join(" Β· ")}`, ""); - } else { - lines.push("_No new topic today β€” reviews and drills only._", ""); - } - - lines.push(`### Reviews due (${reviews.length})`); - if (reviews.length) { - lines.push("Solve each from scratch. Do not open your old solution first."); - for (const [lc, p] of reviews) { - const difficulty = catalog.problems.get(Number(lc))?.difficulty ?? p.difficulty; - lines.push(`- LC ${lc} β€” ${difficulty} β€” stage ${p.stage}`); - } - if (carried > 0) lines.push(`\n${carried} more carried to tomorrow (cap ${DAILY_CAP}).`); - } else { - lines.push("None."); - } - lines.push(""); - - lines.push(`### Blind drills (${drills.length})`); - if (drills.length) { - lines.push("No topic given. Name the pattern out loud before you code."); - for (const d of drills) lines.push(`- LC ${d.lc} β€” ${d.difficulty}`); - } else { - lines.push("None today."); - } - lines.push(""); - - lines.push( - "### Log your results", - "Comment on this issue, one line per problem:", - "`/done 704 pass` Β· `/done 15 fail`", - "", - "### Rules", - "90-minute cap Β· core first, then reviews, then drills Β· close-out", - "ritual on every submit.", - ); - return lines.join("\n"); -} - -// ── find / create / pin the Today issue ────────────────────────── - -async function todayIssue(): Promise<{ number: number; created: boolean }> { - for await (const raw of gh.list(`/repos/${gh.repo}/issues?state=open`)) { - const issue = raw as { number: number; title: string; pull_request?: unknown }; - if (!issue.pull_request && issue.title === TODAY_TITLE) { - return { number: issue.number, created: false }; - } - } - const created = (await gh.api(`/repos/${gh.repo}/issues`, { - method: "POST", - body: JSON.stringify({ title: TODAY_TITLE, body: "(initializing)" }), - })) as { number: number; node_id: string }; - try { - await gh.api("/graphql", { - method: "POST", - body: JSON.stringify({ - query: `mutation($id: ID!) { pinIssue(input: { issueId: $id }) { issue { number } } }`, - variables: { id: created.node_id }, - }), - }); - } catch (err) { - console.warn(`could not pin #${created.number}: ${err} β€” pin it by hand`); - } - return { number: created.number, created: true }; -} - -// ── run ────────────────────────────────────────────────────────── - -const catalog = await fetchCatalog(gh); -const body = composeBody(catalog); - -if (DRY) { - console.log(body); - process.exit(0); -} - -const issue = await todayIssue(); -await gh.api(`/repos/${gh.repo}/issues/${issue.number}`, { - method: "PATCH", - body: JSON.stringify({ body }), -}); -console.log(`${issue.created ? "created + pinned" : "rewrote"} #${issue.number} ${TODAY_TITLE}`); - -// Mirror every due problem's next_review so the project's Target Date views -// stay a live review calendar (problem rows only β€” never topic rows). -const mirror = await projectMirror(gh.repo.split("/")[0]!, gh.repo); -if (weekdayOf(today) !== 0) { - for (const [, p] of dueReviews(state as State, today)) { - await mirror.setTargetDate(p.issue, p.next_review!); - } -} -await reportMirror(mirror); -console.log(body); diff --git a/scripts/srs-setup.ts b/scripts/srs-setup.ts deleted file mode 100755 index 901a5b0..0000000 --- a/scripts/srs-setup.ts +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env bun -/** - * One-shot (idempotent) SRS setup + PROJECT_PAT verification. - * - * 1. Proves the token can talk GraphQL (viewer login) β€” the acceptance test - * for the PROJECT_PAT repo secret when dispatched as a workflow. - * 2. Resolves the "Interview Prep" user project and its built-in Target Date - * field (which the SRS reuses β€” no custom date field is ever created). - * 3. Creates the `SRS Stage` and `First Attempt` single-selects when missing. - * 4. Mirrors every laddered problem's state into the project fields. - * - * Prints every resolved ID so they are on record in the run log. - */ -import { $ } from "bun"; - -import { github } from "./github.ts"; -import { projectMirror, reportMirror } from "./srs-project.ts"; -import { loadState } from "./srs.ts"; - -const PROJECT_TITLE = "Interview Prep"; - -const token = - process.env.PROJECT_PAT || - process.env.GH_TOKEN || - process.env.GITHUB_TOKEN || - (await $`gh auth token`.text()).trim(); - -async function graphql(query: string, variables: Record = {}): Promise { - const res = await fetch("https://api.github.com/graphql", { - method: "POST", - headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, - 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; -} - -// 1. token check β€” hard fail here means PROJECT_PAT is missing or scopeless. -const viewerData = (await graphql(`{ viewer { login } }`)) as { viewer: { login: string } }; -console.log(`token OK β€” authenticated as @${viewerData.viewer.login}`); - -const gh = await github(); -const owner = gh.repo.split("/")[0]!; - -// 2. resolve the project and its fields. -interface FieldNode { - id: string; - name: string; - dataType?: string; - options?: { id: string; name: string }[]; -} -const projectData = (await graphql( - `query($owner: String!, $title: String!) { - user(login: $owner) { - projectsV2(first: 10, query: $title) { - nodes { - id number 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; number: number; title: string; fields: { nodes: FieldNode[] } }[] }; - }; -}; -const project = projectData.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE); -if (!project) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`); -console.log(`project: "${project.title}" #${project.number} β€” ${project.id}`); - -let fields = project.fields.nodes; -const targetDate = fields.find((f) => f.name === "Target Date" && f.dataType === "DATE"); -if (!targetDate) { - throw new Error('the built-in "Target Date" field is missing β€” add it in the project UI'); -} -console.log(`Target Date field: ${targetDate.id} (reused, not created)`); - -// 3. create the two SRS single-selects when absent. -const WANTED: Record = { - "SRS Stage": [ - { name: "new", color: "GRAY", description: "not yet on the ladder" }, - { name: "+2", color: "YELLOW", description: "review due 2 days after last solve" }, - { name: "+5", color: "ORANGE", description: "review due 5 days after last solve" }, - { name: "+10", color: "BLUE", description: "review due 10 days after last solve" }, - { name: "retired", color: "GREEN", description: "passed all three stages" }, - ], - "First Attempt": [ - { name: "pass", color: "GREEN", description: "first timed solve passed" }, - { name: "fail", color: "RED", description: "first timed solve failed" }, - ], -}; - -for (const [name, options] of Object.entries(WANTED)) { - const existing = fields.find((f) => f.name === name); - if (existing) { - console.log(`${name} field: ${existing.id} (already exists)`); - continue; - } - const optionsArg = options - .map((o) => `{name:"${o.name}",color:${o.color},description:"${o.description}"}`) - .join(","); - const created = (await graphql( - `mutation($project: ID!, $name: String!) { - createProjectV2Field(input: { - projectId: $project, dataType: SINGLE_SELECT, name: $name, - singleSelectOptions: [${optionsArg}] - }) { - projectV2Field { ... on ProjectV2SingleSelectField { id name } } - } - }`, - { project: project.id, name }, - )) as { createProjectV2Field: { projectV2Field: { id: string } } }; - console.log(`${name} field: ${created.createProjectV2Field.projectV2Field.id} (created)`); - fields = [...fields, { id: created.createProjectV2Field.projectV2Field.id, name }]; -} - -// 4. mirror every laddered problem into the project. -const state = await loadState(); -const mirror = await projectMirror(owner, gh.repo); -let mirrored = 0; -for (const [lc, p] of Object.entries(state.problems)) { - await mirror.setStage(p.issue, p.stage); - await mirror.setTargetDate(p.issue, p.stage === "retired" ? null : (p.next_review ?? null)); - const first = p.history[0]; - if (first) await mirror.setFirstAttempt(p.issue, first.result); - mirrored++; - if (mirrored % 10 === 0) console.log(` mirrored ${mirrored} problems… (last LC ${lc})`); -} -console.log(`mirrored ${mirrored} laddered problems into the project`); -await reportMirror(mirror); - -if (process.env.GITHUB_STEP_SUMMARY && mirror.warnings.length === 0) { - await Bun.write( - process.env.GITHUB_STEP_SUMMARY, - `### srs-setup βœ…\n\n- token: @${viewerData.viewer.login}\n- project: ${project.id} (#${project.number})\n- Target Date: ${targetDate.id}\n- mirrored: ${mirrored} problems\n`, - ); -} diff --git a/scripts/srs.ts b/scripts/srs.ts deleted file mode 100644 index 156296f..0000000 --- a/scripts/srs.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * Domain library for the spaced-repetition system (SRS). - * - * State model β€” `.github/srs/srs.json` is the single source of truth; the - * GitHub Project fields are a best-effort mirror (see srs-project.ts). - * - * The stage names the review a problem must pass NEXT: a problem at `+2` has - * a review due 2 days after its last clean solve. Passing advances - * new β†’ +2 β†’ +5 β†’ +10 β†’ retired; any failure resets to +2. `new` is reserved - * for deferred Hards that have never been solved β€” `defer_until` keeps them - * out of the queue until Sep 28. - * - * All dates are America/New_York calendar dates (YYYY-MM-DD): the cron fires - * at 10:00 UTC = 6 AM ET, and the campaign is lived in ET. - */ -import { join } from "node:path"; - -import type { GitHub } from "./github.ts"; - -const ROOT = join(import.meta.dir, ".."); - -// ── state ──────────────────────────────────────────────────────── - -export type Stage = "new" | "+2" | "+5" | "+10" | "retired"; -export type Result = "pass" | "fail"; - -export interface Attempt { - date: string; - /** first = learning-day solve, drill = blind drill, gate = gate problem. */ - kind: "first" | "review" | "drill" | "gate"; - result: Result; -} - -export interface ProblemState { - issue: number; - topic: number; - difficulty: string; - set: string; - solved_on?: string; - stage: Stage; - next_review?: string; - /** Deferred Hards stay out of the due queue until this date. */ - defer_until?: string; - history: Attempt[]; -} - -export interface TopicState { - /** Blind-drill recognition misses since the last boost reset. */ - misses: number; - /** Set at gate close; scheduler injects extra drills; next gate clears it. */ - boost: boolean; -} - -export interface GateState { - week: number; - issue: number; - date: string; - problems: number[]; - /** Problems that fell back to the core set (optional pool exhausted). */ - fallbacks: number[]; - results: Record; - rate?: number; -} - -export interface State { - problems: Record; - topics: Record; - drill_pool_used: number[]; - gates: GateState[]; -} - -export const STATE_PATH = process.env.SRS_STATE ?? join(ROOT, ".github", "srs", "srs.json"); -export const SCHEDULE_PATH = - process.env.SRS_SCHEDULE ?? join(ROOT, ".github", "srs", "schedule.json"); - -export async function loadState(): Promise { - return JSON.parse(await Bun.file(STATE_PATH).text()); -} - -export async function saveState(state: State): Promise { - await Bun.write(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`); -} - -/** Day (YYYY-MM-DD) β†’ topic issue number. Human-edited when life happens. */ -export async function loadSchedule(): Promise> { - return JSON.parse(await Bun.file(SCHEDULE_PATH).text()); -} - -// ── dates (America/New_York) ───────────────────────────────────── - -/** Campaign day 1 β€” Monday of Phase I week 1. Day 56 = Oct 11. */ -export const CAMPAIGN_START = "2026-08-17"; -export const CAMPAIGN_DAYS = 56; - -/** Today's ET calendar date; SRS_TODAY overrides for tests and reruns. */ -export function todayET(): string { - if (process.env.SRS_TODAY) return process.env.SRS_TODAY; - return new Intl.DateTimeFormat("en-CA", { - timeZone: "America/New_York", - dateStyle: "short", - }).format(new Date()); -} - -/** 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(); -} - -/** 1-based campaign day; may exceed CAMPAIGN_DAYS after the camp ends. */ -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; -} - -export function isoWeek(date: string): number { - const d = atNoon(date); - // ISO 8601: week containing the year's first Thursday is week 1. - 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 interval ladder ────────────────────────────────────────── - -export const INTERVAL: Record = { "+2": 2, "+5": 5, "+10": 10 }; -const NEXT_STAGE: Record = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" }; - -/** Advance on a pass; returns false when the problem was already retired. */ -export function pass(p: ProblemState, today: string): boolean { - const next = NEXT_STAGE[p.stage]; - if (!next) return false; - p.stage = next; - p.solved_on = today; - if (next === "retired") { - delete p.next_review; - } else { - p.next_review = addDays(today, INTERVAL[next]!); - } - return true; -} - -/** Reset on a failure: back to the +2 stage, due again in 2 days. */ -export function fail(p: ProblemState, today: string): void { - p.stage = "+2"; - p.next_review = addDays(today, 2); -} - -/** Reviews due today or earlier, oldest first β€” the overflow carry order. */ -export function dueReviews(state: State, today: string): [string, ProblemState][] { - return Object.entries(state.problems) - .filter( - ([, p]) => - p.stage !== "retired" && - p.next_review !== undefined && - p.next_review <= today && - (p.defer_until === undefined || p.defer_until <= today), - ) - .sort(([a, pa], [b, pb]) => - pa.next_review === pb.next_review - ? Number(a) - Number(b) - : pa.next_review! < pb.next_review! - ? -1 - : 1, - ); -} - -// ── deterministic sampling ─────────────────────────────────────── - -/** FNV-1a β†’ mulberry32: a 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; - }; -} - -/** Take up to n elements, Fisher–Yates order driven by the seeded PRNG. */ -export function sample(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); -} - -// ── the live catalog (topics + problem sub-issues) ─────────────── - -export interface CatalogProblem { - lc: number; - issue: number; - name: string; - difficulty: "Easy" | "Medium" | "Hard"; - set: "core" | "optional" | "deferred"; - topic: number; - open: boolean; - url: string; -} - -export interface CatalogTopic { - issue: number; - /** e.g. "Topic 03 β€” Two Pointers". */ - title: string; - /** Bare name, e.g. "Two Pointers". */ - name: string; - milestone: number | undefined; -} - -export interface Catalog { - topics: Map; - problems: Map; - byIssue: Map; -} - -const TITLE_RE = /^LC (\d+) Β· (.+) Β· (Easy|Medium|Hard) Β· (core|optional|deferred)$/; - -/** - * Walk every `topic` issue and its sub-issues into one lookup structure. - * The sub-issue linkage is authoritative: every problem hangs off exactly - * one topic (verified against the full issue inventory). - */ -export async function fetchCatalog(gh: GitHub): Promise { - const topics = new Map(); - for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) { - const t = raw as { - number: number; - title: string; - pull_request?: unknown; - milestone?: { number: number } | null; - }; - if (t.pull_request) continue; - const name = t.title.replace(/^Topic \d+ β€” /, ""); - topics.set(t.number, { - issue: t.number, - title: t.title, - name, - milestone: t.milestone?.number, - }); - } - - const problems = new Map(); - const byIssue = new Map(); - for (const topic of topics.keys()) { - for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) { - const s = raw as { number: number; title: string; state: string; body?: string }; - const m = s.title.match(TITLE_RE); - if (!m) continue; // non-curriculum sub-issue - const url = (s.body ?? "").match(/https:\/\/leetcode\.com\/problems\/[a-z0-9-]+\/?/)?.[0]; - const p: CatalogProblem = { - lc: Number(m[1]), - issue: s.number, - name: m[2]!, - difficulty: m[3] as CatalogProblem["difficulty"], - set: m[4] as CatalogProblem["set"], - topic, - open: s.state === "open", - url: url ?? "", - }; - problems.set(p.lc, p); - byIssue.set(p.issue, p); - } - } - return { topics, problems, byIssue }; -} - -// ── shared constants ───────────────────────────────────────────── - -export const TODAY_TITLE = "πŸ“‹ Today";