feat: migrate docs

This commit is contained in:
Prad Nukala
2026-08-25 11:20:52 -04:00
parent e64285f201
commit 9fbe800fed
81 changed files with 30 additions and 8255 deletions
+29 -27
View File
@@ -2,7 +2,7 @@
## Project Overview ## 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/`, 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. LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/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 ## Architecture & Data Flow
@@ -10,7 +10,7 @@ Three owners, one direction of truth:
```mermaid ```mermaid
flowchart LR flowchart LR
work[work/ solutions] -->|bun run sync| docs[docs/solutions/*.mdx] work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
work -->|close-solved.yml| PI[problem issues] work -->|close-solved.yml| PI[problem issues]
PI -->|close-topics.yml| TI[topic issues] PI -->|close-topics.yml| TI[topic issues]
GH[GitHub issues = catalog] -->|nightly reconcile| D1[(D1 = SRS state)] GH[GitHub issues = catalog] -->|nightly reconcile| D1[(D1 = SRS state)]
@@ -23,9 +23,9 @@ flowchart LR
``` ```
- **Reconcile, don't react.** `close-solved.ts`, `close-topics.ts`, and the Worker's catalog sync recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional. - **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. - **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). Project fields (`Target Date`, `SRS Stage`, `First Attempt`) are a best-effort mirror (`apps/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). - **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
- **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`. - **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/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. - **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 ## Key Directories
@@ -33,56 +33,58 @@ flowchart LR
| Path | Purpose | | Path | Purpose |
|---|---| |---|---|
| `work/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/Easy/Array/1.two-sum.py`. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape | | `work/<Difficulty>/<Category>/<num>.<slug>.{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) | | `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
| `docs/` | Blume site. `docs/solutions/(<category>)/<num>-<slug>.mdx` generated by sync; `(algorithms)/`, `(data-structures)/` hand-written explainers | | `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `islands/`, `public/`). `content/(<category>)/<num>-<slug>.mdx` generated by sync |
| `api/` | Cloudflare Worker (own Bun workspace): `src/` modules, `data/schedule.json` (day → topic issue, human-edited, bundled at deploy), `migrations/`, `scripts/import-srs.ts` | | `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (day → topic issue, human-edited, bundled at deploy), `migrations/`, `scripts/import-srs.ts` |
| `.github/workflows/` | deploy (docs), close-solved, close-topics, sync-d1 (issue edits → `/admin/reconcile`) — Worker deploys are manual (`bun run api:deploy`) | | `.github/workflows/` | deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved, close-topics, sync-d1 (issue edits → `/admin/reconcile`) |
## Development Commands ## Development Commands
All from the repo root (a Bun workspace over `apps/*`):
```sh ```sh
bun run pick # scaffold a solution via leetcode-cli (fuzzy picker) bun run pick # scaffold a solution via leetcode-cli (fuzzy picker)
bun run test # run ONE solution against LeetCode's judge (not a test suite) bun run test # run ONE solution against LeetCode's judge (not a test suite)
bun run sync # work/ → docs/solutions/ pages bun run sync # work/ → apps/docs/content/ pages
bun run dev|build # Blume docs site bun run dev|build # Blume docs site (runs in apps/docs/)
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1) bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
bun run close-topics -- --dry-run bun run close-topics -- --dry-run
bun run api:dev # wrangler dev on :8787 (local D1); api:test = DST guard tests bun run api:dev # wrangler dev on :8787 (local D1); api:test = DST guard tests
bun run api:deploy # deploy the Worker (manual by design) bun run api:deploy # deploy the Worker by hand (CI also deploys on main pushes)
curl -X POST -H "Authorization: Bearer $LINK_KEY" \ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
"localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing "localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing
``` ```
## Code Conventions & Common Patterns ## Code Conventions & Common Patterns
- **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. - **Scripts are either entries or libraries.** Entries (`apps/cli/close-*.ts`) use shebang + top-level `await`. Libraries (`apps/cli/github.ts`, `apps/cli/report.ts`, everything in `apps/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`. - **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. - **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`; 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. - **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. - **Dates:** always ET calendar strings (`YYYY-MM-DD`), arithmetic anchored at noon UTC (`atNoon` in `apps/api/src/srs.ts`) to dodge DST. Never `new Date()` math directly.
- **Style:** section-divider comments (`// ── name ───`), file-top doc comments explaining the *why* and invariants, 2-space JSON with trailing newline, `Map` for dynamic keys / `Record` for static tables, no tiny one-expression wrapper functions. - **Style:** section-divider comments (`// ── name ───`), file-top doc comments explaining the *why* and invariants, 2-space JSON with trailing newline, `Map` for dynamic keys / `Record` for static tables, no tiny one-expression wrapper functions.
- **Known intentional duplication:** `close-solved.ts` re-implements header stripping instead of importing from `sync.ts` because `sync.ts` runs its pipeline on import. Don't "deduplicate" it. - **Known intentional duplication:** `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 ## Important Files
- `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). - `apps/api/src/srs.ts` — SRS domain: ladder (`new → +2 → +5 → +10 → retired`; fail resets to `+2`; first-ever log enters at `+2`), ET date math, seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here).
- `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`. - `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`.
- `api/src/digest.ts` / `api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/56) LeetCode Daily Digest`. - `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/56) LeetCode Daily Digest`.
- `api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift. - `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
- `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`. - `apps/cli/sync.ts` — work→docs contract: only `## Solution` onward is script-owned on existing pages; human prose is never touched; never hand-write solution pages or edit inside `## Solution`.
- `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`). - `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the docs `/progress` page fetches `/api/stats` client-side (`apps/docs/islands/ProgressDashboard.tsx`).
- `blume.config.ts` — site base `/leetcode`; `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`). - `apps/docs/blume.config.ts` — site base `/leetcode`; `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`).
- Old `.github/srs/` JSON state and the `srs-*` Actions are RETIRED — do not resurrect; `api/scripts/import-srs.ts` documents the migration. - Old `.github/srs/` JSON state and the `srs-*` Actions are RETIRED — do not resurrect; `apps/api/scripts/import-srs.ts` documents the migration.
## Runtime/Tooling Preferences ## Runtime/Tooling Preferences
- **Bun only**: run scripts with `bun scripts/<name>.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. - **Bun only**: run scripts with `bun apps/cli/<name>.ts` (or the root `bun run` aliases), install with `bun install --frozen-lockfile`. One workspace (`apps/*`), one root `bun.lock`. 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. - **No root tsconfig, no ESLint, no Prettier** — match surrounding style by hand. `apps/api/` has a strict `tsconfig.json`; `Env` types are generated (`bunx wrangler types`), never hand-written.
- **Near-zero runtime npm dependencies** — SVG, PNG, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. The **one** exception is the digest email: `api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `api/tsconfig.json`. Do not extend this exception to any other module. - **Near-zero runtime npm dependencies** — SVG, PNG, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. The **one** exception is the digest email: `apps/api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. Do not extend this exception to any other module.
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions. - Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
## Testing & QA ## Testing & QA
- 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`. - Repo-wide: no test framework — `bun run test` submits one solution to LeetCode's judge. Exception: `apps/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. - 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). - Docs changes: `bunx blume build --isolated` (from `apps/docs/`) must pass with no new warnings (then `rm -rf apps/docs/.blume-verify`); after sync, review only *created* pages (prose transforms are heuristic).
+1 -1
View File
@@ -1,5 +1,5 @@
<p align="center"> <p align="center">
<img alt="header" src="public/dependency-spine.svg" /> <img alt="header" src="https://shieldcn.dev/header/gradient.svg?title=prdlk%2Fleetcode&amp;subtitle=My+Personal+LeetCode+Solutions&amp;logo=leetcode&amp;logoColor=eab308&amp;mode=dark" />
</p> </p>
<p align="center"> <p align="center">
-3
View File
@@ -1,3 +0,0 @@
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
-90
View File
@@ -1,90 +0,0 @@
# srs-api
Cloudflare Worker running the spaced-repetition system: daily digest email,
one-tap logging, `/done` webhook, Saturday review issues, live SVG charts,
and the `/api/stats` feed for the docs progress page.
Live at `https://srs-api.prdlk.workers.dev`.
## Direction of truth
- **D1 owns SRS state** — stages, review dates, attempt history, gate
scores, boost flags. Nothing else is authoritative.
- **GitHub issues own the catalog** — topics, problems, `set:*`/`diff:*`
labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via
`/admin/reconcile`); it recomputes from scratch, never invents rows, and
never overwrites SRS-owned columns (`stage`, `next_review`).
- **The repo owns the schedule** — `data/schedule.json`, human-edited,
bundled at deploy. Git history is its audit log.
- The GitHub Project mirror (`Target Date`, `SRS Stage`, `First Attempt`)
is best-effort: failures log warnings, never block a D1 write. Topic rows
are never written.
## The ladder
`new → +2 → +5 → +10 → retired`; pass advances, fail resets to `+2`.
A problem's first-ever log enters at `+2` regardless of result. Stage names
the NEXT review's interval. All dates are ET calendar strings anchored at
noon UTC (`src/srs.ts`) — never raw `Date` math.
Blind drills draw only from topics **already learned**: scheduled in an
earlier week (the current week's optional pool is reserved for Saturday's
gate) and with at least one core problem on the ladder — a skipped learning
day never feeds drills just because its calendar week lapsed.
## Routes
| Route | Auth | Purpose |
|---|---|---|
| `GET /log?p&r&d&sig` | HMAC (`LINK_KEY`) | one-tap pass/fail; idempotent per (problem, date, kind); links expire after 3 days |
| `POST /webhook/github` | HMAC (`WEBHOOK_SECRET`) | `/done <n> pass\|fail` comments (owner only, any issue); `review`-issue close → gate scoring |
| `GET /chart/{progress,ladder,heatmap}.svg`, `GET /badge/gate.svg` | public | hand-rolled SVGs, `max-age=300` (GitHub Camo's freshness floor) |
| `GET /api/stats` | public, CORS-pinned to the docs origin | one JSON document for `/progress` |
| `POST /admin/{digest,review,reconcile}` | `Authorization: Bearer <LINK_KEY>` | manual triggers; `digest` takes `?dry=1&force=1&date=` |
## Crons (DST-proof)
Each event has two UTC crons; code fires only when the computed ET hour
matches (`etHour` in `src/srs.ts`, unit-checked in `src/srs.test.ts`):
- `0 12,13 * * *` → 8 AM ET: catalog reconcile, then the digest.
- `0 4,5 * * 6` → midnight ET Saturday: create `Review — Week N` (so the
8 AM digest can link to it).
## Secrets & bindings
`wrangler secret put``GH_PAT` (Issues + Projects RW), `WEBHOOK_SECRET`
(matches the repo webhook), `LINK_KEY` (signs one-tap links, gates admin
routes). Bindings in `wrangler.jsonc`: `DB` (D1 `srs`), `EMAIL`
(`send_email`, restricted to the verified destination). Sender domain
`prdlk.com` is onboarded to Email Sending.
The `sync-d1.yml` Actions workflow additionally pushes curriculum issue
edits into D1 immediately (`POST /admin/reconcile` with the
`SRS_ADMIN_KEY` repo secret); the morning cron is the backstop.
## Local dev
```sh
cp .dev.vars.example .dev.vars # or fill GH_PAT/WEBHOOK_SECRET/LINK_KEY
bun install
bunx wrangler d1 migrations apply srs --local
bun run dev # wrangler dev on :8787, local D1
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
"localhost:8787/admin/digest?dry=1&date=2026-08-31" # prints HTML, sends nothing
bun test src # DST guard + date math
```
`?date=` on admin routes is the `SRS_TODAY` equivalent. The one-shot
migration from the retired `.github/srs/srs.json` lives at
`scripts/import-srs.ts` (`--remote` for production D1); it is re-runnable —
import-sourced attempts are wiped and re-inserted.
## Deploy
```sh
bun run deploy # from 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`.
-292
View File
@@ -1,292 +0,0 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "srs-api",
"dependencies": {
"@react-email/components": "^1.0.12",
"@react-email/render": "^2.1.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
},
"devDependencies": {
"@types/react": "^19.2.18",
"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=="],
"@react-email/body": ["@react-email/body@0.3.0", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug=="],
"@react-email/button": ["@react-email/button@0.2.1", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A=="],
"@react-email/code-block": ["@react-email/code-block@0.2.1", "", { "dependencies": { "prismjs": "^1.30.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw=="],
"@react-email/code-inline": ["@react-email/code-inline@0.0.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA=="],
"@react-email/column": ["@react-email/column@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg=="],
"@react-email/components": ["@react-email/components@1.0.12", "", { "dependencies": { "@react-email/body": "0.3.0", "@react-email/button": "0.2.1", "@react-email/code-block": "0.2.1", "@react-email/code-inline": "0.0.6", "@react-email/column": "0.0.14", "@react-email/container": "0.0.16", "@react-email/font": "0.0.10", "@react-email/head": "0.0.13", "@react-email/heading": "0.0.16", "@react-email/hr": "0.0.12", "@react-email/html": "0.0.12", "@react-email/img": "0.0.12", "@react-email/link": "0.0.13", "@react-email/markdown": "0.0.18", "@react-email/preview": "0.0.14", "@react-email/render": "2.0.6", "@react-email/row": "0.0.13", "@react-email/section": "0.0.17", "@react-email/tailwind": "2.0.7", "@react-email/text": "0.1.6" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-tH18JhPDWgE+3jnYkzyB6ZrZdfNnEsFe4PwmuXmlOw4NGIysP8wPY5aXZg++pTG9qUabXg1nzX/FGHGkObH8xQ=="],
"@react-email/container": ["@react-email/container@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ=="],
"@react-email/font": ["@react-email/font@0.0.10", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA=="],
"@react-email/head": ["@react-email/head@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog=="],
"@react-email/heading": ["@react-email/heading@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw=="],
"@react-email/hr": ["@react-email/hr@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA=="],
"@react-email/html": ["@react-email/html@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw=="],
"@react-email/img": ["@react-email/img@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ=="],
"@react-email/link": ["@react-email/link@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw=="],
"@react-email/markdown": ["@react-email/markdown@0.0.18", "", { "dependencies": { "marked": "^15.0.12" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg=="],
"@react-email/preview": ["@react-email/preview@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw=="],
"@react-email/render": ["@react-email/render@2.1.0", "", { "dependencies": { "entities": "^4.5.0", "html-to-text": "^9.0.5", "html5parser": "^3.0.0", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-F+zE3O6d6sW6Aj2UjvZAA17R7tJKM7kcq2mgV6k4HCT8jeLLFaVP2txMtH1lgqYFRMZ0Gxsd37q2PRyiXLXXxA=="],
"@react-email/row": ["@react-email/row@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw=="],
"@react-email/section": ["@react-email/section@0.0.17", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w=="],
"@react-email/tailwind": ["@react-email/tailwind@2.0.7", "", { "dependencies": { "tailwindcss": "^4.1.18" }, "peerDependencies": { "@react-email/body": ">=0", "@react-email/button": ">=0", "@react-email/code-block": ">=0", "@react-email/code-inline": ">=0", "@react-email/container": ">=0", "@react-email/heading": ">=0", "@react-email/hr": ">=0", "@react-email/img": ">=0", "@react-email/link": ">=0", "@react-email/preview": ">=0", "@react-email/text": ">=0", "react": "^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@react-email/body", "@react-email/button", "@react-email/code-block", "@react-email/code-inline", "@react-email/container", "@react-email/heading", "@react-email/hr", "@react-email/img", "@react-email/link", "@react-email/preview"] }, "sha512-kGw80weVFXikcnCXbigTGXGWQ0MRCSYNCudcdkWxebkWYd0FG6/NPoN3V1p/u68/4+NxZwYPVi2fhnp0x23HdA=="],
"@react-email/text": ["@react-email/text@0.1.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw=="],
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
"@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="],
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"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=="],
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
"html5parser": ["html5parser@3.0.0", "", {}, "sha512-iNpSopa+4YHX50UOk825tBy7MghmXHo/ZpLskBYN0kAr1xhH8GlIMk5bLRXcZlfP3AnLUcSuFMu8C4MdOUxA8A=="],
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"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=="],
"parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
"prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
"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=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"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=="],
"@react-email/components/@react-email/render": ["@react-email/render@2.0.6", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog=="],
}
}
-26
View File
@@ -1,26 +0,0 @@
{
"2026-08-17": 3,
"2026-08-18": 4,
"2026-08-24": 5,
"2026-08-25": 6,
"2026-08-26": 7,
"2026-08-27": 8,
"2026-08-28": 9,
"2026-08-31": 10,
"2026-09-01": 11,
"2026-09-02": 12,
"2026-09-03": 13,
"2026-09-04": 14,
"2026-09-07": 15,
"2026-09-08": 16,
"2026-09-09": 17,
"2026-09-10": 18,
"2026-09-11": 19,
"2026-09-14": 20,
"2026-09-15": 21,
"2026-09-16": 22,
"2026-09-17": 23,
"2026-09-18": 24,
"2026-09-21": 25,
"2026-09-22": 26
}
-53
View File
@@ -1,53 +0,0 @@
-- 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
);
-21
View File
@@ -1,21 +0,0 @@
{
"name": "srs-api",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev --test-scheduled",
"deploy": "wrangler deploy",
"types": "wrangler types",
"test": "bun test src"
},
"devDependencies": {
"@types/react": "^19.2.18",
"wrangler": "^4.125.0"
},
"dependencies": {
"@react-email/components": "^1.0.12",
"@react-email/render": "^2.1.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
}
}
-94
View File
@@ -1,94 +0,0 @@
#!/usr/bin/env bun
/**
* One-shot migration: .github/srs/srs.json → D1.
*
* Run AFTER the catalog reconcile has filled `problems` (the import only
* overlays SRS-owned fields — stage, next_review, defer_until — and inserts
* attempts/topic counters). Re-runnable without duplicates: import-sourced
* attempts are wiped and re-inserted, everything else upserts.
*
* bun api/scripts/import-srs.ts # local D1 (wrangler dev state)
* bun api/scripts/import-srs.ts --remote # production D1
*
* Prints row counts; verify them against srs.json before deleting anything.
*/
import { $ } from "bun";
import { join } from "node:path";
const ROOT = join(import.meta.dir, "..", "..");
const API = join(ROOT, "api");
const REMOTE = process.argv.includes("--remote");
interface Attempt {
date: string;
kind: string;
result: string;
}
interface Problem {
issue: number;
topic: number;
difficulty: string;
set: string;
solved_on?: string;
stage: string;
next_review?: string;
defer_until?: string;
history: Attempt[];
}
interface State {
problems: Record<string, Problem>;
topics: Record<string, { misses: number; boost: boolean }>;
drill_pool_used: number[];
}
const state: State = JSON.parse(
await Bun.file(join(ROOT, ".github", "srs", "srs.json")).text(),
);
const q = (v: string | null | undefined) => (v == null ? "NULL" : `'${v}'`);
const lines: string[] = ["DELETE FROM attempts WHERE source = 'import';"];
let attempts = 0;
for (const [lc, p] of Object.entries(state.problems)) {
lines.push(
`UPDATE problems SET stage = ${q(p.stage)}, next_review = ${q(p.next_review ?? null)}, ` +
`defer_until = ${q(p.defer_until ?? null)} WHERE lc_number = ${Number(lc)};`,
);
for (const a of p.history) {
lines.push(
`INSERT INTO attempts (lc_number, date, kind, result, source) ` +
`VALUES (${Number(lc)}, ${q(a.date)}, ${q(a.kind)}, ${q(a.result)}, 'import');`,
);
attempts++;
}
}
for (const [topic, t] of Object.entries(state.topics)) {
lines.push(
`UPDATE topics SET misses = ${t.misses}, boost = ${t.boost ? 1 : 0} WHERE issue = ${Number(topic)};`,
);
}
for (const lc of state.drill_pool_used) {
lines.push(`INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (${lc});`);
}
const sqlPath = join(API, "migrations", ".import.sql");
await Bun.write(sqlPath, lines.join("\n") + "\n");
const flag = REMOTE ? "--remote" : "--local";
await $`bunx wrangler d1 execute srs ${flag} --file ${sqlPath}`.cwd(API);
await $`rm ${sqlPath}`;
const counts =
await $`bunx wrangler d1 execute srs ${flag} --json --command ${"SELECT (SELECT COUNT(*) FROM problems) AS problems, (SELECT COUNT(*) FROM problems WHERE stage != 'new') AS laddered, (SELECT COUNT(*) FROM problems WHERE defer_until IS NOT NULL) AS deferred, (SELECT COUNT(*) FROM attempts WHERE source='import') AS imported_attempts, (SELECT COUNT(*) FROM topics) AS topics, (SELECT COUNT(*) FROM drill_pool_used) AS drills_used"}`
.cwd(API)
.json();
const expected = {
json_problems: Object.keys(state.problems).length,
json_attempts: attempts,
json_laddered: Object.values(state.problems).filter((p) => p.stage !== "new").length,
json_deferred: Object.values(state.problems).filter((p) => p.defer_until).length,
json_topics: Object.keys(state.topics).length,
json_drills_used: state.drill_pool_used.length,
};
console.log("expected from srs.json:", JSON.stringify(expected));
console.log("in D1:", JSON.stringify(counts[0]?.results?.[0] ?? counts));
-92
View File
@@ -1,92 +0,0 @@
/**
* Nightly GitHub → D1 catalog reconcile. GitHub issues own the catalog
* (topics, problems, set/diff labels, milestones); this recomputes desired
* rows from scratch on every run — re-runs are no-ops. SRS-owned columns
* (stage, next_review) are NEVER overwritten; defer_until is set once, on
* first sight of a deferred problem. The Worker never invents catalog rows.
*/
import { addDays } from "./srs.ts";
import type { GitHub } from "./github.ts";
const TITLE_RE = /^LC (\d+) · (.+) · (Easy|Medium|Hard) · (core|optional|deferred)$/;
/** Deferred Hards enter the queue from this date, two per day. */
const DEFER_FROM = "2026-09-28";
export interface ReconcileReport {
topics: number;
problems: number;
}
export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<ReconcileReport> {
interface TopicIssue {
number: number;
title: string;
milestone: number | null;
}
const topics: TopicIssue[] = [];
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
if (!raw || typeof raw !== "object") continue;
if ("pull_request" in raw) continue;
if (!("number" in raw) || typeof raw.number !== "number") continue;
if (!("title" in raw) || typeof raw.title !== "string") continue;
let milestone: number | null = null;
if (
"milestone" in raw &&
raw.milestone &&
typeof raw.milestone === "object" &&
"number" in raw.milestone &&
typeof raw.milestone.number === "number"
) {
milestone = raw.milestone.number;
}
topics.push({ number: raw.number, title: raw.title, milestone });
}
const statements: D1PreparedStatement[] = [];
for (const t of topics) {
statements.push(
db
.prepare(
`INSERT INTO topics (issue, name, milestone) VALUES (?1, ?2, ?3)
ON CONFLICT(issue) DO UPDATE SET name = ?2, milestone = ?3`,
)
.bind(t.number, t.title.replace(/^Topic \d+ — /, ""), t.milestone),
);
}
let problems = 0;
let deferredSeen = 0;
for (const t of topics) {
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) {
if (!raw || typeof raw !== "object") continue;
if (!("number" in raw) || typeof raw.number !== "number") continue;
if (!("title" in raw) || typeof raw.title !== "string") continue;
const m = raw.title.match(TITLE_RE);
if (!m) continue; // non-curriculum sub-issue
const lc = Number(m[1]);
const set = m[4]!;
// Two deferred Hards per day from DEFER_FROM, in catalog walk order —
// applied only when the row is first created (SRS owns it afterwards).
const defer = set === "deferred" ? addDays(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
if (set === "deferred") deferredSeen++;
statements.push(
db
.prepare(
`INSERT INTO problems (lc_number, issue, topic_issue, title, difficulty, set_label, defer_until, next_review)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
ON CONFLICT(lc_number) DO UPDATE SET
issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`,
)
.bind(lc, raw.number, t.number, m[2]!, m[3]!.toLowerCase(), set, defer),
);
problems++;
}
}
// D1 batches are transactional; chunk to stay under statement limits.
for (let i = 0; i < statements.length; i += 50) {
await db.batch(statements.slice(i, i + 50));
}
return { topics: topics.length, problems };
}
-406
View File
@@ -1,406 +0,0 @@
/**
* Hand-rolled SVG chart generation for the SRS Worker.
*
* Feeds five endpoints — /chart/progress.svg, /chart/ladder.svg,
* /chart/heatmap.svg, /chart/heatmap.png, /badge/gate.svg — each served with
* `Cache-Control: public, max-age=300`. The heatmap has a raster twin because
* every major email client blocks SVG, so the digest cannot embed the vector.
*
* The SVG is string-built: no chart library, no dependencies, and the one
* import is the hand-rolled PNG encoder next door. The only dynamic values
* entering the markup are numbers, percentages, and dates the Worker itself
* computes, plus the fixed phase/stage labels — so nothing here needs XML
* escaping. Styling is shadcn dark zinc to match the README's shieldcn
* badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode
* green ramp. Every function renders on a zero-row DB.
*/
import { Canvas, textWidth } from "./png.ts";
// D1Database comes from the generated worker-configuration.d.ts runtime types.
const FONT = "Verdana,DejaVu Sans,sans-serif";
// shadcn dark-zinc palette, matching the README's shieldcn badges.
const BG = "#09090b"; // zinc-950 card
const FG = "#fafafa"; // zinc-50 text
const MUTED = "#a1a1aa"; // zinc-400 secondary text
const LINE = "#27272a"; // zinc-800 structure / empty
const GREEN = "#16a34a"; // green-600 (core / pass)
const BLUE = "#2563eb"; // blue-600 (optional)
const RED = "#f87171"; // red-400 (fail, on dark)
// Green ramp shared by the heatmap and the ladder (GitHub dark-mode
// contribution hues; level 0 is the empty zinc cell).
const GREENS = ["#27272a", "#0e4429", "#006d32", "#26a641", "#39d353"];
// Labels are stored SVG-ready: phase II's "&" is pre-escaped since these
// strings go straight into markup and nothing else here needs escaping.
const PHASES = [
{ milestone: 1, name: "I — Linear" },
{ milestone: 2, name: "II — Nodal &amp; Grid" },
{ milestone: 3, name: "III — Hierarchical" },
{ milestone: 4, name: "IV — Relational" },
{ milestone: 5, name: "V — Decision Space" },
];
const STAGES = ["new", "+2", "+5", "+10", "retired"];
// ── svg helpers ──────────────────────────────────────────────────
/** Opening tag plus the rounded dark card every chart starts with. */
function svgOpen(width: number, height: number): string {
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
`viewBox="0 0 ${width} ${height}" font-family="${FONT}">` +
`<rect width="${width}" height="${height}" rx="6" fill="${BG}"/>`
);
}
/** A <text> element; charts place dozens of these with the same defaults. */
function text(
x: number,
y: number,
content: string,
attrs: { size?: number; fill?: string; anchor?: string; weight?: string } = {},
): string {
const { size = 12, fill = FG, anchor = "start", weight } = attrs;
const bold = weight ? ` font-weight="${weight}"` : "";
return `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" text-anchor="${anchor}"${bold}>${content}</text>`;
}
// ── progress: stacked bar per phase ──────────────────────────────
interface ProgressRow {
milestone: number;
set_label: string;
done: number;
total: number;
}
export async function progressChart(db: D1Database): Promise<string> {
const { results } = await db
.prepare(
`SELECT t.milestone AS milestone, p.set_label AS set_label,
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done,
COUNT(*) AS total
FROM problems p JOIN topics t ON p.topic_issue = t.issue
WHERE t.milestone IS NOT NULL
GROUP BY t.milestone, p.set_label`,
)
.all<ProgressRow>();
// Per phase: core done / optional done / remaining (all-set total all done).
const phases = PHASES.map((phase) => {
const rows = results.filter((r) => r.milestone === phase.milestone);
const total = rows.reduce((n, r) => n + r.total, 0);
const done = rows.reduce((n, r) => n + r.done, 0);
return {
name: phase.name,
coreDone: rows.find((r) => r.set_label === "core")?.done ?? 0,
optionalDone: rows.find((r) => r.set_label === "optional")?.done ?? 0,
remaining: total - done,
done,
total,
};
});
const width = 640;
const height = 300;
const barX = 168;
const barMaxW = 400;
const barH = 22;
const rowStep = 48;
const scale = Math.max(1, ...phases.map((p) => p.total));
const parts = [svgOpen(width, height)];
// Legend on top.
const legend: [string, string][] = [
[GREEN, "core done"],
[BLUE, "optional done"],
[LINE, "remaining"],
];
let lx = barX;
for (const [color, label] of legend) {
parts.push(`<rect x="${lx}" y="12" width="12" height="12" rx="2" fill="${color}"/>`);
parts.push(text(lx + 17, 22, label, { size: 11, fill: MUTED }));
lx += 17 + label.length * 7 + 24;
}
phases.forEach((p, i) => {
const y = 52 + i * rowStep;
const midY = y + barH / 2 + 4;
parts.push(text(barX - 12, midY, p.name, { anchor: "end" }));
let x = barX;
const segments: [number, string][] = [
[p.coreDone, GREEN],
[p.optionalDone, BLUE],
[p.remaining, LINE],
];
for (const [count, color] of segments) {
const w = (count / scale) * barMaxW;
if (w > 0) {
parts.push(`<rect x="${x.toFixed(1)}" y="${y}" width="${w.toFixed(1)}" height="${barH}" fill="${color}"/>`);
// Count inside the segment when it fits; tiny slivers stay unlabeled.
if (w >= 20) {
const labelFill = color === LINE ? MUTED : FG;
parts.push(text(x + w / 2, midY, String(count), { size: 11, fill: labelFill, anchor: "middle" }));
}
x += w;
}
}
parts.push(text(x + 8, midY, `${p.done}/${p.total}`, { size: 11, fill: MUTED }));
});
parts.push("</svg>");
return parts.join("");
}
// ── ladder: bar per stage ────────────────────────────────────────
export async function ladderChart(db: D1Database): Promise<string> {
const { results } = await db
.prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`)
.all<{ stage: string; count: number }>();
const counts = STAGES.map((s) => results.find((r) => r.stage === s)?.count ?? 0);
const width = 640;
const height = 220;
const plotTop = 26;
const plotBottom = height - 32;
const plotH = plotBottom - plotTop;
const slotW = (width - 80) / STAGES.length;
const barW = 64;
const scale = Math.max(1, ...counts);
const parts = [svgOpen(width, height)];
counts.forEach((count, i) => {
const cx = 40 + slotW * i + slotW / 2;
const barH = (count / scale) * plotH;
const y = plotBottom - barH;
if (barH > 0) {
parts.push(
`<rect x="${(cx - barW / 2).toFixed(1)}" y="${y.toFixed(1)}" width="${barW}" height="${barH.toFixed(1)}" rx="3" fill="${GREENS[i]}"/>`,
);
}
parts.push(text(cx, Math.max(y - 6, 16), String(count), { size: 12, anchor: "middle", weight: "bold" }));
parts.push(text(cx, plotBottom + 18, STAGES[i]!, { size: 12, fill: MUTED, anchor: "middle" }));
});
parts.push(`<line x1="40" y1="${plotBottom}" x2="${width - 40}" y2="${plotBottom}" stroke="${LINE}"/>`);
parts.push("</svg>");
return parts.join("");
}
// ── heatmap: attempts per campaign day ───────────────────────────
const DAY_MS = 86_400_000;
// Layout in CSS pixels. heatmapPng multiplies every one of these by its device
// scale, so the vector and raster pictures cannot drift apart.
const HEAT_W = 560;
const HEAT_H = 160;
const HEAT_GRID_X = 34;
const HEAT_GRID_Y = 24;
const HEAT_CELL = 16;
const HEAT_STEP = 19; // cell + 3px gap
// Grid row → left-hand label; both renderers print only these three.
const WEEKDAYS: [number, string][] = [
[0, "Mon"],
[2, "Wed"],
[4, "Fri"],
];
/** One grid square: column, Mon-based row, and index into GREENS. */
interface HeatmapCell {
col: number;
row: number;
level: number;
}
/**
* The heatmap's whole data model — one cell per campaign day, plus the week
* count that positions the legend — shared by both renderers.
*
* Every per-day date derives from one UTC-midnight timestamp, so local-timezone
* drift never shifts a cell. The campaign starts on a Monday, so day i sits at
* column i/7; the row comes from the real weekday, so an off-Monday start still
* lands correctly.
*/
async function heatmapCells(
db: D1Database,
start: string,
days: number,
): Promise<{ cells: HeatmapCell[]; weeks: number }> {
const [sy, sm, sd] = start.split("-").map(Number);
const base = Date.UTC(sy!, sm! - 1, sd!);
const end = new Date(base + (days - 1) * DAY_MS).toISOString().slice(0, 10);
const { results } = await db
.prepare(`SELECT date, COUNT(*) AS attempts FROM attempts WHERE date >= ?1 AND date <= ?2 GROUP BY date`)
.bind(start, end)
.all<{ date: string; attempts: number }>();
const byDate = new Map(results.map((r) => [r.date, r.attempts]));
const cells: HeatmapCell[] = [];
for (let i = 0; i < days; i++) {
const day = new Date(base + i * DAY_MS);
const attempts = byDate.get(day.toISOString().slice(0, 10)) ?? 0;
cells.push({
col: Math.floor(i / 7),
row: (day.getUTCDay() + 6) % 7, // Mon = 0
level: Math.min(attempts, 4),
});
}
return { cells, weeks: Math.ceil(days / 7) };
}
export async function heatmapChart(db: D1Database, start: string, days: number): Promise<string> {
const { cells, weeks } = await heatmapCells(db, start, days);
const parts = [svgOpen(HEAT_W, HEAT_H)];
// Week numbers across the top, Mon/Wed/Fri down the left.
for (let w = 0; w < weeks; w++) {
parts.push(
text(HEAT_GRID_X + w * HEAT_STEP + HEAT_CELL / 2, HEAT_GRID_Y - 7, `W${w + 1}`, {
size: 10,
fill: MUTED,
anchor: "middle",
}),
);
}
for (const [row, label] of WEEKDAYS) {
parts.push(
text(HEAT_GRID_X - 6, HEAT_GRID_Y + row * HEAT_STEP + HEAT_CELL - 4, label, {
size: 10,
fill: MUTED,
anchor: "end",
}),
);
}
for (const { col, row, level } of cells) {
parts.push(
`<rect x="${HEAT_GRID_X + col * HEAT_STEP}" y="${HEAT_GRID_Y + row * HEAT_STEP}" width="${HEAT_CELL}" height="${HEAT_CELL}" rx="2" fill="${GREENS[level]}"/>`,
);
}
// Less → More ramp fills the space right of the grid.
const legendX = HEAT_GRID_X + weeks * HEAT_STEP + 40;
const legendY = HEAT_GRID_Y + 3 * HEAT_STEP;
parts.push(text(legendX - 6, legendY + HEAT_CELL - 4, "Less", { size: 10, fill: MUTED, anchor: "end" }));
GREENS.forEach((color, i) => {
parts.push(
`<rect x="${legendX + i * HEAT_STEP}" y="${legendY}" width="${HEAT_CELL}" height="${HEAT_CELL}" rx="2" fill="${color}"/>`,
);
});
parts.push(text(legendX + GREENS.length * HEAT_STEP + 3, legendY + HEAT_CELL - 4, "More", { size: 10, fill: MUTED }));
parts.push("</svg>");
return parts.join("");
}
// The PNG carries no alpha channel, so whatever the rounded card does not cover
// has to be painted with the colour sitting behind the image: the digest
// email's GitHub-dark card.
const EMAIL_CARD = "#0d1117";
// Device-pixel multiplier. The email hands the image the full 552px card
// width, so 3x lands a little over 2x density on a retina screen; the labels
// stay 10 CSS px, the size of the SVG's. Flat colour compresses to a few KB
// either way, so the extra resolution is close to free.
const HEAT_SCALE = 3;
/**
* The same picture as heatmapChart, rastered for email.
*
* Two deliberate departures from the SVG. The canvas is trimmed to the width
* the content actually occupies instead of HEAT_W: the README's SVG sits in a
* narrow table cell and scales up, whereas the email gives the image the full
* 552px card, and the SVG's slack right margin would otherwise shrink the
* grid to nothing. And the bitmap font hangs off a top-left origin rather than
* a baseline, so each SVG baseline becomes "baseline minus one cap height".
*/
export async function heatmapPng(db: D1Database, start: string, days: number): Promise<Uint8Array> {
const { cells, weeks } = await heatmapCells(db, start, days);
const s = HEAT_SCALE;
const gridX = HEAT_GRID_X * s;
const gridY = HEAT_GRID_Y * s;
const cell = HEAT_CELL * s;
const step = HEAT_STEP * s;
const capH = 7 * s;
const legendX = gridX + weeks * step + 40 * s;
const legendY = gridY + 3 * step;
const moreX = legendX + GREENS.length * step + 3 * s;
const width = moreX + textWidth("More", s) + gridX;
const height = HEAT_H * s;
const canvas = new Canvas(width, height, EMAIL_CARD);
canvas.rect(0, 0, width, height, BG, 6 * s);
for (let w = 0; w < weeks; w++) {
const label = `W${w + 1}`;
const middle = gridX + w * step + cell / 2;
canvas.text(middle - textWidth(label, s) / 2, gridY - 7 * s - capH, label, MUTED, s);
}
for (const [row, label] of WEEKDAYS) {
canvas.text(
gridX - 6 * s - textWidth(label, s),
gridY + row * step + cell - 4 * s - capH,
label,
MUTED,
s,
);
}
for (const { col, row, level } of cells) {
canvas.rect(gridX + col * step, gridY + row * step, cell, cell, GREENS[level]!, 2 * s);
}
const legendBase = legendY + cell - 4 * s - capH;
canvas.text(legendX - 6 * s - textWidth("Less", s), legendBase, "Less", MUTED, s);
GREENS.forEach((color, i) => {
canvas.rect(legendX + i * step, legendY, cell, cell, color, 2 * s);
});
canvas.text(moreX, legendBase, "More", MUTED, s);
return await canvas.encode();
}
// ── gate badge: shieldcn-style dark pill ─────────────────────────
export async function gateBadge(db: D1Database): Promise<string> {
const row = await db
.prepare(`SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1`)
.first<{ pass_rate: number }>();
const label = "gate";
const value = row ? `${Math.round(row.pass_rate * 100)}%` : "none yet";
const valueFill = row ? (row.pass_rate >= 0.7 ? "#4ade80" : RED) : MUTED;
// shieldcn geometry: height 32, rx 6, one flat zinc-900 pill. Verdana 13px
// ≈ 7.5px per char; 12px outer padding, 8px between label and value.
const labelW = Math.round(label.length * 7.5);
const valueW = Math.round(value.length * 7.5);
const total = 12 + labelW + 8 + valueW + 12;
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="32" ` +
`viewBox="0 0 ${total} 32" role="img" aria-label="${label}: ${value}" font-family="${FONT}">` +
`<rect width="${total}" height="32" rx="6" fill="#18181b"/>` +
`<g font-size="13">` +
`<text x="12" y="21" fill="${FG}" fill-opacity=".7">${label}</text>` +
`<text x="${12 + labelW + 8}" y="21" fill="${valueFill}" font-weight="bold">${value}</text>` +
`</g>` +
`</svg>`
);
}
-180
View File
@@ -1,180 +0,0 @@
/**
* The daily digest email — built from D1 + the bundled schedule, sent at
* 8 AM ET via the send_email binding. This module owns data only: it turns
* D1 rows into a `DigestData` and hands it to email.tsx, which renders both
* the HTML and the plain-text alternative from that single tree (React Email
* `render`), so the two can never drift.
*
* Retrieval rules: review and drill rows carry number + difficulty only —
* never the topic, never a solution link. `DigestRow` has no field for
* either, so the rule holds by construction. The learning day's core list is
* the only labeled section. Reviews + drills ≤ 6, reviews first, overflow
* simply stays due (oldest tomorrow). Sunday is a two-line rest note.
*
* Idempotency: email_log keys sends by ET date — a same-day re-send is a
* no-op unless forced; the body itself is deterministic (drill picks are
* seeded by the date).
*/
import {
CAMPAIGN_DAYS,
type ProblemRow,
SCHEDULE,
addDays,
campaignDay,
campaignWeek,
dueReviews,
isoWeek,
pickDrills,
prettyDate,
streak,
weekdayOf,
} from "./srs.ts";
import { type DigestData, type DigestRow, renderDigest } from "./email.tsx";
import { signLink } from "./links.ts";
const DAILY_CAP = 6;
export interface Digest {
subject: string;
html: string;
text: string;
}
/** A problem row plus its signed one-tap pass/fail URLs. */
async function tapRow(env: Env, p: ProblemRow, date: string, staged: boolean): Promise<DigestRow> {
const [pass, fail] = await Promise.all([
signLink(env.LINK_KEY, p.lc_number, "pass", date),
signLink(env.LINK_KEY, p.lc_number, "fail", date),
]);
const base = `${env.PUBLIC_URL}/log?p=${p.lc_number}&d=${date}&r=`;
return {
lc: p.lc_number,
difficulty: p.difficulty,
...(staged ? { stage: p.stage } : {}),
passUrl: `${base}pass&sig=${pass}`,
failUrl: `${base}fail&sig=${fail}`,
};
}
/** Everything the email needs, read straight out of D1 and the schedule. */
export async function collectDigest(env: Env, date: string): Promise<DigestData> {
const db = env.DB;
const rest = weekdayOf(date) === 0;
// Reviews take the cap first; drills fill whatever is left.
const due = rest ? [] : await dueReviews(db, date);
const capped = due.slice(0, DAILY_CAP);
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - capped.length);
const data: DigestData = {
day: prettyDate(date),
progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`,
streak: await streak(db, date),
rest,
reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))),
carried: due.length - capped.length,
drills: await Promise.all(drills.map((p) => tapRow(env, p, date, false))),
yesterday: { total: 0, failed: [] },
heatmapUrl: `${env.PUBLIC_URL}/chart/heatmap.png`,
progressUrl: `${env.DOCS_URL}/progress`,
};
// New topic — the only section allowed to name problems and link them.
const topicIssue = rest ? undefined : SCHEDULE[date];
if (topicIssue !== undefined) {
const topic = await db
.prepare("SELECT name FROM topics WHERE issue = ?")
.bind(topicIssue)
.first<{ name: string }>();
const { results: core } = await db
.prepare(
"SELECT * FROM problems WHERE topic_issue = ? AND set_label = 'core' ORDER BY lc_number",
)
.bind(topicIssue)
.all<ProblemRow>();
data.topic = {
name: topic?.name ?? `#${topicIssue}`,
core: core.map((p) => ({
lc: p.lc_number,
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
solved: p.stage !== "new",
})),
};
}
// Saturday: the review issue already exists (created at midnight ET).
if (weekdayOf(date) === 6) {
const gate = await db
.prepare("SELECT issue FROM gates WHERE week = ? AND issue IS NOT NULL")
.bind(isoWeek(date))
.first<{ issue: number }>();
if (gate) {
data.gate = {
week: campaignWeek(date),
url: `https://github.com/${env.REPO}/issues/${gate.issue}`,
};
}
}
// Footer: yesterday's log summarised (failures named — they are the
// actionable part) and the gate rate to date.
const { results: logged } = await db
.prepare("SELECT lc_number, result FROM attempts WHERE date = ? ORDER BY id")
.bind(addDays(date, -1))
.all<{ lc_number: number; result: string }>();
data.yesterday = {
total: logged.length,
failed: logged.filter((a) => a.result !== "pass").map((a) => a.lc_number),
};
const lastGate = await db
.prepare("SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1")
.first<{ pass_rate: number }>();
if (lastGate) data.gateRate = lastGate.pass_rate;
return data;
}
export async function buildDigest(env: Env, date: string): Promise<Digest> {
const data = await collectDigest(env, date);
const { html, text } = await renderDigest(data);
return {
subject: `(Day ${campaignDay(date)}/${CAMPAIGN_DAYS}) LeetCode Daily Digest`,
html,
text,
};
}
export interface SendReport {
sent: boolean;
reason: string;
digest: Digest;
}
/** Send today's digest exactly once per ET date (unless forced). */
export async function sendDigest(
env: Env,
date: string,
opts: { force?: boolean; dry?: boolean } = {},
): Promise<SendReport> {
const digest = await buildDigest(env, date);
if (opts.dry) return { sent: false, reason: "dry run", digest };
const already = await env.DB.prepare("SELECT sent_at FROM email_log WHERE date = ?")
.bind(date)
.first();
if (already && !opts.force) return { sent: false, reason: "already sent today", digest };
await env.EMAIL.send({
to: env.TO_EMAIL,
from: { email: env.FROM_EMAIL, name: "SRS" },
subject: digest.subject,
html: digest.html,
text: digest.text,
});
await env.DB.prepare(
"INSERT INTO email_log (date, sent_at) VALUES (?, ?) ON CONFLICT(date) DO UPDATE SET sent_at = excluded.sent_at",
)
.bind(date, new Date().toISOString())
.run();
return { sent: true, reason: already ? "forced re-send" : "sent", digest };
}
-451
View File
@@ -1,451 +0,0 @@
/**
* Presentation layer for the daily digest — React Email components rendered
* to HTML (and to the plain-text alternative) inside the Worker.
*
* This file owns *only* layout and wording. Every number, URL and signature
* is computed in digest.ts and handed over as `DigestData`, so the retrieval
* rules (review/drill rows carry number + difficulty only — never the topic,
* never a solution link) are enforced by what the data model can express:
* `DigestRow` has no title and no issue field.
*
* Dark by design. The theme is hard-coded rather than left to the client's
* dark-mode heuristics: `color-scheme: dark` tells Apple Mail and Outlook not
* to re-invert it, and the canvas colour is painted by a full-width <Section>
* table because Gmail drops styles on <body>.
*
* Other email constraints that shape the markup: inline styles only (clients
* strip <style>), real <table> layout for the problem lists so Outlook and
* Gmail agree on column alignment, React Email's <Button> for the one-tap
* links (it emits the MSO padding conditionals a bare <a> lacks), and the
* heatmap arrives as PNG — every major client refuses remote SVG.
*/
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Text,
} from "@react-email/components";
import { render } from "@react-email/render";
import type { CSSProperties } from "react";
// ── palette (GitHub dark) ────────────────────────────────────────
const CANVAS = "#010409";
const CARD = "#0d1117";
const BORDER = "#30363d";
const RULE = "#21262d";
const INK = "#e6edf3";
const MUTED = "#8b949e";
const LINK = "#58a6ff";
const GREEN = "#3fb950";
const AMBER = "#d29922";
const RED = "#f85149";
const PASS_BG = "#238636";
const FAIL_BG = "#da3633";
const GATE_BG = "#1f6feb";
const FONT = '-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif';
// Outlook's Word engine is unreliable with text-transform, so display labels
// are table lookups rather than a CSS trick.
const DIFFICULTY: Record<string, { label: string; color: string }> = {
easy: { label: "Easy", color: GREEN },
medium: { label: "Medium", color: AMBER },
hard: { label: "Hard", color: RED },
};
// The SRS ladder in plain English — "+5" means the last look was 5 days back.
const LAST_SEEN: Record<string, string> = {
new: "first look",
"+2": "2 days ago",
"+5": "5 days ago",
"+10": "10 days ago",
retired: "retired",
};
// ── data model ───────────────────────────────────────────────────
/** A review or drill line: number + difficulty + one-tap links, nothing else. */
export interface DigestRow {
lc: number;
difficulty: string;
/** Reviews only — drills are unstaged by design. */
stage?: string;
passUrl: string;
failUrl: string;
}
export interface DigestData {
/** "Tuesday, August 25" */
day: string;
/** "Day 9 of 56 · Week 2" */
progress: string;
streak: number;
rest: boolean;
topic?: { name: string; core: { lc: number; url: string; solved: boolean }[] };
reviews: DigestRow[];
/** Reviews past the daily cap; they simply stay due. */
carried: number;
drills: DigestRow[];
gate?: { week: number; url: string };
yesterday: { total: number; failed: number[] };
gateRate?: number;
/** PNG, not SVG — email clients refuse the latter. */
heatmapUrl: string;
progressUrl: string;
}
// ── styles ───────────────────────────────────────────────────────
const page: CSSProperties = { backgroundColor: CANVAS, padding: "28px 0" };
const card: CSSProperties = {
backgroundColor: CARD,
border: `1px solid ${BORDER}`,
borderRadius: "10px",
margin: "0 auto",
maxWidth: "600px",
padding: "28px 24px",
};
const h1: CSSProperties = { color: INK, fontSize: "20px", fontWeight: 600, lineHeight: "26px", margin: 0 };
const label: CSSProperties = {
color: MUTED,
fontSize: "11px",
fontWeight: 700,
letterSpacing: "0.8px",
margin: "0 0 10px",
textTransform: "uppercase",
};
const hint: CSSProperties = { color: MUTED, fontSize: "13px", lineHeight: "19px", margin: "0 0 12px" };
const th: CSSProperties = {
borderBottom: `1px solid ${BORDER}`,
color: MUTED,
fontSize: "12px",
fontWeight: 400,
padding: "0 0 8px",
textAlign: "left",
};
const td: CSSProperties = {
borderBottom: `1px solid ${RULE}`,
color: INK,
fontSize: "14px",
padding: "12px 0",
textAlign: "left",
};
const tap: CSSProperties = {
borderRadius: "6px",
color: "#ffffff",
display: "inline-block",
fontSize: "12px",
fontWeight: 600,
lineHeight: "12px",
padding: "9px 15px",
textDecoration: "none",
};
/**
* A problem list as a real table. The "last seen" column appears only for
* reviews, which keeps drills at three columns on a phone.
*/
function ProblemTable({ rows, staged }: { rows: DigestRow[]; staged: boolean }) {
return (
<table
width="100%"
border={0}
cellPadding={0}
cellSpacing={0}
style={{ borderCollapse: "collapse", width: "100%" }}
>
<thead>
<tr>
<th style={th}>Problem</th>
<th style={th}>Level</th>
{staged ? <th style={th}>Last seen</th> : null}
<th style={{ ...th, textAlign: "right" }}>How did it go?</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const difficulty = DIFFICULTY[row.difficulty];
return (
<tr key={row.lc}>
<td style={{ ...td, fontWeight: 600 }}>LC {row.lc}</td>
<td style={{ ...td, color: difficulty?.color ?? INK }}>{difficulty?.label ?? row.difficulty}</td>
{staged ? (
<td style={{ ...td, color: MUTED }}>{(row.stage && LAST_SEEN[row.stage]) ?? row.stage}</td>
) : null}
<td style={{ ...td, textAlign: "right", whiteSpace: "nowrap" }}>
<Button href={row.passUrl} style={{ ...tap, backgroundColor: PASS_BG }}>
got it
</Button>
<Button href={row.failUrl} style={{ ...tap, backgroundColor: FAIL_BG, marginLeft: "6px" }}>
missed it
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
function Block({
title,
note,
rows,
staged,
empty,
}: {
title: string;
note: string;
rows: DigestRow[];
staged: boolean;
empty: string;
}) {
return (
<Section style={{ marginTop: "28px" }}>
<Text style={label}>{title}</Text>
{rows.length === 0 ? (
<Text style={{ ...hint, margin: 0 }}>{empty}</Text>
) : (
<>
<Text style={hint}>{note}</Text>
<ProblemTable rows={rows} staged={staged} />
</>
)}
</Section>
);
}
// ── email ────────────────────────────────────────────────────────
function DigestEmail({ data }: { data: DigestData }) {
const load = data.reviews.length + data.drills.length;
const preview = data.rest
? "Rest day — nothing to do but rest."
: `${load} to work through today${data.topic ? `, starting with ${data.topic.name}` : ""}.`;
const passed = data.yesterday.total - data.yesterday.failed.length;
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
return (
<Html lang="en" dir="ltr">
<Head>
<meta name="color-scheme" content="dark" />
<meta name="supported-color-schemes" content="dark" />
</Head>
<Preview>{preview}</Preview>
<Body style={{ backgroundColor: CANVAS, fontFamily: FONT, margin: 0, padding: 0 }}>
<Section style={page}>
<Container style={card}>
<Heading as="h1" style={h1}>
{data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`}
</Heading>
<Text style={{ color: MUTED, fontSize: "13px", margin: "8px 0 0" }}>
{data.progress}
{data.rest
? " · nothing is due today, and anything overdue waits for Monday."
: data.streak === 0
? " · no streak going yet — today is a good day to start one."
: ` · 🔥 ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`}
</Text>
{data.rest ? null : (
<>
{data.topic ? (
<Section style={{ marginTop: "28px" }}>
<Text style={label}>Something new today</Text>
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
{data.topic.name}
</Text>
<Text style={{ ...hint, margin: 0 }}>
Work through these ticked ones you have already solved:{" "}
{data.topic.core.map((p, i) => (
<span key={p.lc}>
{i > 0 ? " · " : ""}
<Link href={p.url} style={{ color: p.solved ? MUTED : LINK, textDecoration: "none" }}>
LC {p.lc}
</Link>
{p.solved ? <span style={{ color: GREEN }}> </span> : null}
</span>
))}
</Text>
</Section>
) : null}
<Block
title="Time to see these again"
note="Solve each one from scratch, and resist opening your old answer first."
rows={data.reviews}
staged
empty="Nothing is due for review today."
/>
{data.carried > 0 ? (
<Text style={{ ...hint, margin: "12px 0 0" }}>
{data.carried} more {data.carried === 1 ? "is" : "are"} waiting they come back
tomorrow, oldest first.
</Text>
) : null}
<Block
title="Cold start, no hints"
note="You are not told the topic. Say the pattern out loud before you write a line."
rows={data.drills}
staged={false}
empty="No cold starts today."
/>
{data.gate ? (
<Section style={{ marginTop: "28px" }}>
<Text style={label}>Weekly checkpoint</Text>
<Text style={hint}>Timed and blind. Close the issue when you are done.</Text>
<Button
href={data.gate.url}
style={{ ...tap, backgroundColor: GATE_BG, fontSize: "13px", padding: "11px 18px" }}
>
Open week {data.gate.week} review
</Button>
</Section>
) : null}
</>
)}
{/* Eight weeks at a glance — the one thing here that is pure
encouragement rather than instruction. */}
<Section style={{ marginTop: "28px" }}>
<Text style={label}>Every day you showed up</Text>
<Link href={data.progressUrl}>
<Img
src={data.heatmapUrl}
alt="Attempt heatmap across the eight-week campaign"
width="552"
style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }}
/>
</Link>
</Section>
<Hr style={{ borderColor: BORDER, margin: "28px 0 18px" }} />
<Text style={{ color: MUTED, fontSize: "12px", lineHeight: "19px", margin: 0 }}>
{data.yesterday.total === 0
? "You did not log anything yesterday."
: `Yesterday you logged ${data.yesterday.total} and got ${passed} of them` +
(missed ? `; ${missed} got away.` : ".")}{" "}
{data.gateRate === undefined
? "No checkpoints scored yet."
: `Checkpoints are running at ${Math.round(data.gateRate * 100)}%.`}{" "}
<Link href={data.progressUrl} style={{ color: LINK, textDecoration: "none" }}>
See the full picture
</Link>
</Text>
</Container>
</Section>
</Body>
</Html>
);
}
/**
* The plain-text alternative. React Email's `plainText` mode flattens the
* problem tables into one unreadable run, so text gets its own writer — fed
* by the same `DigestData`, so the numbers and links can never disagree with
* the HTML even though the wording lives in two places.
*/
function plainDigest(data: DigestData): string {
const out: string[] = [
data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`,
data.progress +
(data.rest
? " · nothing is due today, and anything overdue waits for Monday."
: data.streak === 0
? " · no streak going yet — today is a good day to start one."
: ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`),
];
if (!data.rest) {
if (data.topic) {
out.push(
"",
`SOMETHING NEW TODAY — ${data.topic.name}`,
"Work through these; the ones marked done you have already solved.",
...data.topic.core.map((p) => ` LC ${p.lc}${p.solved ? " (done)" : ""}${p.url}`),
);
}
const lists: [string, string, DigestRow[], string][] = [
[
"TIME TO SEE THESE AGAIN",
"Solve each one from scratch, and resist opening your old answer first.",
data.reviews,
"Nothing is due for review today.",
],
[
"COLD START, NO HINTS",
"You are not told the topic. Say the pattern out loud before you write a line.",
data.drills,
"No cold starts today.",
],
];
for (const [title, note, rows, empty] of lists) {
out.push("", title);
if (rows.length === 0) {
out.push(empty);
continue;
}
out.push(note);
for (const row of rows) {
const seen = row.stage ? `, last seen ${LAST_SEEN[row.stage] ?? row.stage}` : "";
out.push(
` LC ${row.lc}${DIFFICULTY[row.difficulty]?.label ?? row.difficulty}${seen}`,
` got it: ${row.passUrl}`,
` missed it: ${row.failUrl}`,
);
}
}
if (data.carried > 0) {
out.push(
"",
`${data.carried} more ${data.carried === 1 ? "is" : "are"} waiting — they come back tomorrow, oldest first.`,
);
}
if (data.gate) {
out.push(
"",
"WEEKLY CHECKPOINT",
"Timed and blind. Close the issue when you are done.",
` Week ${data.gate.week} review — ${data.gate.url}`,
);
}
}
const passed = data.yesterday.total - data.yesterday.failed.length;
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
out.push(
"",
"─".repeat(48),
data.yesterday.total === 0
? "You did not log anything yesterday."
: `Yesterday you logged ${data.yesterday.total} and got ${passed} of them` +
(missed ? `; ${missed} got away.` : "."),
data.gateRate === undefined
? "No checkpoints scored yet."
: `Checkpoints are running at ${Math.round(data.gateRate * 100)}%.`,
`Every day you showed up: ${data.heatmapUrl}`,
`See the full picture: ${data.progressUrl}`,
);
return out.join("\n");
}
/**
* The HTML body plus its plain-text alternative — always sent as a pair so a
* client that refuses HTML still gets the same problems and the same links.
*/
export async function renderDigest(data: DigestData): Promise<{ html: string; text: string }> {
return { html: await render(<DigestEmail data={data} />), text: plainDigest(data) };
}
-270
View File
@@ -1,270 +0,0 @@
/**
* Saturday review issue: created at midnight ET so the 8 AM digest can link
* to it; scored when the issue closes (webhook).
*
* The gate half is a topic-blind quiz — one unsolved optional problem per
* topic scheduled this week (which is why daily drills only draw from
* earlier weeks). The recap half is generated data, deliberately NOT a task
* list: the week's solved problems are already scheduled by the +2 ladder,
* and re-assigning them on Saturday would be massed practice.
*
* Label is `review` (the retired Actions system owned `gate`). Creation is
* idempotent by week; deferred Hards never appear.
*/
import {
type ProblemRow,
type Result,
SCHEDULE,
campaignWeek,
isoWeek,
rng,
sample,
streak,
weekMonday,
} from "./srs.ts";
import type { GitHub } from "./github.ts";
const PASS_TARGET = 0.7;
const REVIEW_LABEL = "review";
const MAX_GATE_PROBLEMS = 5;
function capitalized(difficulty: string): string {
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
}
/** Unsolved problems of a topic in a set — never attempted, never drilled. */
async function freshPool(db: D1Database, topic: number, set: string): Promise<ProblemRow[]> {
const { results } = await db
.prepare(
`SELECT * FROM problems
WHERE topic_issue = ?1 AND set_label = ?2 AND stage = 'new' AND defer_until IS NULL
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = problems.lc_number)
ORDER BY lc_number`,
)
.bind(topic, set)
.all<ProblemRow>();
return results;
}
export interface CreateReport {
created: boolean;
issue?: number;
reason: string;
}
export async function createReviewIssue(env: Env, gh: GitHub, date: string): Promise<CreateReport> {
const db = env.DB;
const week = campaignWeek(date);
const iso = isoWeek(date);
const title = `Review — Week ${week}`;
const existing = await db
.prepare("SELECT issue FROM gates WHERE week = ? AND issue IS NOT NULL")
.bind(iso)
.first<{ issue: number }>();
if (existing) {
return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` };
}
// Topics scheduled this week; review-only weeks (Sep 23 onward) sample
// across every topic that still has unsolved optional problems.
let topics = Object.entries(SCHEDULE)
.filter(([d]) => campaignWeek(d) === week)
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([, t]) => t);
if (topics.length === 0) {
const { results } = await db
.prepare(
`SELECT DISTINCT topic_issue AS t FROM problems
WHERE set_label = 'optional' AND stage = 'new'
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
ORDER BY topic_issue`,
)
.all<{ t: number }>();
topics = sample(results.map((r) => r.t), MAX_GATE_PROBLEMS, rng(`gate-topics-${iso}`)).sort(
(a, b) => a - b,
);
}
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
const random = rng(`gate-${iso}`);
const picks: { p: ProblemRow; fallback: boolean }[] = [];
for (const topic of topics) {
const optional = await freshPool(db, topic, "optional");
if (optional.length > 0) {
picks.push({ p: sample(optional, 1, random)[0]!, fallback: false });
continue;
}
const core = await freshPool(db, topic, "core");
if (core.length > 0) picks.push({ p: sample(core, 1, random)[0]!, fallback: true });
// Both pools exhausted: the topic is fully solved; nothing to quiz.
}
if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` };
// Recap: everything attempted this week, plus streak — data, not tasks.
const monday = weekMonday(date);
const { results: attempts } = await db
.prepare(
`SELECT a.lc_number, a.date, a.kind, a.result FROM attempts a
WHERE a.date >= ? AND a.date < ? AND a.source != 'import'
ORDER BY a.date, a.id`,
)
.bind(monday, date)
.all<{ lc_number: number; date: string; kind: string; result: string }>();
const reviewsDone = attempts.filter((a) => a.kind === "review" && a.result === "pass").length;
const currentStreak = await streak(db, date);
const gateLines = picks.map(
({ p, fallback }) =>
`- LC ${p.lc_number}${capitalized(p.difficulty)}${fallback ? " *(core fallback — optional pool exhausted)*" : ""}`,
);
const recapLines = attempts.length
? attempts.map(
(a) => `| LC ${a.lc_number} | ${a.kind} | ${a.result === "pass" ? "✅" : "❌"} | ${a.date} |`,
)
: ["| — | no attempts logged this week | | |"];
const body = [
"## Gate — blind set",
`**Target: ${Math.round(PASS_TARGET * 100)}% first-attempt pass rate. Timed. Narrate out loud.**`,
"No topics given. Log with `/done <n> pass|fail`, then close this issue.",
...gateLines,
"",
`## Week ${week} recap`,
`${attempts.length} attempts · ${reviewsDone} reviews passed · streak ${currentStreak}`,
"",
"| Problem | Kind | Result | Day |",
"| --- | --- | --- | --- |",
...recapLines,
].join("\n");
// Ensure the `review` label exists (422 = already there).
try {
await gh.rest(`/repos/${gh.repo}/labels`, {
method: "POST",
body: JSON.stringify({
name: REVIEW_LABEL,
color: "5319e7",
description: "Weekly blind gate + recap",
}),
});
} catch (err) {
if (!String(err).includes("422")) throw err;
}
const milestone = await db
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
.bind(topics[0])
.first<{ milestone: number }>();
const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
method: "POST",
body: JSON.stringify({
title,
body,
labels: [REVIEW_LABEL],
milestone: milestone?.milestone,
}),
})) as { number: number };
await db
.prepare(
`INSERT INTO gates (week, issue, problems) VALUES (?1, ?2, ?3)
ON CONFLICT(week) DO UPDATE SET issue = ?2, problems = ?3`,
)
.bind(iso, created.number, JSON.stringify(picks.map(({ p }) => p.lc_number)))
.run();
// Last week's boosted topics had their remedial week — clear the flags.
await db.prepare("UPDATE topics SET boost = 0 WHERE boost = 1").run();
return { created: true, issue: created.number, reason: `created #${created.number}` };
}
export interface ScoreReport {
scored: boolean;
rate?: number;
reason: string;
}
/**
* Grade a closed review issue: first-attempt pass rate over its gate set.
* Unlogged problems count as failures — skipping a gate problem is not a
* pass. Boosts topics that failed a gate problem or reached 2 drill misses;
* miss counters reset once consumed. The badge/charts read D1 live, so
* "refreshing the badge" is this row update.
*/
export async function scoreReview(
env: Env,
gh: GitHub,
issueNumber: number,
date: string,
): Promise<ScoreReport> {
const db = env.DB;
const gate = await db
.prepare("SELECT week, problems, pass_rate FROM gates WHERE issue = ?")
.bind(issueNumber)
.first<{ week: number; problems: string; pass_rate: number | null }>();
if (!gate) return { scored: false, reason: `issue #${issueNumber} has no gate record` };
if (gate.pass_rate !== null) return { scored: false, rate: gate.pass_rate, reason: "already scored" };
const lcs: number[] = JSON.parse(gate.problems);
const results: Record<number, Result | undefined> = {};
for (const lc of lcs) {
const row = await db
.prepare("SELECT result FROM attempts WHERE lc_number = ? AND kind = 'gate' ORDER BY id LIMIT 1")
.bind(lc)
.first<{ result: Result }>();
results[lc] = row?.result;
}
const passes = lcs.filter((lc) => results[lc] === "pass");
const unlogged = lcs.filter((lc) => results[lc] === undefined);
const rate = passes.length / lcs.length;
await db
.prepare("UPDATE gates SET pass_rate = ?, closed_on = ? WHERE issue = ?")
.bind(rate, date, issueNumber)
.run();
const boosted: number[] = [];
for (const lc of lcs) {
if (results[lc] !== "fail") continue;
const p = await db
.prepare("SELECT topic_issue FROM problems WHERE lc_number = ?")
.bind(lc)
.first<{ topic_issue: number }>();
if (p) boosted.push(p.topic_issue);
}
const { results: missed } = await db
.prepare("SELECT issue FROM topics WHERE misses >= 2")
.all<{ issue: number }>();
boosted.push(...missed.map((m) => m.issue));
const boostSet = [...new Set(boosted)];
if (boostSet.length) {
await db.batch(
boostSet.map((t) => db.prepare("UPDATE topics SET boost = 1, misses = 0 WHERE issue = ?").bind(t)),
);
}
const passed = rate >= PASS_TARGET;
const summary = [
`## Gate — Week ${gate.week}: **${Math.round(rate * 100)}%** first-attempt (target ${Math.round(PASS_TARGET * 100)}%) — ${passed ? "✅ pass" : "❌ fail"}`,
"",
...lcs.map((lc) => {
const r = results[lc];
return `- LC ${lc}${r === "pass" ? "✅ pass" : r === "fail" ? "❌ fail" : "⬜ not logged (counted as fail)"}`;
}),
"",
unlogged.length ? `${unlogged.length} problem(s) were never logged.` : "",
boostSet.length
? `Boosted topics for next week's drills: ${boostSet.map((t) => `#${t}`).join(", ")}.`
: "No topics boosted.",
"",
`Live charts: ${env.PUBLIC_URL}/chart/progress.svg · progress: ${env.DOCS_URL}/progress`,
]
.filter((l) => l !== "")
.join("\n");
await gh.comment(issueNumber, summary);
return { scored: true, rate, reason: `scored ${Math.round(rate * 100)}%` };
}
-94
View File
@@ -1,94 +0,0 @@
/**
* GitHub REST + GraphQL client on GH_PAT. Hand-rolled fetch, no SDK —
* matching the repo's dependency-free automation rule.
*/
export interface GitHub {
repo: string;
rest(path: string, init?: RequestInit): Promise<unknown>;
graphql(query: string, variables?: Record<string, unknown>): Promise<unknown>;
list(path: string): AsyncGenerator<unknown, void, void>;
/** Comment first, then close — a failed close still leaves a visible note. */
closeIssue(issue: number, comment: string): Promise<void>;
comment(issue: number, body: string): Promise<void>;
react(commentId: number, content: string): Promise<void>;
}
const PER_PAGE = 100;
export function github(token: string, repo: string): GitHub {
async function rest(path: string, init: RequestInit = {}): Promise<unknown> {
const res = await fetch(`https://api.github.com${path}`, {
...init,
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
"user-agent": "srs-api",
"x-github-api-version": "2022-11-28",
...(init.body ? { "content-type": "application/json" } : {}),
},
});
if (!res.ok) {
throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`);
}
return res.status === 204 ? null : res.json();
}
async function graphql(
query: string,
variables: Record<string, unknown> = {},
): Promise<unknown> {
const res = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
"user-agent": "srs-api",
},
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
if (payload.errors?.length) throw new Error(payload.errors.map((e) => e.message).join("; "));
return payload.data;
}
async function* list(path: string): AsyncGenerator<unknown, void, void> {
const sep = path.includes("?") ? "&" : "?";
for (let page = 1; ; page++) {
const batch = await rest(`${path}${sep}per_page=${PER_PAGE}&page=${page}`);
if (!Array.isArray(batch)) throw new Error(`unexpected ${path} payload: not an array`);
yield* batch;
if (batch.length < PER_PAGE) return;
}
}
return {
repo,
rest,
graphql,
list,
async closeIssue(issue, comment) {
await rest(`/repos/${repo}/issues/${issue}/comments`, {
method: "POST",
body: JSON.stringify({ body: comment }),
});
await rest(`/repos/${repo}/issues/${issue}`, {
method: "PATCH",
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
});
},
async comment(issue, body) {
await rest(`/repos/${repo}/issues/${issue}/comments`, {
method: "POST",
body: JSON.stringify({ body }),
});
},
async react(commentId, content) {
await rest(`/repos/${repo}/issues/comments/${commentId}/reactions`, {
method: "POST",
body: JSON.stringify({ content }),
});
},
};
}
-324
View File
@@ -1,324 +0,0 @@
/**
* 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, heatmapPng, ladderChart, progressChart } from "./charts.ts";
import { sendDigest } from "./digest.ts";
import { createReviewIssue, scoreReview } from "./gate.ts";
import { type GitHub, github } from "./github.ts";
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
import { type Mirror, projectMirror } from "./mirror.ts";
import {
CAMPAIGN_START,
type LogOutcome,
daysBetween,
etDate,
etHour,
logAttempt,
weekdayOf,
} from "./srs.ts";
import { buildStats } from "./stats.ts";
// ── shared side effects after a state write ──────────────────────
/**
* Mirror one logged attempt into GitHub: Project fields always; on a
* first-ever pass also close the problem's sub-issue (comment first — repo
* convention). Runs inside ctx.waitUntil; failures are warnings.
*/
async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Promise<void> {
if (outcome.error || outcome.duplicate) return;
const gh = github(env.GH_PAT, env.REPO);
const mirror: Mirror = projectMirror(gh, env.REPO.split("/")[0]!);
await mirror.setStage(outcome.issue, outcome.stage);
await mirror.setTargetDate(outcome.issue, outcome.next_review);
if (outcome.first) await mirror.setFirstAttempt(outcome.issue, outcome.result);
if (outcome.first && outcome.result === "pass") {
try {
await gh.closeIssue(
outcome.issue,
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +2. Logged by the SRS Worker.`,
);
} catch (err) {
console.warn(`close #${outcome.issue}: ${err}`);
}
}
}
function outcomeLine(o: LogOutcome): string {
if (o.error) return o.error;
if (o.duplicate) return `LC ${o.lc}: already logged today — nothing changed`;
return (
`LC ${o.lc}: ${o.kind} ${o.result} → stage ${o.stage}` +
(o.next_review ? `, review ${o.next_review}` : " — retired 🎉")
);
}
// ── one-tap email links ──────────────────────────────────────────
function page(title: string, body: string, status = 200): Response {
return new Response(
`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">
<body style="font-family:-apple-system,Segoe UI,sans-serif;max-width:420px;margin:15vh auto;padding:0 16px;text-align:center">
<h2>${title}</h2><p style="color:#57606a">${body}</p></body>`,
{ status, headers: { "content-type": "text/html; charset=utf-8" } },
);
}
async function handleTap(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const lc = Number(url.searchParams.get("p"));
const result = url.searchParams.get("r");
const date = url.searchParams.get("d") ?? "";
const sig = url.searchParams.get("sig") ?? "";
if (!Number.isFinite(lc) || (result !== "pass" && result !== "fail") || !date) {
return page("Bad link", "Missing or malformed parameters.", 400);
}
if (!(await verifyLink(env.LINK_KEY, lc, result, date, sig))) {
return page("Bad signature", "This link was not signed by the SRS.", 403);
}
const today = etDate(new Date());
if (daysBetween(date, today) > 3 || daysBetween(today, date) > 1) {
return page("Link expired", "Older than 3 days — log it with a /done comment instead.", 410);
}
const outcome = await logAttempt(env.DB, { lc, date, result, source: "email" });
if (outcome.error) return page("Not logged", outcome.error, 422);
if (outcome.duplicate) {
return page("Already logged ✓", `LC ${lc} was already recorded today. Nothing changed.`);
}
ctx.waitUntil(mirrorOutcome(env, outcome, "email"));
return page(
result === "pass" ? "Logged ✅" : "Logged — back to +2",
outcomeLine(outcome),
);
}
// ── GitHub webhook ───────────────────────────────────────────────
interface CommentEvent {
action: string;
issue: { number: number; title: string; labels: { name: string }[] };
comment: { id: number; body: string; user: { login: string } };
}
async function handleWebhook(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const raw = await request.arrayBuffer();
const ok = await verifyWebhook(env.WEBHOOK_SECRET, raw, request.headers.get("x-hub-signature-256"));
if (!ok) return new Response("bad signature", { status: 401 });
const event = request.headers.get("x-github-event");
const payload: unknown = JSON.parse(new TextDecoder().decode(raw));
const owner = env.REPO.split("/")[0]!;
const today = etDate(new Date());
if (event === "issues") {
if (!payload || typeof payload !== "object" || !("action" in payload) || !("issue" in payload)) {
return new Response("ignored");
}
const p = payload as { action: string; issue: { number: number; labels: { name: string }[] } };
if (p.action === "closed" && p.issue.labels.some((l) => l.name === "review")) {
const gh = github(env.GH_PAT, env.REPO);
const report = await scoreReview(env, gh, p.issue.number, today);
return Response.json(report);
}
return new Response("ignored");
}
if (event !== "issue_comment") return new Response("ignored");
const p = payload as CommentEvent;
if (p.action !== "created") return new Response("ignored");
if (p.comment.user.login !== owner) return new Response("ignored (owner only)");
const commands: { lc: number; result: "pass" | "fail" }[] = [];
const errors: string[] = [];
for (const rawLine of p.comment.body.split("\n")) {
const line = rawLine.trim();
if (!line.startsWith("/done")) continue;
const m = line.match(/^\/done\s+(\d+)\s+(pass|fail)\s*$/);
if (m) commands.push({ lc: Number(m[1]), result: m[2] as "pass" | "fail" });
else errors.push(`cannot parse \`${line}\` — expected \`/done <number> <pass|fail>\``);
}
if (commands.length === 0 && errors.length === 0) return new Response("no commands");
// Comments on a review issue log its gate problems as kind='gate'.
const gateRow = await env.DB.prepare("SELECT problems FROM gates WHERE issue = ?")
.bind(p.issue.number)
.first<{ problems: string }>();
const gateLcs = new Set<number>(gateRow ? JSON.parse(gateRow.problems) : []);
const outcomes: LogOutcome[] = [];
for (const cmd of commands) {
const outcome = await logAttempt(env.DB, {
lc: cmd.lc,
date: today,
result: cmd.result,
source: "webhook",
gate: gateLcs.has(cmd.lc),
});
if (outcome.error) errors.push(outcome.error);
else outcomes.push(outcome);
}
const gh = github(env.GH_PAT, env.REPO);
ctx.waitUntil(
(async () => {
for (const outcome of outcomes) await mirrorOutcome(env, outcome, "webhook");
try {
if (errors.length === 0) {
await gh.react(p.comment.id, "+1");
} else {
await gh.comment(
p.issue.number,
`Could not log everything:\n\n${errors.map((e) => `- ${e}`).join("\n")}` +
(outcomes.length
? `\n\nApplied anyway:\n\n${outcomes.map((o) => `- ${outcomeLine(o)}`).join("\n")}`
: ""),
);
}
} catch (err) {
console.warn(`webhook feedback: ${err}`);
}
})(),
);
return Response.json({ applied: outcomes.map(outcomeLine), errors });
}
// ── admin (bearer LINK_KEY, timing-safe) ─────────────────────────
async function adminAuthorized(request: Request, env: Env): Promise<boolean> {
const header = request.headers.get("authorization") ?? "";
if (!header.startsWith("Bearer ")) return false;
return timingSafeEqual(header.slice(7), env.LINK_KEY);
}
async function handleAdmin(request: Request, env: Env, path: string): Promise<Response> {
if (!(await adminAuthorized(request, env))) return new Response("unauthorized", { status: 401 });
const url = new URL(request.url);
const date = url.searchParams.get("date") ?? etDate(new Date());
const gh = () => github(env.GH_PAT, env.REPO);
if (path === "/admin/reconcile") {
return Response.json(await reconcileCatalog(env.DB, gh()));
}
if (path === "/admin/digest") {
const report = await sendDigest(env, date, {
dry: url.searchParams.get("dry") === "1",
force: url.searchParams.get("force") === "1",
});
if (url.searchParams.get("dry") === "1") {
return new Response(report.digest.html, { headers: { "content-type": "text/html" } });
}
return Response.json({ sent: report.sent, reason: report.reason, subject: report.digest.subject });
}
if (path === "/admin/review") {
return Response.json(await createReviewIssue(env, gh(), date));
}
return new Response("not found", { status: 404 });
}
// ── router ───────────────────────────────────────────────────────
const SVG_HEADERS = {
"content-type": "image/svg+xml",
// GitHub Camo honors this; 5 minutes is the freshness floor for README charts.
"cache-control": "public, max-age=300",
};
// The digest embeds the raster heatmap; email clients refuse SVG entirely.
const PNG_HEADERS = {
"content-type": "image/png",
"cache-control": "public, max-age=300",
};
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
try {
if (path === "/log" && request.method === "GET") return await handleTap(request, env, ctx);
if (path === "/webhook/github" && request.method === "POST") {
return await handleWebhook(request, env, ctx);
}
if (path.startsWith("/admin/") && request.method === "POST") {
return await handleAdmin(request, env, path);
}
if (path === "/chart/progress.svg") {
return new Response(await progressChart(env.DB), { headers: SVG_HEADERS });
}
if (path === "/chart/ladder.svg") {
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
}
if (path === "/chart/heatmap.svg") {
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, 56), { headers: SVG_HEADERS });
}
if (path === "/chart/heatmap.png") {
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, 56), { headers: PNG_HEADERS });
}
if (path === "/badge/gate.svg") {
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
}
if (path === "/api/stats") {
// The docs serve from both the Pages origin and the custom domain;
// reflect the requesting origin only when it is on the allowlist.
const allowed = env.DOCS_ORIGIN.split(",").map((o) => o.trim());
const origin = request.headers.get("origin");
const cors = {
"access-control-allow-origin":
origin && allowed.includes(origin) ? origin : allowed[0]!,
"access-control-allow-methods": "GET",
vary: "Origin",
};
if (request.method === "OPTIONS") return new Response(null, { headers: cors });
const stats = await buildStats(env.DB, etDate(new Date()));
return Response.json(stats, { headers: cors });
}
return new Response("srs-api", { status: path === "/" ? 200 : 404 });
} catch (err) {
console.error(`unhandled ${request.method} ${path}: ${err}`);
return new Response("internal error", { status: 500 });
}
},
/**
* DST-proof cron dispatch: crons fire at both possible UTC hours; the
* computed ET hour decides. 8 AM daily → catalog reconcile + digest;
* midnight Saturday → review issue (so the 8 AM digest can link to it).
*/
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
const now = new Date(controller.scheduledTime);
const hour = etHour(now);
const date = etDate(now);
if (hour === 8) {
try {
const report = await reconcileCatalog(env.DB, github(env.GH_PAT, env.REPO));
console.log(`catalog: ${report.topics} topics, ${report.problems} problems`);
} catch (err) {
console.warn(`catalog reconcile failed (digest still goes out): ${err}`);
}
const report = await sendDigest(env, date);
console.log(`digest ${date}: ${report.reason}`);
return;
}
if (hour === 0 && weekdayOf(date) === 6) {
const report = await createReviewIssue(env, github(env.GH_PAT, env.REPO), date);
console.log(`review issue ${date}: ${report.reason}`);
return;
}
console.log(`cron at ET hour ${hour} on ${date}: no-op (DST guard)`);
void ctx;
},
};
-79
View File
@@ -1,79 +0,0 @@
/**
* One-tap email links: `GET /log?p=704&r=pass&d=2026-08-31&sig=<hmac>`.
* The sig is HMAC-SHA256 over `p|r|d` with LINK_KEY (WebCrypto, hex).
* Verification is constant-time; links older than 3 days are rejected.
*/
async function hmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
}
export async function signLink(
key: string,
p: number,
r: string,
d: string,
): Promise<string> {
const mac = await crypto.subtle.sign(
"HMAC",
await hmacKey(key),
new TextEncoder().encode(`${p}|${r}|${d}`),
);
return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
export async function verifyLink(
key: string,
p: number,
r: string,
d: string,
sig: string,
): Promise<boolean> {
if (!/^[0-9a-f]{64}$/.test(sig)) return false;
const bytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) bytes[i] = Number.parseInt(sig.slice(i * 2, i * 2 + 2), 16);
// crypto.subtle.verify is constant-time; never compare hex strings directly.
return crypto.subtle.verify(
"HMAC",
await hmacKey(key),
bytes,
new TextEncoder().encode(`${p}|${r}|${d}`),
);
}
/** Constant-time equality for webhook signatures and admin bearer keys. */
export async function timingSafeEqual(a: string, b: string): Promise<boolean> {
// HMAC both sides with a random key: unequal-length inputs and content
// differences are equally invisible to timing.
const key = await crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, false, [
"sign",
]);
const enc = new TextEncoder();
const [ma, mb] = await Promise.all([
crypto.subtle.sign("HMAC", key, enc.encode(a)),
crypto.subtle.sign("HMAC", key, enc.encode(b)),
]);
const va = new Uint8Array(ma);
const vb = new Uint8Array(mb);
let diff = 0;
for (let i = 0; i < va.length; i++) diff |= va[i]! ^ vb[i]!;
return diff === 0;
}
/** GitHub webhook `X-Hub-Signature-256: sha256=<hex>` verification. */
export async function verifyWebhook(
secret: string,
body: ArrayBuffer,
header: string | null,
): Promise<boolean> {
if (!header?.startsWith("sha256=")) return false;
const mac = await crypto.subtle.sign("HMAC", await hmacKey(secret), body);
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
return timingSafeEqual(header.slice(7), expected);
}
-172
View File
@@ -1,172 +0,0 @@
/**
* Best-effort mirror into the "Interview Prep" user Project — ported from
* scripts/srs-project.ts. D1 is the truth; a GraphQL failure here becomes a
* console warning and never blocks a state write. Only problem issues are
* ever passed in, so topic rows' Target Date is never written.
*
* Field/option IDs are resolved by NAME per invocation (a Worker isolate is
* short-lived; the two extra queries per mirror burst are irrelevant at this
* volume and survive field re-creation).
*/
import type { GitHub } from "./github.ts";
const PROJECT_TITLE = "Interview Prep";
interface SelectField {
id: string;
options: Record<string, string>;
}
interface ProjectInfo {
id: string;
targetDate: string;
srsStage: SelectField;
firstAttempt: SelectField;
}
export interface Mirror {
setTargetDate(issue: number, date: string | null): Promise<void>;
setStage(issue: number, stage: string): Promise<void>;
setFirstAttempt(issue: number, result: string): Promise<void>;
warnings: string[];
}
export function projectMirror(gh: GitHub, owner: string): Mirror {
const warnings: string[] = [];
let info: ProjectInfo | undefined;
const itemIds = new Map<number, string | undefined>();
async function resolve(): Promise<ProjectInfo> {
if (info) return info;
const data = (await gh.graphql(
`query($owner: String!, $title: String!) {
user(login: $owner) {
projectsV2(first: 10, query: $title) {
nodes {
id title
fields(first: 30) {
nodes {
... on ProjectV2FieldCommon { id name dataType }
... on ProjectV2SingleSelectField { id name options { id name } }
}
}
}
}
}
}`,
{ owner, title: PROJECT_TITLE },
)) as {
user: {
projectsV2: {
nodes: {
id: string;
title: string;
fields: {
nodes: { id: string; name: string; options?: { id: string; name: string }[] }[];
};
}[];
};
};
};
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
const select = (name: string): SelectField => {
const f = node.fields.nodes.find((n) => n.name === name);
if (!f?.options) throw new Error(`single-select field "${name}" missing`);
return { id: f.id, options: Object.fromEntries(f.options.map((o) => [o.name, o.id])) };
};
const date = node.fields.nodes.find((n) => n.name === "Target Date");
if (!date) throw new Error(`date field "Target Date" missing`);
info = {
id: node.id,
targetDate: date.id,
srsStage: select("SRS Stage"),
firstAttempt: select("First Attempt"),
};
return info;
}
async function itemId(issue: number): Promise<string> {
if (itemIds.has(issue)) {
const cached = itemIds.get(issue);
if (!cached) throw new Error(`issue #${issue} is not in the project`);
return cached;
}
const project = await resolve();
const [repoOwner, repoName] = gh.repo.split("/");
const data = (await gh.graphql(
`query($owner: String!, $name: String!, $issue: Int!) {
repository(owner: $owner, name: $name) {
issue(number: $issue) {
projectItems(first: 10, includeArchived: true) {
nodes { id project { id } }
}
}
}
}`,
{ owner: repoOwner, name: repoName, issue },
)) as {
repository: { issue: { projectItems: { nodes: { id: string; project: { id: string } }[] } } };
};
const item = data.repository.issue.projectItems.nodes.find((n) => n.project.id === project.id);
itemIds.set(issue, item?.id);
if (!item) throw new Error(`issue #${issue} is not in the project`);
return item.id;
}
async function setField(issue: number, fieldId: string, value: object): Promise<void> {
const project = await resolve();
await gh.graphql(
`mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
updateProjectV2ItemFieldValue(
input: { projectId: $project, itemId: $item, fieldId: $field, value: $value }
) { projectV2Item { id } }
}`,
{ project: project.id, item: await itemId(issue), field: fieldId, value },
);
}
async function attempt(what: string, op: () => Promise<void>): Promise<void> {
try {
await op();
} catch (err) {
const message = `mirror ${what}: ${err instanceof Error ? err.message : String(err)}`;
warnings.push(message);
console.warn(message); // observability picks this up; never rethrow
}
}
return {
warnings,
setTargetDate: (issue, date) =>
attempt(`Target Date #${issue}`, async () => {
const project = await resolve();
if (date === null) {
await gh.graphql(
`mutation($project: ID!, $item: ID!, $field: ID!) {
clearProjectV2ItemFieldValue(
input: { projectId: $project, itemId: $item, fieldId: $field }
) { projectV2Item { id } }
}`,
{ project: project.id, item: await itemId(issue), field: project.targetDate },
);
} else {
await setField(issue, project.targetDate, { date });
}
}),
setStage: (issue, stage) =>
attempt(`SRS Stage #${issue}`, async () => {
const project = await resolve();
const option = project.srsStage.options[stage];
if (!option) throw new Error(`no option "${stage}"`);
await setField(issue, project.srsStage.id, { singleSelectOptionId: option });
}),
setFirstAttempt: (issue, result) =>
attempt(`First Attempt #${issue}`, async () => {
const project = await resolve();
const option = project.firstAttempt.options[result];
if (!option) throw new Error(`no option "${result}"`);
await setField(issue, project.firstAttempt.id, { singleSelectOptionId: option });
}),
};
}
-254
View File
@@ -1,254 +0,0 @@
/**
* Hand-rolled PNG encoder for the SRS Worker.
*
* Every major email client — Gmail, Outlook, Apple Mail — refuses to render
* SVG, inline or remote, so the daily digest cannot reuse /chart/heatmap.svg;
* it needs raster bytes. The Worker ships with no runtime dependencies by
* design (SVG, HMAC and GraphQL are all hand-rolled here), so this file
* encodes PNG itself instead of pulling in a codec.
*
* The format reduces to four things the runtime already provides:
* - an 8-byte signature plus chunks (IHDR, IDAT, IEND), each one
* length + type + payload + CRC32, every integer big-endian;
* - one filter byte per scanline, always 0 here: these images are flat
* rectangles, so a predictor would buy nothing;
* - zlib-wrapped deflate for IDAT, which is exactly what
* `new CompressionStream("deflate")` emits ("deflate-raw" would not);
* - CRC32, a 256-entry table built lazily on the first chunk.
*
* Colour type 2 (truecolour, 8-bit, no alpha): these charts are opaque cards,
* so an alpha channel would cost a third more bytes for nothing. That does mean
* transparency is unavailable — a caller wanting rounded corners paints the
* colour that sits behind the image first, then the rounded card on top.
*/
// ── crc32 ────────────────────────────────────────────────────────
// One table for every chunk of every image; built on first use.
let crcTable: Uint32Array | undefined;
function crc32(bytes: Uint8Array): number {
let table = crcTable;
if (!table) {
table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c;
}
crcTable = table;
}
let crc = 0xffffffff;
for (const byte of bytes) crc = table[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
// ── chunks ───────────────────────────────────────────────────────
const SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/** length, 4-char type, payload, then CRC32 over the type and payload. */
function chunk(type: string, data: Uint8Array): Uint8Array {
const out = new Uint8Array(data.length + 12);
const view = new DataView(out.buffer);
view.setUint32(0, data.length);
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
out.set(data, 8);
view.setUint32(data.length + 8, crc32(out.subarray(4, data.length + 8)));
return out;
}
// ── 5x7 bitmap font ──────────────────────────────────────────────
const GLYPH_W = 5;
const GLYPH_H = 7;
/**
* Seven row bitmasks per glyph, five low bits each, MSB (0b10000) leftmost.
* Uppercase only: chart labels are short and mechanical, and a lowercase set
* would double the table for no gain. Unlisted characters render blank.
*/
const GLYPHS: Record<string, number[]> = {
A: [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
B: [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110],
C: [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110],
D: [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110],
E: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
F: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000],
G: [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110],
H: [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
I: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111],
J: [0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100],
K: [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001],
L: [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
M: [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001],
N: [0b10001, 0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001],
O: [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
P: [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
Q: [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10011, 0b01101],
R: [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001],
S: [0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110],
T: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100],
U: [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
V: [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100],
W: [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001],
X: [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001],
Y: [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100],
Z: [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111],
"0": [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
"1": [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
"2": [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
"3": [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
"4": [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
"5": [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
"6": [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
"7": [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
"8": [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
"9": [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
" ": [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000],
"-": [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000],
"/": [0b00001, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b10000],
":": [0b00000, 0b00100, 0b00100, 0b00000, 0b00100, 0b00100, 0b00000],
};
/**
* Device pixels `content` occupies at `scale`, inter-glyph gaps included.
* Exported because callers need it to size a canvas before constructing one.
*/
export function textWidth(content: string, scale = 1): number {
return content.length === 0 ? 0 : (content.length * (GLYPH_W + 1) - 1) * scale;
}
// ── canvas ───────────────────────────────────────────────────────
/** "#rrggbb" to its three channel bytes; called once per draw, not per pixel. */
function rgb(color: string): [number, number, number] {
const packed = Number.parseInt(color.slice(1), 16);
return [(packed >> 16) & 0xff, (packed >> 8) & 0xff, packed & 0xff];
}
/** Fixed-size RGB canvas: filled rectangles plus 5x7 bitmap text. */
export class Canvas {
readonly width: number;
readonly height: number;
/** Row-major RGB triples, unpadded; encode() interleaves the filter bytes. */
private readonly pixels: Uint8Array;
constructor(width: number, height: number, background: string) {
this.width = width;
this.height = height;
this.pixels = new Uint8Array(width * height * 3);
const [r, g, b] = rgb(background);
for (let i = 0; i < this.pixels.length; i += 3) {
this.pixels[i] = r;
this.pixels[i + 1] = g;
this.pixels[i + 2] = b;
}
}
/**
* Filled rectangle, clipped to the canvas. `radius` rounds all four corners
* by dropping the pixels whose centre falls outside the corner circle —
* enough for the small cell roundings and the card these charts draw.
*/
rect(x: number, y: number, w: number, h: number, color: string, radius = 0): void {
const [r, g, b] = rgb(color);
// Device pixels only: a fractional origin would index the buffer between
// bytes and silently drop the whole rectangle.
const left = Math.round(x);
const top = Math.round(y);
const rw = Math.round(w);
const rh = Math.round(h);
const rad = Math.min(radius, rw / 2, rh / 2);
const y1 = Math.min(this.height, top + rh);
const x1 = Math.min(this.width, left + rw);
for (let py = Math.max(0, top); py < y1; py++) {
// How far this row lies past the nearer corner centre; 0 in between.
const cy = py + 0.5;
const dy = cy < top + rad ? top + rad - cy : cy > top + rh - rad ? cy - (top + rh - rad) : 0;
const rowBase = py * this.width * 3;
for (let px = Math.max(0, left); px < x1; px++) {
const cx = px + 0.5;
const dx = cx < left + rad ? left + rad - cx : cx > left + rw - rad ? cx - (left + rw - rad) : 0;
if (dx * dx + dy * dy > rad * rad) continue;
const at = rowBase + px * 3;
this.pixels[at] = r;
this.pixels[at + 1] = g;
this.pixels[at + 2] = b;
}
}
}
/**
* Bitmap text from a top-left origin (not a baseline), `scale` device pixels
* per glyph pixel, 1 glyph pixel of advance between characters. Input is
* uppercased; unknown characters advance without drawing.
*/
text(x: number, y: number, content: string, color: string, scale = 1): void {
const [r, g, b] = rgb(color);
const left = Math.round(x);
const top = Math.round(y);
const upper = content.toUpperCase();
for (let i = 0; i < upper.length; i++) {
const glyph = GLYPHS[upper[i]!];
if (!glyph) continue;
const originX = left + i * (GLYPH_W + 1) * scale;
for (let gy = 0; gy < GLYPH_H; gy++) {
const bits = glyph[gy]!;
if (bits === 0) continue;
for (let gx = 0; gx < GLYPH_W; gx++) {
if ((bits & (1 << (GLYPH_W - 1 - gx))) === 0) continue;
// Every set glyph pixel is a scale x scale block of device pixels.
const blockX = originX + gx * scale;
const blockY = top + gy * scale;
const y1 = Math.min(this.height, blockY + scale);
const x1 = Math.min(this.width, blockX + scale);
for (let py = Math.max(0, blockY); py < y1; py++) {
const rowBase = py * this.width * 3;
for (let px = Math.max(0, blockX); px < x1; px++) {
const at = rowBase + px * 3;
this.pixels[at] = r;
this.pixels[at + 1] = g;
this.pixels[at + 2] = b;
}
}
}
}
}
}
/** PNG bytes: 8-bit truecolour, one IDAT, zlib via CompressionStream. */
async encode(): Promise<Uint8Array> {
const stride = this.width * 3;
const raw = new Uint8Array((stride + 1) * this.height);
for (let y = 0; y < this.height; y++) {
// Leading 0 of each scanline slot is the "no filter" byte.
raw.set(this.pixels.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
}
const deflated = new Response(raw).body!.pipeThrough(new CompressionStream("deflate"));
const idat = new Uint8Array(await new Response(deflated).arrayBuffer());
const ihdr = new Uint8Array(13);
const header = new DataView(ihdr.buffer);
header.setUint32(0, this.width);
header.setUint32(4, this.height);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // colour type 2: truecolour RGB
// Bytes 10-12 stay 0: deflate, adaptive filtering, no interlace.
const chunks = [chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0))];
let total = SIGNATURE.length;
for (const c of chunks) total += c.length;
const png = new Uint8Array(total);
png.set(SIGNATURE);
let at = SIGNATURE.length;
for (const c of chunks) {
png.set(c, at);
at += c.length;
}
return png;
}
}
-59
View File
@@ -1,59 +0,0 @@
/**
* 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")));
});
});
-330
View File
@@ -1,330 +0,0 @@
/**
* 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 023 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 (MonSun), aligned to CAMPAIGN_START. */
export function campaignWeek(date: string): number {
return Math.floor(daysBetween(CAMPAIGN_START, date) / 7) + 1;
}
/** Monday of the date's campaign week. */
export function weekMonday(date: string): string {
return addDays(CAMPAIGN_START, (campaignWeek(date) - 1) * 7);
}
export function isoWeek(date: string): number {
const d = atNoon(date);
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const jan1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7);
}
export function prettyDate(date: string): string {
return new Intl.DateTimeFormat("en-US", {
timeZone: "UTC",
weekday: "long",
month: "long",
day: "numeric",
}).format(atNoon(date));
}
// ── the schedule (bundled; humans edit api/data/schedule.json) ───
export const SCHEDULE: Record<string, number> = scheduleJson;
/** Week in which a topic was (or will be) taught. */
export function topicWeek(topic: number): number | undefined {
for (const [date, t] of Object.entries(SCHEDULE)) {
if (t === topic) return campaignWeek(date);
}
return undefined;
}
// ── deterministic sampling ───────────────────────────────────────
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
export function rng(seed: string): () => number {
let h = 0x811c9dc5;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return () => {
h = Math.imul(h ^ (h >>> 15), h | 1);
h ^= h + Math.imul(h ^ (h >>> 7), h | 61);
return ((h ^ (h >>> 14)) >>> 0) / 4294967296;
};
}
/** Up to n elements, FisherYates order driven by the seeded PRNG. */
export function sample<T>(pool: T[], n: number, random: () => number): T[] {
const copy = [...pool];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[copy[i], copy[j]] = [copy[j]!, copy[i]!];
}
return copy.slice(0, n);
}
// ── rows ─────────────────────────────────────────────────────────
export type Stage = "new" | "+2" | "+5" | "+10" | "retired";
export type Result = "pass" | "fail";
export type Kind = "first" | "review" | "drill" | "gate";
export interface ProblemRow {
lc_number: number;
issue: number;
topic_issue: number;
title: string;
difficulty: string;
set_label: string;
stage: Stage;
next_review: string | null;
defer_until: string | null;
}
export const INTERVAL: Record<string, number> = { "+2": 2, "+5": 5, "+10": 10 };
const NEXT_STAGE: Record<string, Stage> = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" };
// ── queries ──────────────────────────────────────────────────────
export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow | null> {
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
}
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
const { results } = await db
.prepare(
`SELECT * FROM problems
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
AND (defer_until IS NULL OR defer_until <= ?1)
ORDER BY next_review, lc_number`,
)
.bind(date)
.all<ProblemRow>();
return results;
}
/**
* Blind drills: unsolved optional problems from topics ALREADY LEARNED —
* scheduled in an earlier week (the current week's optional pool is reserved
* for Saturday's gate) AND showing learning evidence: at least one of the
* topic's core problems has entered the ladder. A skipped learning day never
* feeds drills just because its calendar week lapsed. Never repeated,
* seeded by date. Boosted topics contribute up to 2 extra.
*/
export async function pickDrills(
db: D1Database,
date: string,
budget: number,
): Promise<ProblemRow[]> {
if (budget <= 0) return [];
const week = campaignWeek(date);
const { results } = await db
.prepare(
`SELECT p.*, t.boost AS boost FROM problems p
JOIN topics t ON t.issue = p.topic_issue
WHERE p.set_label = 'optional' AND p.stage = 'new'
AND p.lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = p.lc_number)
AND EXISTS (SELECT 1 FROM problems c
WHERE c.topic_issue = p.topic_issue
AND c.set_label = 'core' AND c.stage != 'new')
ORDER BY p.lc_number`,
)
.all<ProblemRow & { boost: number }>();
const pool = results.filter((p) => {
const w = topicWeek(p.topic_issue);
return w !== undefined && w < week;
});
const boosted = pool.filter((p) => p.boost === 1);
const regular = pool.filter((p) => p.boost !== 1);
const boostPicks = sample(boosted, Math.min(2, budget), rng(`boost-${date}`));
const regularPicks = sample(
regular,
Math.min(2, Math.max(0, budget - boostPicks.length)),
rng(`drill-${date}`),
);
return [...boostPicks, ...regularPicks].slice(0, budget);
}
/** Consecutive days with ≥1 attempt, ending today or yesterday. */
export async function streak(db: D1Database, today: string): Promise<number> {
const { results } = await db
.prepare("SELECT DISTINCT date FROM attempts ORDER BY date DESC LIMIT 90")
.all<{ date: string }>();
const days = new Set(results.map((r) => r.date));
let cursor = days.has(today) ? today : addDays(today, -1);
let n = 0;
while (days.has(cursor)) {
n++;
cursor = addDays(cursor, -1);
}
return n;
}
// ── the one write path ───────────────────────────────────────────
export interface LogOutcome {
lc: number;
title: string;
kind: Kind;
result: Result;
stage: Stage;
next_review: string | null;
/** First-ever attempt — caller closes the sub-issue on pass. */
first: boolean;
issue: number;
/** Email one-tap replay: nothing changed. */
duplicate: boolean;
error?: string;
}
/**
* Record an attempt and move the ladder. Ladder semantics live here and only
* here — email one-taps, webhook /done lines, and gate scoring all converge.
*
* Email idempotency comes from the partial unique index on
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
* nothing and must not touch the ladder. Webhook corrections (pass then fail
* on the same day) remain legal — every webhook attempt appends.
*/
export async function logAttempt(
db: D1Database,
opts: { lc: number; date: string; result: Result; source: "email" | "webhook"; gate?: boolean },
): Promise<LogOutcome> {
const p = await getProblem(db, opts.lc);
const nothing: LogOutcome = {
lc: opts.lc,
title: "",
kind: "review",
result: opts.result,
stage: "new",
next_review: null,
first: false,
issue: 0,
duplicate: false,
};
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
if (p.stage === "retired") {
return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` };
}
const attempted = await db
.prepare("SELECT 1 AS x FROM attempts WHERE lc_number = ? LIMIT 1")
.bind(opts.lc)
.first();
const first = !attempted && p.stage === "new";
const kind: Kind = opts.gate ? "gate" : first ? (p.set_label === "optional" ? "drill" : "first") : "review";
const inserted = await db
.prepare(
`INSERT INTO attempts (lc_number, date, kind, result, source) VALUES (?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING`,
)
.bind(opts.lc, opts.date, kind, opts.result, opts.source)
.run();
if (opts.source === "email" && inserted.meta.changes === 0) {
return { ...nothing, title: p.title, kind, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true };
}
// Ladder move. First-ever logs enter at +2 for pass AND fail.
let stage: Stage;
if (first) {
stage = "+2";
} else if (opts.result === "pass") {
stage = NEXT_STAGE[p.stage] ?? "retired";
} else {
stage = "+2";
}
const next = stage === "retired" ? null : addDays(opts.date, INTERVAL[stage]!);
const writes = [
db.prepare(
"UPDATE problems SET stage = ?, next_review = ?, defer_until = NULL WHERE lc_number = ?",
).bind(stage, next, opts.lc),
];
if (first && kind === "drill") {
writes.push(
db.prepare("INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (?)").bind(opts.lc),
);
if (opts.result === "fail") {
writes.push(
db.prepare("UPDATE topics SET misses = misses + 1 WHERE issue = ?").bind(p.topic_issue),
);
}
}
await db.batch(writes);
return {
lc: opts.lc,
title: p.title,
kind,
result: opts.result,
stage,
next_review: next,
first,
issue: p.issue,
duplicate: false,
};
}
-107
View File
@@ -1,107 +0,0 @@
/**
* GET /api/stats — the one JSON document behind the docs progress page.
* Read-only aggregation over D1; shape is the page's contract, change both
* together. A problem counts as "done" once it entered the ladder
* (stage != 'new').
*/
import {
CAMPAIGN_DAYS,
CAMPAIGN_START,
addDays,
campaignDay,
campaignWeek,
streak,
} from "./srs.ts";
const PHASE_NAMES: Record<number, string> = {
1: "I — Linear",
2: "II — Nodal & Grid",
3: "III — Hierarchical",
4: "IV — Relational",
5: "V — Decision Space",
};
export async function buildStats(db: D1Database, today: string): Promise<object> {
const { results: phaseRows } = await db
.prepare(
`SELECT t.milestone AS milestone, p.set_label AS set_label,
COUNT(*) AS total,
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done
FROM problems p JOIN topics t ON t.issue = p.topic_issue
WHERE t.milestone IS NOT NULL
GROUP BY t.milestone, p.set_label
ORDER BY t.milestone`,
)
.all<{ milestone: number; set_label: string; total: number; done: number }>();
const phases = new Map<
number,
{ milestone: number; name: string } & Record<string, number | string>
>();
for (const row of phaseRows) {
const phase =
phases.get(row.milestone) ??
({
milestone: row.milestone,
name: PHASE_NAMES[row.milestone] ?? `Phase ${row.milestone}`,
core_done: 0,
core_total: 0,
optional_done: 0,
optional_total: 0,
deferred_done: 0,
deferred_total: 0,
} as { milestone: number; name: string } & Record<string, number | string>);
phase[`${row.set_label}_done`] = row.done;
phase[`${row.set_label}_total`] = row.total;
phases.set(row.milestone, phase);
}
const { results: ladderRows } = await db
.prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage")
.all<{ stage: string; n: number }>();
const ladder: Record<string, number> = { new: 0, "+2": 0, "+5": 0, "+10": 0, retired: 0 };
for (const row of ladderRows) ladder[row.stage] = row.n;
const { results: gates } = await db
.prepare("SELECT week, issue, pass_rate, closed_on FROM gates ORDER BY week")
.all<{ week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }>();
// Review-queue depth for the next 14 days: everything due by that day.
const queue: { date: string; due: number }[] = [];
for (let i = 0; i < 14; i++) {
const date = addDays(today, i);
const row = await db
.prepare(
`SELECT COUNT(*) AS n FROM problems
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
AND (defer_until IS NULL OR defer_until <= ?1)`,
)
.bind(date)
.first<{ n: number }>();
queue.push({ date, due: row?.n ?? 0 });
}
const recent: { date: string; attempts: number; passes: number }[] = [];
for (let i = 6; i >= 0; i--) {
const date = addDays(today, -i);
const row = await db
.prepare(
`SELECT COUNT(*) AS attempts,
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes
FROM attempts WHERE date = ?`,
)
.bind(date)
.first<{ attempts: number; passes: number | null }>();
recent.push({ date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 });
}
return {
generated: new Date().toISOString(),
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
phases: [...phases.values()],
ladder,
gates,
streak: await streak(db, today),
queue,
recent,
};
}
-18
View File
@@ -1,18 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["esnext"],
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["./worker-configuration.d.ts", "bun"]
},
"include": ["src", "scripts"]
}
-41
View File
@@ -1,41 +0,0 @@
// 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_URL": "https://prdlk.github.io/leetcode",
"DOCS_ORIGIN": "https://prdlk.github.io,https://lc.prad.nu",
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev"
}
// Secrets (wrangler secret put): GH_PAT, WEBHOOK_SECRET, LINK_KEY.
}
-22
View File
@@ -1,22 +0,0 @@
import { defineConfig } from "blume";
export default defineConfig({
title: "Leetcode",
description: "Documentation for LeetCode solutions",
deployment: {
site: "https://prdlk.github.io",
base: "/leetcode",
},
navigation: {
tabs: [
{
label: "Solutions",
path: "/solutions",
},
{
label: "Progress",
path: "/progress",
},
],
},
});
-118
View File
@@ -1,118 +0,0 @@
---
title: 'Frequency Map'
description: 'Converging/parallel index walk that prunes the O(n^2) pair space using order.'
---
## The idea
A frequency map is a hash table with one job: **count things**.
Key = the thing. Value = how many times you saw it.
One pass to build. O(1) to ask "how many?"
Most string and array problems fall to this question:
"Do these two collections contain the same stuff?"
Count both. Compare the counts.
## The picture
```mermaid
flowchart LR
S["'banana'"] --> C["Counter"]
C --> B["b → 1"]
C --> A["a → 3"]
C --> N["n → 2"]
style C fill:#1565c0,color:#fff
```
## The Python tool
`Counter` builds the map in one line. `defaultdict(int)` when you need to count by hand.
```python
from collections import Counter, defaultdict
count = Counter("banana") # {'a': 3, 'n': 2, 'b': 1}
count.most_common(2) # [('a', 3), ('n', 2)]
freq = defaultdict(int)
for c in "banana":
freq[c] += 1 # no KeyError, starts at 0
```
## Ransom Note (LC 383) — do I have enough letters?
Count the magazine. Spend letters as the note needs them.
Counter subtraction does this in two lines.
```python
from collections import Counter
def can_construct(ransom_note: str, magazine: str) -> bool:
need = Counter(ransom_note)
have = Counter(magazine)
return all(have[c] >= n for c, n in need.items())
```
## Top K Frequent (LC 347) — count, then rank
Two steps: count everything, then pick the k biggest counts.
```mermaid
flowchart LR
A["nums:<br/>1 1 1 2 2 3"] --> B["Count:<br/>1→3, 2→2, 3→1"]
B --> C["Buckets by count:<br/>slot 3: [1]<br/>slot 2: [2]<br/>slot 1: [3]"]
C --> D["Walk from top:<br/>[1, 2] ✓"]
style D fill:#2e7d32,color:#fff
```
Bucket sort trick: a number can appear at most n times.
So make n+1 buckets. Put each number in the bucket of its count.
Walk buckets from the top. O(n) — no sort, no heap.
```python
from collections import Counter
def top_k_frequent(nums: list[int], k: int) -> list[int]:
count = Counter(nums)
buckets = [[] for _ in range(len(nums) + 1)]
for num, freq in count.items():
buckets[freq].append(num)
result = []
for freq in range(len(buckets) - 1, 0, -1):
for num in buckets[freq]:
result.append(num)
if len(result) == k:
return result
```
## Say the trade-off out loud
Three ways to rank counts. Name them in the interview:
| Method | Time | When |
|---|---|---|
| Sort the counts | O(n log n) | Fine, simple |
| Heap of size k | O(n log k) | k small, streaming |
| Bucket sort | O(n) | Best — counts are bounded by n |
## Complexity
| | Time | Space |
|---|---|---|
| Build the map | O(n) | O(u) — unique items |
| Lookup | O(1) | — |
## Close-out ritual
Before you submit, say out loud:
1. Time and space complexity.
2. One edge case trace: empty string, k = number of unique items, or one element repeated n times.
## Plan problems
**A-set:** LC 217 · 242 · 1 · 49 · 347
**B-set:** LC 383 · 205 · 128 · 290
-116
View File
@@ -1,116 +0,0 @@
---
title: 'Two Pointers'
description: 'Converging/parallel index walk that prunes the O(n^2) pair space using order.'
---
## The idea
Checking every pair costs O(n²). That is n² boxes to open.
But if the array is **ordered**, you do not need every pair.
Put one pointer at each end. Look at the sum:
- Too small? Only a bigger left value can help. Move left in.
- Too big? Only a smaller right value can help. Move right in.
Each move kills a whole row of the pair space. O(n²) → O(n).
## The picture
```mermaid
flowchart TD
A["2 7 11 15 target 18<br/>L R"] --> B["2 + 15 = 17 < 18<br/>too small → move L right"]
B --> C["2 7 11 15<br/> L R"]
C --> D["7 + 15 = 22 > 18<br/>too big → move R left"]
D --> E["2 7 11 15<br/> L R"]
E --> F["7 + 11 = 18 ✓"]
style F fill:#2e7d32,color:#fff
```
## Not the same as binary search
Binary search **jumps** to the middle and discards half unseen — O(log n).
Two pointers **walks** — every element gets inspected once — O(n).
Both need order. Different moves.
## Two Sum II (LC 167) — the pure template
```python
def two_sum(numbers: list[int], target: int) -> list[int]:
lo, hi = 0, len(numbers) - 1
while lo < hi:
total = numbers[lo] + numbers[hi]
if total == target:
return [lo + 1, hi + 1]
if total < target:
lo += 1 # only a bigger value helps
else:
hi -= 1 # only a smaller value helps
```
## Container With Most Water (LC 11) — move the shorter wall
Water = width × shorter wall.
Moving the taller wall can never help — width shrinks, height cannot grow.
So always move the shorter one. Say this proof in the interview.
```python
def max_area(height: list[int]) -> int:
lo, hi = 0, len(height) - 1
best = 0
while lo < hi:
best = max(best, (hi - lo) * min(height[lo], height[hi]))
if height[lo] < height[hi]:
lo += 1
else:
hi -= 1
return best
```
## 3Sum (LC 15) — fix one, converge two
Sort first. Fix the smallest number. Now it is Two Sum II on the rest.
Skip duplicates at every level or you get repeat triplets.
```python
def three_sum(nums: list[int]) -> list[list[int]]:
nums.sort()
result = []
for i in range(len(nums) - 2):
if i > 0 and nums[i] == nums[i - 1]:
continue # skip duplicate anchor
lo, hi = i + 1, len(nums) - 1
while lo < hi:
total = nums[i] + nums[lo] + nums[hi]
if total < 0:
lo += 1
elif total > 0:
hi -= 1
else:
result.append([nums[i], nums[lo], nums[hi]])
lo += 1
while lo < hi and nums[lo] == nums[lo - 1]:
lo += 1 # skip duplicate pair
return result
```
## Complexity
| | Time | Space |
|---|---|---|
| Converging pair | O(n) | O(1) |
| 3Sum | O(n²) — sort + n passes | O(1) extra |
## Close-out ritual
Before you submit, say out loud:
1. Time and space complexity.
2. One edge case trace: two elements, all duplicates, or no valid answer.
## Plan problems
**A-set:** LC 125 · 167 · 15 · 11 · 42
**B-set:** LC 392 · 977 · 189 · 80
-119
View File
@@ -1,119 +0,0 @@
---
title: 'Prefix Sum'
description: 'Precomputed cumulative state for O(1) range queries.'
---
## The idea
Precompute running totals once. Then any range sum costs O(1).
Like mile markers on a highway.
Distance from mile 30 to mile 80? Subtract: 80 30 = 50.
You do not re-drive the road.
**The formula:** `sum(i..j) = prefix[j + 1] - prefix[i]`
## The picture
```mermaid
flowchart TD
A["nums: 3 1 4 1 5"] --> B["prefix: 0 3 4 8 9 14"]
B --> C["sum(1..3) = prefix[4] prefix[1]<br/>= 9 3 = 6"]
C --> D["Check: 1 + 4 + 1 = 6 ✓"]
style D fill:#2e7d32,color:#fff
```
The leading 0 matters. It makes ranges that start at index 0 work with no special case.
## Range Sum Query (LC 303)
Pay O(n) once at build time. Answer every query in O(1).
```python
from itertools import accumulate
class NumArray:
def __init__(self, nums: list[int]):
self.prefix = [0] + list(accumulate(nums))
def sum_range(self, left: int, right: int) -> int:
return self.prefix[right + 1] - self.prefix[left]
```
## Prefix + Hashmap (LC 560 — Subarray Sum Equals K)
The signature trick of this topic. The question flips:
"Which subarrays sum to k?" becomes
"At each point, how many *earlier* prefixes equal `current k`?"
Because: if `prefix[j] prefix[i] = k`, the slice between them sums to k.
```mermaid
flowchart LR
A["Walk the array,<br/>carry running sum"] --> B["Ask the map:<br/>seen sum k before?"]
B --> C["Yes, m times →<br/>add m to answer"]
B --> D["Record current sum<br/>in the map"]
D --> A
style C fill:#2e7d32,color:#fff
```
```python
from collections import defaultdict
def subarray_sum(nums: list[int], k: int) -> int:
count = 0
current = 0
seen = defaultdict(int)
seen[0] = 1 # empty prefix — subarrays that start at 0
for n in nums:
current += n
count += seen[current - k] # ask first
seen[current] += 1 # record after
return count
```
**Order matters.** Ask before you record, or a subarray of length 0 counts itself when k = 0.
## Product variant (LC 238 — Product of Array Except Self)
Same idea, two directions. Prefix products from the left, suffix products from the right.
`answer[i] = left[i] × right[i]` — everything except i.
```python
def product_except_self(nums: list[int]) -> list[int]:
n = len(nums)
result = [1] * n
left = 1
for i in range(n):
result[i] = left
left *= nums[i]
right = 1
for i in range(n - 1, -1, -1):
result[i] *= right
right *= nums[i]
return result
```
## Complexity
| | Time | Space |
|---|---|---|
| Build | O(n) | O(n) |
| Each range query | O(1) | — |
| Prefix + hashmap | O(n) one pass | O(n) |
## Close-out ritual
Before you submit, say out loud:
1. Time and space complexity.
2. One edge case trace: range starting at 0, k = 0 with zeros in the array, or negative numbers (this is why sliding window fails and prefix + hashmap wins).
## Plan problems
**A-set:** LC 303 · 238 · 560
**B-set:** LC 525 · 974
-117
View File
@@ -1,117 +0,0 @@
---
title: 'Sliding Window'
description: 'Two pointers plus incremental state between them.'
---
## The idea
Two pointers that move the **same direction**, carrying state between them.
Think of a caterpillar:
- The head crawls forward and eats (expand right).
- When the rule breaks, the tail pulls in until the rule holds again (contract left).
The window is always a **contiguous** slice. You never rebuild it — you update the carried state as edges move. That is what makes it O(n).
**The recipe:**
1. Expand right. Add the new element to your state.
2. Rule broken? Contract left until it holds.
3. Record the best window. Repeat.
## The picture
```mermaid
flowchart TD
A["a b c a b c<br/>[a] window = a"] --> B["[a b] expand → ab"]
B --> C["[a b c] expand → abc, best = 3"]
C --> D["a [b c a] 'a' repeats → contract, then expand"]
D --> E["a b [c a b] keep sliding, best stays 3"]
style C fill:#2e7d32,color:#fff
```
## Longest Substring Without Repeating (LC 3)
State = a set of chars in the window. Repeat found? Shrink from the left until it is gone.
```python
def length_of_longest_substring(s: str) -> int:
window = set()
left = 0
best = 0
for right, c in enumerate(s):
while c in window: # rule broken
window.remove(s[left]) # contract left
left += 1
window.add(c) # expand right
best = max(best, right - left + 1)
return best
```
## Best Time to Buy and Sell (LC 121) — the hidden window
Looks like a stock problem. It is a window problem.
Left = cheapest buy so far. Right = today. Carry one number: the min price.
```python
def max_profit(prices: list[int]) -> int:
min_price = prices[0]
best = 0
for p in prices[1:]:
best = max(best, p - min_price)
min_price = min(min_price, p)
return best
```
## Character Replacement (LC 424) — window with a budget
Rule: window is valid if `window size count of top letter ≤ k`.
That many replacements fix the window. Carry a frequency map.
```python
from collections import defaultdict
def character_replacement(s: str, k: int) -> int:
count = defaultdict(int)
left = 0
best = 0
top = 0 # highest letter count seen
for right, c in enumerate(s):
count[c] += 1
top = max(top, count[c])
if (right - left + 1) - top > k: # over budget
count[s[left]] -= 1 # contract exactly one step
left += 1
best = max(best, right - left + 1)
return best
```
## When the window fails
Sliding window needs a one-way rule: **growing can only hurt, shrinking can only help.**
Negative numbers break this — a bigger window can flip from bad to good.
That is when you reach for prefix sum + hashmap (LC 560) instead.
Say this decision out loud in the interview.
## Complexity
| | Time | Space |
|---|---|---|
| All patterns above | O(n) — each index enters and leaves once | O(1) or O(alphabet) |
## Close-out ritual
Before you submit, say out loud:
1. Time and space complexity — note each element enters and leaves the window at most once.
2. One edge case trace: empty string, all same char, or k larger than the string.
## Plan problems
**A-set:** LC 121 · 3 · 424 · 567 · 76
**B-set:** LC 209 · 1004 · 643
<YouTube id="QGNAVBn1_bc" title="Sliding Window" />
-100
View File
@@ -1,100 +0,0 @@
---
title: 'Binary Search'
description: 'In algorithms, arrays and strings are very similar. They are both ordered collections of elements. The difference is that arrays are mutable, while strings are immutable.'
---
## The idea
You look for a name in a phone book. You do not start at page 1.
You open the middle. Too far? Go left. Not far enough? Go right.
Each step throws away half the book.
100 items → 7 steps. 4 billion items → 32 steps.
**The core rule:** ask a yes/no question that flips exactly once.
Everything left of the flip is "no". Everything right is "yes".
Binary search finds the flip point.
## The picture
```mermaid
flowchart TD
A["Array: 1 3 5 7 9 11 13<br/>Target: 9"] --> B["mid = 7<br/>7 < 9 → discard left half"]
B --> C["Array: 9 11 13<br/>mid = 11<br/>11 > 9 → discard right half"]
C --> D["Array: 9<br/>mid = 9<br/>Found ✓"]
style D fill:#2e7d32,color:#fff
```
## The template
```python
def binary_search(nums: list[int], target: int) -> int:
lo, hi = 0, len(nums) - 1
while lo <= hi:
mid = (lo + hi) // 2
if nums[mid] == target:
return mid
if nums[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1
```
## Search the answer space (LC 875 — Koko Eating Bananas)
The array does not need to exist. You can binary search over *possible answers*.
Ask: "does speed k work?" Slow speeds fail. Fast speeds work.
The answer flips once. Find the flip.
```mermaid
flowchart LR
A["k=1<br/>NO"] --> B["k=2<br/>NO"] --> C["k=3<br/>NO"] --> D["k=4<br/>YES ← answer"] --> E["k=5<br/>YES"] --> F["k=6<br/>YES"]
style D fill:#2e7d32,color:#fff
```
```python
import math
def min_eating_speed(piles: list[int], h: int) -> int:
def k_works(k: int) -> bool:
return sum(math.ceil(p / k) for p in piles) <= h
lo, hi = 1, max(piles)
while lo < hi:
mid = (lo + hi) // 2
if k_works(mid):
hi = mid # mid works — keep it, look left
else:
lo = mid + 1 # mid fails — discard it
return lo
```
## Not the same as two pointers
Two pointers **walks** — it inspects every element it passes. O(n).
Binary search **jumps** — it inspects one midpoint and discards half unseen. O(log n).
## Complexity
| | Time | Space |
|---|---|---|
| Sorted array | O(log n) | O(1) |
| Answer space | O(n log m) | O(1) |
n = array size, m = answer range.
## Close-out ritual
Before you submit, say out loud:
1. Time and space complexity.
2. One edge case trace: empty array, one element, or target not present.
## Plan problems
**A-set:** LC 704 · 74 · 875 · 33 · 153
**B-set:** LC 35 · 162 · 34 · 4 ⭐
-121
View File
@@ -1,121 +0,0 @@
---
title: Breadth-First Search
---
# Breadth-First Search (BFS)
## The idea
You want a mango seller. You ask your friends first.
No luck? You ask friends-of-friends. Then their friends.
You search in **rings**, closest first.
That is BFS. It answers two questions:
1. **Is there a path** from A to B?
2. **What is the shortest path?** (unweighted — fewest hops)
BFS is *always* shortest path on unweighted graphs. First time you reach a node = fewest possible steps.
## The picture
```mermaid
flowchart TD
YOU((You)) --> A((Alice))
YOU --> B((Bob))
A --> C((Claire))
A --> D((Dan))
B --> E((Eve))
E --> M((Mango seller ✓))
style YOU fill:#1565c0,color:#fff
style A fill:#6a1b9a,color:#fff
style B fill:#6a1b9a,color:#fff
style C fill:#ef6c00,color:#fff
style D fill:#ef6c00,color:#fff
style E fill:#ef6c00,color:#fff
style M fill:#2e7d32,color:#fff
```
Blue = start. Purple = ring 1. Orange = ring 2. Green = found at ring 3.
## The two tools
1. **Queue** — first in, first out. This keeps the rings in order.
2. **Visited set** — never check the same node twice. Without it, cycles loop forever.
## The template
```python
from collections import deque
def bfs(start, graph: dict) -> None:
queue = deque([start])
visited = {start}
while queue:
level_size = len(queue) # snapshot = one full ring
for _ in range(level_size):
node = queue.popleft()
# process node here
for nxt in graph.get(node, []):
if nxt not in visited:
visited.add(nxt) # mark WHEN queued, not when popped
queue.append(nxt)
```
## Multi-source BFS (LC 994 — Rotting Oranges)
Start with *all* rotten oranges in the queue at once.
Each ring = one minute of rot spreading.
```python
from collections import deque
def oranges_rotting(grid: list[list[int]]) -> int:
rows, cols = len(grid), len(grid[0])
queue = deque()
fresh = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == 2:
queue.append((r, c))
elif grid[r][c] == 1:
fresh += 1
minutes = 0
dirs = [(1, 0), (-1, 0), (0, 1), (0, -1)]
while queue and fresh > 0:
for _ in range(len(queue)):
r, c = queue.popleft()
for dr, dc in dirs:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
grid[nr][nc] = 2
fresh -= 1
queue.append((nr, nc))
minutes += 1
return minutes if fresh == 0 else -1
```
## Complexity
| | Time | Space |
|---|---|---|
| BFS | O(V + E) | O(V) |
V = nodes, E = edges. On a grid: O(rows × cols).
## Close-out ritual
Before you submit, say out loud:
1. Time and space complexity.
2. One edge case trace: empty graph, start = target, or unreachable target.
## Plan problems
**Tree BFS A-set:** LC 102 · 199 · 1448
**Graph BFS A-set:** LC 994 · 127
**B-set:** LC 103 · 637 · 909 · 433 · 286
-110
View File
@@ -1,110 +0,0 @@
---
title: Depth-First Search
---
# Depth-First Search (DFS)
## The idea
BFS searches in rings. DFS picks one path and follows it to the end.
Dead end? Back up one step. Try the next branch.
Like exploring a maze with one hand on the wall.
Use DFS when:
- The answer **composes from subtrees** (depth, path sums, subtree checks).
- You need **all** of a region (islands, flood fill, connected components).
Use BFS when you need **shortest path**. DFS does not give shortest path.
## The picture
```mermaid
flowchart TD
A((1)) --> B((2))
A --> C((5))
B --> D((3))
B --> E((4))
C --> F((6))
style A fill:#1565c0,color:#fff
style B fill:#1565c0,color:#fff
style D fill:#1565c0,color:#fff
```
Visit order: 1 → 2 → 3 (bottom!) → back up → 4 → back up → 5 → 6.
Numbers show the order. DFS goes **deep before wide**.
## Tree DFS — answers flow up (LC 104 — Max Depth)
The pattern: ask each child a question. Combine the answers. Return up.
```python
def max_depth(root) -> int:
if root is None:
return 0 # base case
left = max_depth(root.left)
right = max_depth(root.right)
return 1 + max(left, right) # combine + return up
```
```mermaid
flowchart BT
D["leaf returns 1"] --> B["returns 1 + max(1,1) = 2"]
E["leaf returns 1"] --> B
B --> A["root returns 1 + max(2,1) = 3"]
C["leaf returns 1"] --> A
style A fill:#2e7d32,color:#fff
```
## Graph DFS — add a visited set (LC 200 — Number of Islands)
Graphs have cycles. Trees do not.
So graph DFS needs one new thing: **mark where you have been.**
On a grid, sink the land as you visit it — the grid *is* the visited set.
```python
def num_islands(grid: list[list[str]]) -> int:
rows, cols = len(grid), len(grid[0])
count = 0
def sink(r: int, c: int) -> None:
if r < 0 or r >= rows or c < 0 or c >= cols:
return
if grid[r][c] != "1":
return
grid[r][c] = "0" # mark visited
sink(r + 1, c)
sink(r - 1, c)
sink(r, c + 1)
sink(r, c - 1)
for r in range(rows):
for c in range(cols):
if grid[r][c] == "1":
count += 1
sink(r, c) # eat the whole island
return count
```
## Complexity
| | Time | Space |
|---|---|---|
| Tree DFS | O(n) | O(h) — call stack, h = height |
| Graph DFS | O(V + E) | O(V) |
Worst case h = n (a stick-shaped tree).
## Close-out ritual
Before you submit, say out loud:
1. Time and space complexity — name the call stack.
2. One edge case trace: null root, single node, or all-water grid.
## Plan problems
**Tree DFS A-set:** LC 226 · 104 · 100 · 572 · 236 · 124 ⭐
**Graph DFS A-set:** LC 200 · 695 · 133 · 417 · 130
**B-set:** LC 101 · 112 · 129 · 543 · 399 · 261
-127
View File
@@ -1,127 +0,0 @@
---
title: Dynamic Programming (1-D)
---
# Dynamic Programming (1-D)
## The idea
DP is backtracking with a memory.
Backtracking tries every path. Many paths repeat the same subproblem.
DP solves each subproblem **once**, saves the answer, and reuses it.
Grokking's rule: break the big problem into small problems.
Solve the small ones first. Build up.
**The two things you must find:**
1. **The state** — what does `dp[i]` mean, in one sentence?
2. **The recurrence** — how does `dp[i]` come from earlier answers?
## The picture — why memo matters (Climbing Stairs)
Without memo, `f(5)` computes `f(3)` twice and `f(2)` three times:
```mermaid
flowchart TD
A["f(5)"] --> B["f(4)"]
A --> C["f(3)"]
B --> D["f(3)"]
B --> E["f(2)"]
C --> F["f(2)"]
C --> G["f(1)"]
D --> H["f(2)"]
D --> I["f(1)"]
style C fill:#c62828,color:#fff
style D fill:#c62828,color:#fff
style E fill:#ef6c00,color:#fff
style F fill:#ef6c00,color:#fff
style H fill:#ef6c00,color:#fff
```
Red and orange = repeated work. Memo turns the tree into a straight line: O(2ⁿ) → O(n).
## Climbing Stairs (LC 70)
State: `dp[i]` = ways to reach step i.
Recurrence: you arrive from one step below or two below.
```python
def climb_stairs(n: int) -> int:
if n <= 2:
return n
prev2, prev1 = 1, 2
for _ in range(3, n + 1):
prev2, prev1 = prev1, prev1 + prev2
return prev1
```
## House Robber (LC 198)
State: `dp[i]` = max loot using houses 0..i.
Recurrence at each house: **rob it** (skip the neighbor) or **skip it**.
```mermaid
flowchart LR
A["House i"] --> B["Rob:<br/>nums[i] + dp[i-2]"]
A --> C["Skip:<br/>dp[i-1]"]
B --> D["dp[i] = max of both"]
C --> D
style D fill:#2e7d32,color:#fff
```
```python
def rob(nums: list[int]) -> int:
skip = take = 0
for n in nums:
skip, take = max(skip, take), skip + n # skip it / rob it
return max(skip, take)
```
## Coin Change (LC 322)
State: `dp[a]` = fewest coins to make amount a.
Recurrence: try each coin, take the best.
```python
import math
def coin_change(coins: list[int], amount: int) -> int:
dp = [math.inf] * (amount + 1)
dp[0] = 0
for a in range(1, amount + 1):
for c in coins:
if c <= a:
dp[a] = min(dp[a], dp[a - c] + 1)
return dp[amount] if dp[amount] != math.inf else -1
```
## The interview script
Say these four lines out loud, in order:
1. "The brute force is backtracking — try everything."
2. "Subproblems overlap, so I will memoize."
3. "State: dp[i] means ___." (one sentence)
4. "Recurrence: dp[i] = ___."
## Complexity
| | Time | Space |
|---|---|---|
| Climbing Stairs / House Robber | O(n) | O(1) with rolling vars |
| Coin Change | O(amount × coins) | O(amount) |
## Close-out ritual
Before you submit, say out loud:
1. Time and space complexity.
2. One edge case trace: n = 0 or 1, empty array, or unreachable amount (return -1).
## Plan problems
**A-set:** LC 70 · 746 · 198 · 213 · 322 · 300
**B-set:** LC 139 · 91 · 647
Note: DP matters only if Google or Databricks advance to later rounds. Your fintech targets skew toward simulation, hashmap, and heap.
-95
View File
@@ -1,95 +0,0 @@
---
title: Greedy Algorithms
---
# Greedy Algorithms
## The idea
At each step, take the best move *right now*. Never look back.
Grokking's example: the classroom problem.
You want the most classes in one room. Which do you pick?
**Always pick the class that ends first.** It leaves the most room for the rest.
Greedy works only when the local best is *provably* the global best.
When it works, it beats DP — no memo table, no lookback, often O(n).
## The picture
```mermaid
gantt
dateFormat HH:mm
axisFormat %H:%M
section Pick ✓
Art (ends first) :done, 09:00, 45m
Math (ends next) :done, 10:00, 60m
Music :done, 11:00, 60m
section Skip ✗
English (overlaps Art) :crit, 09:30, 60m
CS (overlaps Math) :crit, 10:30, 60m
```
Pick by earliest end time. Skip anything that overlaps a pick.
## Kadane's — DP squeezed to one variable (LC 53 — Maximum Subarray)
The greedy question at each element:
"Do I extend the running sum, or start fresh here?"
If the running sum is negative, it only hurts. Drop it.
```python
def max_sub_array(nums: list[int]) -> int:
best = current = nums[0]
for n in nums[1:]:
current = max(n, current + n) # extend or restart
best = max(best, current)
return best
```
```mermaid
flowchart LR
A["-2"] --> B["1<br/>restart"] --> C["-2<br/>extend"] --> D["4<br/>restart"] --> E["3<br/>extend"] --> F["5<br/>extend"] --> G["6<br/>extend ← best"]
style G fill:#2e7d32,color:#fff
```
## Jump Game (LC 55) — track the farthest reach
One pass. Keep the farthest index you can touch.
If your position ever passes the reach, you are stuck.
```python
def can_jump(nums: list[int]) -> bool:
reach = 0
for i, n in enumerate(nums):
if i > reach:
return False # stuck
reach = max(reach, i + n)
return True
```
## How to justify greedy in an interview
Use an **exchange argument**: "If an optimal answer made a different choice here, I could swap in my greedy choice without making it worse."
Say this out loud. It is the difference between guessing and proving.
## Complexity
| | Time | Space |
|---|---|---|
| One-pass greedy (Kadane, Jump) | O(n) | O(1) |
| Sort-then-commit (intervals) | O(n log n) | O(1) |
## Close-out ritual
Before you submit, say out loud:
1. Time and space complexity.
2. One edge case trace: all-negative array, single element, or zero at index 0.
## Plan problems
**A-set:** LC 53 · 55 · 45 · 134
**B-set:** LC 122 · 918 · 763
**Feeds into topic 24 (Intervals):** LC 57 · 56 · 435 · 253
-6
View File
@@ -1,6 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({
title: "Algorithms",
order: 2,
});
@@ -1,65 +0,0 @@
---
title: 'Arrays and Strings'
description: 'In algorithms, arrays and strings are very similar. They are both ordered collections of elements. The difference is that arrays are mutable, while strings are immutable.'
sidebar:
label: Arrays & Strings
---
Technically, an array can't be resized. A dynamic array, or list, can be. In the context of algorithm problems, usually when people talk about arrays, they are referring to dynamic arrays. In this entire course, we will be talking about dynamic arrays/lists, but we will just use the word "array".
## Time Complexity
Arrays and strings are both ordered collections of elements. The difference is that arrays are mutable, while strings are immutable.
| Operation | Array/List | String(Immutable) |
| --------- | ---------- | ----------------- |
| Appending to end | *O(1) | O(n) |
| Popping from end | O(1) | O(n) |
| Insertion, not from end | O(n) | O(n) |
| Deletion, not from end | O(n) | O(n) |
| Modifying an element | O(1) | O(n) |
| Random access | *O(1) | O(n) |
| Checking if an element exists | O(1) | O(n) |
:::note[Clarification 1]
Appending to the end of a list is amortized O(1) time complexity. This means that the time complexity of appending to the end of a list is the same as the time complexity of appending to the end of an array.
:::
:::note[Clarification 2]
Random access in this context means that you can access an element at any index in constant time.
:::
## Patterns
<Columns cols={2}>
<Column>
<Tile
title="Two Pointers"
description="Ship your first page in minutes."
href="/leetcode/two-pointers"
>
<Icon icon="rocket" size={28} />
</Tile>
</Column>
<Column>
<Tile
title="Sliding Window"
description="Ship your first page in minutes."
href="/leetcode/sliding-window"
>
<Icon icon="rocket" size={28} />
</Tile>
</Column>
<Column>
<Tile
title="Prefix Sum"
description="Ship your first page in minutes."
href="/leetcode/prefix-sum"
>
<Icon icon="rocket" size={28} />
</Tile>
</Column>
</Columns>
-101
View File
@@ -1,101 +0,0 @@
---
title: 'Hash Tables'
description: 'In algorithms, arrays and strings are very similar. They are both ordered collections of elements. The difference is that arrays are mutable, while strings are immutable.'
---
## The idea
A hash table is like a helpful grocery clerk.
You say "avocado". She tells you the price at once.
She does not walk the aisles. She just *knows*.
How? A hash function turns your key into a slot number.
Same key → same slot, every time. Lookup is O(1).
## The picture
```mermaid
flowchart LR
K1["'apple'"] --> H["Hash<br/>function"]
K2["'milk'"] --> H
K3["'avocado'"] --> H
H --> S0["Slot 0: milk → 1.49"]
H --> S1["Slot 1: apple → 0.67"]
H --> S2["Slot 2: avocado → 1.99"]
style H fill:#1565c0,color:#fff
```
## Three jobs it does
1. **Membership** — "have I seen this before?"
2. **Frequency** — "how many times?"
3. **Mapping** — "what goes with this?"
## Membership (LC 217 — Contains Duplicate)
```python
def contains_duplicate(nums: list[int]) -> bool:
seen = set()
for n in nums:
if n in seen:
return True
seen.add(n)
return False
```
## Frequency (LC 242 — Valid Anagram)
```python
from collections import Counter
def is_anagram(s: str, t: str) -> bool:
if len(s) != len(t):
return False
return Counter(s) == Counter(t)
```
## Key design (LC 49 — Group Anagrams)
Sometimes the trick is *what you use as the key*.
"eat", "tea", "ate" all sort to "aet". Sorted string = group key.
```mermaid
flowchart LR
A["eat"] --> K["key: 'aet'"]
B["tea"] --> K
C["ate"] --> K
K --> G["group: [eat, tea, ate]"]
style K fill:#1565c0,color:#fff
```
```python
from collections import defaultdict
def group_anagrams(strs: list[str]) -> list[list[str]]:
groups = defaultdict(list)
for s in strs:
key = "".join(sorted(s))
groups[key].append(s)
return list(groups.values())
```
## Complexity
| | Average | Worst |
|---|---|---|
| Lookup / insert / delete | O(1) | O(n) |
Worst case comes from collisions. Interviews treat it as O(1).
## Close-out ritual
Before you submit, say out loud:
1. Time and space complexity.
2. One edge case trace: empty input, all same element, or key collision on your key design.
## Plan problems
**A-set:** LC 217 · 242 · 1 · 49 · 347
**B-set:** LC 383 · 205 · 128 · 290
-6
View File
@@ -1,6 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({
title: "Data Structures",
order: 1,
});
-12
View File
@@ -1,12 +0,0 @@
---
title: Introduction
description: Welcome to your new Blume docs.
---
## Getting Started
![Dependency Map](../public/dependency-spine.svg)
Welcome to **Blume** — markdown-first docs powered by Astro and Vite.
Edit `docs/index.mdx` to get started, then run `blume dev`.
-9
View File
@@ -1,9 +0,0 @@
---
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.
<ProgressDashboard />
-47
View File
@@ -1,47 +0,0 @@
---
title: '1. Two Sum'
description: You are given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target
sidebar:
label: 'Two Sum'
badge: 'Easy'
---
<Badge variant="accent">Hash Table</Badge>
::::warning
Can you come up with an algorithm that is less than O(n^2) time complexity?
::::
### Example 1:
- Input: `nums = [2,7,11,15], target = 9`
- Output: `[0,1]`
- Explanation: Because `nums[0] + nums[1]` == `9`, we return [0, 1].
### Example 2:
- Input: `nums = [3,2,4], target = 6`
- Output: `[1,2]`
### Example 3:
- Input: `nums = [3,3], target = 6`
- Output: `[0,1]`
### Constraints:
- `2 <= nums.length <= 10^4`
- `-10^9 <= nums[i] <= 10^9`
- `-10^9 <= target <= 10^9`
- Only one valid answer exists.
## Solution
```py
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
seen = {}
for i, num in enumerate(nums):
complement = target - num
if complement in seen:
return [seen[complement], i]
seen[num] = i
return []
```
@@ -1,48 +0,0 @@
---
title: '11. Container With Most Water'
description: You are given an integer array height of length n. There are n vertical lines drawn such that the two endpoints of the i^th line are (i, 0) and (i, height[i])
sidebar:
label: 'Container With Most Water'
badge: 'Medium'
---
<Badge variant="accent">Two Pointers</Badge>
::::warning
Notice that you may not slant the container.
::::
### Example 1:
- Input: `height = [1,8,6,2,5,4,8,3,7]`
- Output: `49`
- Explanation: The above vertical lines are represented by array `[1,8,6,2,5,4,8,3,7]`. In this case, the max area of water (blue section) the container can contain is `49`.
### Example 2:
- Input: `height = [1,1]`
- Output: `1`
### Constraints:
- `n == height.length`
- `2 <= n <= 10^5`
- `0 <= height[i] <= 10^4`
## Solution
```py
class Solution:
def maxArea(self, height: List[int]) -> int: # noqa: F821
res = 0
left = 0
right = len(height) - 1
while left < right:
area = (right - left) * min(height[left], height[right])
res = max(res, area)
if height[left] < height[right]:
left += 1
else:
right -= 1
return res
```
@@ -1,38 +0,0 @@
---
title: '121. Best Time to Buy and Sell Stock'
description: You are given an array prices where prices[i] is the price of a given stock on the i^th day
sidebar:
label: 'Best Time to Buy and Sell Stock'
badge: 'Easy'
---
<Badge variant="accent">Sliding Window</Badge>
### Example 1:
- Input: `prices = [7,1,5,3,6,4]`
- Output: `5`
- Explanation: Buy on day `2` (price = `1`) and sell on day `5` (price = `6`), profit = `6-1 = 5`. Note that buying on day `2` and selling on day `1` is not allowed because you must buy before you sell.
### Example 2:
- Input: `prices = [7,6,4,3,1]`
- Output: `0`
- Explanation: In this case, no transactions are done and the max profit = `0`.
### Constraints:
- `1 <= prices.length <= 10^5`
- `0 <= prices[i] <= 10^4`
## Solution
```py
class Solution:
def maxProfit(self, prices: List[int]) -> int:
left = min(prices)
for right in range(len(prices)):
while curr > left:
curr -= prices[left]
left += 1
ans = max(ans, curr)
return ans
```
@@ -1,63 +0,0 @@
---
title: '1365. How Many Numbers Are Smaller Than the Current Number'
description: Given the array nums, for each nums[i] find out how many numbers in the array are smaller than it. That is, for each nums[i] you have to count the number of valid j's such that j != i and nums[j] < nums[i]
sidebar:
label: 'How Many Numbers Are Smaller Than the Current Number'
badge: 'Easy'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `nums = [8,1,2,2,3]`
- Output: `[4,0,1,1,3]`
- Explanation: For `nums[0]=8` there exist four smaller numbers than it (`1`, `2`, `2` and `3`). For `nums[1]=1` does not exist any smaller number than it. For `nums[2]=2` there exist one smaller number than it (`1`). For `nums[3]=2` there exist one smaller number than it (`1`). For `nums[4]=3` there exist three smaller numbers than it (`1`, `2` and `2`).
### Example 2:
- Input: `nums = [6,5,4,8]`
- Output: `[2,1,0,3]`
### Example 3:
- Input: `nums = [7,7,7,7]`
- Output: `[0,0,0,0]`
### Constraints:
- `2 <= nums.length <= 500`
- `0 <= nums[i] <= 100`
## Solution
```js
/**
* @param {number[]} nums
* @return {number[]}
*/
var smallerNumbersThanCurrent = function (nums) {
// Step 1: Begin by initializing a [Frequency Map]()
const freq = {};
for (let n of nums) freq[n] = (freq[n] || 0) + 1;
// Step 2: Sort the numbers by ascending order
const sorted = Object.keys(freq).sort((a, b) => a - b);
// Step 3: Init a count of numbers smaller than the active number
let count = 0;
// Step 4: Init a map to track number of values smaller for each number
const smaller = {};
// Step 5: Iterate over the sorted list
for (let num of sorted) {
// Set count for active number
smaller[num] = count;
// Update the count by frequency
count += freq[num];
}
// Step 6: Use original list and find number of smaller values than it
return nums.map((n) => smaller[n]);
};
```
@@ -1,57 +0,0 @@
---
title: '1413. Minimum Value to Get Positive Step by Step Sum'
description: Given an array of integers nums, you start with an initial positive value startValue
sidebar:
label: 'Minimum Value to Get Positive Step by Step Sum'
badge: 'Easy'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `nums = [-3,2,-3,4,2]`
- Output: `5`
- Explanation: If you choose `startValue = 4`, in the third iteration your step by step sum is less than `1`.
```
step by step sum
startValue = 4 | startValue = 5 | nums
(4 -3 ) = 1 | (5 -3 ) = 2 | -3
(1 +2 ) = 3 | (2 +2 ) = 4 | 2
(3 -3 ) = 0 | (4 -3 ) = 1 | -3
(0 +4 ) = 4 | (1 +4 ) = 5 | 4
(4 +2 ) = 6 | (5 +2 ) = 7 | 2
```
### Example 2:
- Input: `nums = [1,2]`
- Output: `1`
- Explanation: Minimum start value should be positive.
### Example 3:
- Input: `nums = [1,-2,-3]`
- Output: `5`
### Constraints:
- `1 <= nums.length <= 100`
- `-100 <= nums[i] <= 100`
## Solution
```js
/**
* @param {number[]} nums
* @return {number}
*/
var minStartValue = function(nums) {
let prefix = [nums[0]];
// Make prefix sum start at 1 after initializing seed value
for (let i = 1; i < nums.length; i++){
prefix.push(prefix[i - 1] + nums[i]);
}
let min = Math.min(...prefix);
return Math.max(1, 1 - min);
};
```
@@ -1,45 +0,0 @@
---
title: '1426. Counting Elements'
description: Given an integer array arr, count how many elements x there are, such that x + 1 is also in arr. If there are duplicates in arr, count them separately
sidebar:
label: 'Counting Elements'
badge: 'Easy'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `arr = [1,2,3]`
- Output: `2`
- Explanation: `1` and `2` are counted cause `2` and `3` are in `arr`.
### Example 2:
- Input: `arr = [1,1,3,3,5,5,7,7]`
- Output: `0`
- Explanation: No numbers are counted, cause there is no `2`, `4`, `6`, or `8` in `arr`.
### Constraints:
- `1 <= arr.length <= 1000`
- `0 <= arr[i] <= 1000`
## Solution
```js
/**
* @param {number[]} arr
* @return {number}
*/
var countElements = function(arr) {
let arrSet = new Set(arr);
let count = 0;
for (let n of arr) {
let sum = n + 1;
if (arrSet.has(sum)) {
count++;
}
}
return count;
};
```
@@ -1,44 +0,0 @@
---
title: '1480. Running Sum of 1d Array'
description: Given an array nums. We define a running sum of an array as runningSum[i] = sum(nums[0]&hellip;nums[i])
sidebar:
label: 'Running Sum of 1d Array'
badge: 'Easy'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `nums = [1,2,3,4]`
- Output: `[1,3,6,10]`
- Explanation: Running sum is obtained as follows: `[1, 1+2, 1+2+3, 1+2+3+4]`.
### Example 2:
- Input: `nums = [1,1,1,1,1]`
- Output: `[1,2,3,4,5]`
- Explanation: Running sum is obtained as follows: `[1, 1+1, 1+1+1, 1+1+1+1, 1+1+1+1+1]`.
### Example 3:
- Input: `nums = [3,1,2,10,1]`
- Output: `[3,4,6,16,17]`
### Constraints:
- `1 <= nums.length <= 1000`
- `-10^6 <= nums[i] <= 10^6`
## Solution
```js
/**
* @param {number[]} nums
* @return {number[]}
*/
var runningSum = function(nums) {
let prefix = [nums[0]];
for (let i = 1; i < nums.length; i++){
prefix.push(prefix[i - 1] + nums[i]);
}
return prefix;
};
```
-78
View File
@@ -1,78 +0,0 @@
---
title: '15. 3Sum'
description: Given an integer array nums, return all the triplets [nums[i], nums[j], nums[k]] such that i != j, i != k, and j != k, and nums[i] + nums[j] + nums[k] == 0
sidebar:
label: '3Sum'
badge: 'Medium'
---
<Badge variant="accent">Two Pointers</Badge>
::::warning
Notice that the solution set must not contain duplicate triplets.
::::
### Example 1:
- Input: `nums = [-1,0,1,2,-1,-4]`
- Output: `[[-1,-1,2],[-1,0,1]]`
- Explanation: `nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0`. `nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0`. `nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0`. The distinct triplets are `[-1,0,1]` and `[-1,-1,2]`. Notice that the order of the output and the order of the triplets does not matter.
### Example 2:
- Input: `nums = [0,1,1]`
- Output: `[]`
- Explanation: The only possible triplet does not sum up to `0`.
### Example 3:
- Input: `nums = [0,0,0]`
- Output: `[[0,0,0]]`
- Explanation: The only possible triplet sums up to `0`.
### Constraints:
- `3 <= nums.length <= 3000`
- `-10^5 <= nums[i] <= 10^5`
## Solution
```py
class Solution:
def threeSum(self, nums: list[int]) -> list[list[int]]:
nums.sort()
result = []
n = len(nums)
for i in range(n):
# skip all zero
if i > 0 and nums[i] == nums[i - 1]:
continue
# two pointers
left = i + 1
right = n - 1
target = -nums[i]
while left < right:
current = nums[left] + nums[right]
if current == target:
result.append([nums[i], nums[left], nums[right]])
# skip duplicates
while left < right and nums[left] == nums[left + 1]:
left += 1
while left < right and nums[right] == nums[right - 1]:
right -= 1
# shift pointers
left += 1
right -= 1
# since sorted, if current < target, then move left
elif current < target:
left += 1
# since sorted, if current > target, then move right
else:
right -= 1
return result
```
@@ -1,42 +0,0 @@
---
title: '1636. Sort Array by Increasing Frequency'
description: Given an array of integers nums, sort the array in increasing order based on the frequency of the values. If multiple values have the same frequency, sort them in decreasing order
sidebar:
label: 'Sort Array by Increasing Frequency'
badge: 'Easy'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `nums = [1,1,2,2,2,3]`
- Output: `[3,1,1,2,2,2]`
- Explanation: `'3'` has a frequency of `1`, `'1'` has a frequency of `2`, and `'2'` has a frequency of `3`.
### Example 2:
- Input: `nums = [2,3,1,3,2]`
- Output: `[1,3,3,2,2]`
- Explanation: `'2'` and `'3'` both have a frequency of `2`, so they are sorted in decreasing order.
### Example 3:
- Input: `nums = [-1,1,-6,4,5,-6,1,4,1]`
- Output: `[5,-1,4,4,-6,-6,1,1,1]`
### Constraints:
- `1 <= nums.length <= 100`
- `-100 <= nums[i] <= 100`
## Solution
```js
/**
* @param {number[]} nums
* @return {number[]}
*/
var frequencySort = function (nums) {
const freq = {};
for (let n of nums) freq[n] = (freq[n] || 0) + 1;
return nums.sort((a, b) => freq[a] - freq[b] || b - a);
};
```
@@ -1,55 +0,0 @@
---
title: '167. Two Sum II - Input Array Is Sorted'
description: Given a 1-indexed array of integers numbers that is already sorted in non-decreasing order, find two numbers such that they add up to a specific target number. Let these two numbers be numbers[index1] and numbers[index2] where 1 <= index1 < index2 <= numbers.length
sidebar:
label: 'Two Sum II - Input Array Is Sorted'
badge: 'Medium'
---
<Badge variant="accent">Two Pointers</Badge>
::::warning
Your solution must use only constant extra space.
::::
### Example 1:
- Input: `numbers = [2,7,11,15], target = 9`
- Output: `[1,2]`
- Explanation: The sum of `2` and `7` is `9`. Therefore, `index1 = 1`, `index2 = 2`. We return `[1, 2]`.
### Example 2:
- Input: `numbers = [2,3,4], target = 6`
- Output: `[1,3]`
- Explanation: The sum of `2` and `4` is `6`. Therefore `index1 = 1`, `index2 = 3`. We return `[1, 3]`.
### Example 3:
- Input: `numbers = [-1,0], target = -1`
- Output: `[1,2]`
- Explanation: The sum of `-1` and `0` is `-1`. Therefore `index1 = 1`, `index2 = 2`. We return `[1, 2]`.
### Constraints:
- `2 <= numbers.length <= 3 * 10^4`
- `-1000 <= numbers[i] <= 1000`
- `numbers` is sorted in non-decreasing order.
- `-1000 <= target <= 1000`
- The tests are generated such that there is exactly one solution.
## Solution
```py
class Solution:
def twoSum(self, numbers: List[int], target: int) -> List[int]:
i = 0
j = len(numbers) - 1
while i < j:
c = numbers[i] + numbers[j]
if c == target:
return [i + 1, j + 1]
elif c < target:
i+=1
else:
j-=1
return []
```
@@ -1,42 +0,0 @@
---
title: '189. Rotate Array'
description: Given an integer array nums, rotate the array to the right by k steps, where k is non-negative
sidebar:
label: 'Rotate Array'
badge: 'Medium'
---
<Badge variant="accent">Two Pointers</Badge>
::::warning
Try to come up with as many solutions as you can. There are at least three different ways to solve this problem. Could you do it in-place with `O(1)` extra space?
::::
### Example 1:
- Input: `nums = [1,2,3,4,5,6,7], k = 3`
- Output: `[5,6,7,1,2,3,4]`
- Explanation: rotate 1 steps to the right: `[7,1,2,3,4,5,6]` rotate 2 steps to the right: `[6,7,1,2,3,4,5]` rotate 3 steps to the right: `[5,6,7,1,2,3,4]`
### Example 2:
- Input: `nums = [-1,-100,3,99], k = 2`
- Output: `[3,99,-1,-100]`
- Explanation: rotate 1 steps to the right: `[99,-1,-100,3]` rotate 2 steps to the right: `[3,99,-1,-100]`
### Constraints:
- `1 <= nums.length <= 10^5`
- `-2^31 <= nums[i] <= 2^31 - 1`
- `0 <= k <= 10^5`
## Solution
```js
/**
* @param {number[]} nums
* @param {number} k
* @return {void} Do not return anything, modify nums in-place instead.
*/
var rotate = function(nums, k) {
};
```
@@ -1,50 +0,0 @@
---
title: '2090. K Radius Subarray Averages'
description: You are given a 0-indexed array nums of n integers, and an integer k
sidebar:
label: 'K Radius Subarray Averages'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `nums = [7,4,3,9,1,8,5,2,6], k = 3`
- Output: `[-1,-1,-1,5,4,4,-1,-1,-1]`
- Explanation:
- `avg[0]`, `avg[1]`, and `avg[2]` are `-1` because there are less than `k` elements before each index.
- The sum of the subarray centered at index `3` with radius `3` is: `7 + 4 + 3 + 9 + 1 + 8 + 5 = 37`. Using integer division, `avg[3] = 37 / 7 = 5`.
- For the subarray centered at index `4`, `avg[4] = (4 + 3 + 9 + 1 + 8 + 5 + 2) / 7 = 4`.
- For the subarray centered at index `5`, `avg[5] = (3 + 9 + 1 + 8 + 5 + 2 + 6) / 7 = 4`.
- `avg[6]`, `avg[7]`, and `avg[8]` are `-1` because there are less than `k` elements after each index.
### Example 2:
- Input: `nums = [100000], k = 0`
- Output: `[100000]`
- Explanation:
- The sum of the subarray centered at index `0` with radius `0` is: `100000`. `avg[0] = 100000 / 1 = 100000`.
### Example 3:
- Input: `nums = [8], k = 100000`
- Output: `[-1]`
- Explanation:
- `avg[0]` is `-1` because there are less than `k` elements before and after index `0`.
### Constraints:
- `n == nums.length`
- `1 <= n <= 10^5`
- `0 <= nums[i], k <= 10^5`
## Solution
```js
/**
* @param {number[]} nums
* @param {number} k
* @return {number[]}
*/
var getAverages = function(nums, k) {
};
```
@@ -1,47 +0,0 @@
---
title: '217. Contains Duplicate'
description: Given an integer array nums, return true if any value appears at least twice in the array, and return false if every element is distinct
sidebar:
label: 'Contains Duplicate'
badge: 'Easy'
---
<Badge variant="accent">Hash Table</Badge>
### Example 1:
- Input: `nums = [1,2,3,1]`
- Output: `true`
- Explanation: The element `1` occurs at the indices `0` and `3`.
### Example 2:
- Input: `nums = [1,2,3,4]`
- Output: `false`
- Explanation: All elements are distinct.
### Example 3:
- Input: `nums = [1,1,1,3,3,4,3,2,4,2]`
- Output: `true`
### Constraints:
- `1 <= nums.length <= 10^5`
- `-10^9 <= nums[i] <= 10^9`
## Solution
```js
/**
* @param {number[]} nums
* @return {boolean}
*/
var containsDuplicate = function(nums) {
const freq = {};
for (let n of nums) {
freq[n] = (freq[n] || 0) + 1;
if (freq[n] >= 2) {
return true;
}
}
return false;
};
```
@@ -1,53 +0,0 @@
---
title: '238. Product of Array Except Self'
description: Given an integer array nums, return an array answer such that answer[i] is equal to the product of all the elements of nums except nums[i]
sidebar:
label: 'Product of Array Except Self'
badge: 'Medium'
---
<Badge variant="accent">Prefix Sum</Badge>
::::warning
You must write an algorithm that runs in O(n) time and without using the division operation.
Can you solve the problem in O(1) extra space complexity? (The output array does not count as extra space for space complexity analysis.)
::::
### Example 1:
- Input: `nums = [1,2,3,4]`
- Output: `[24,12,8,6]`
### Example 2:
- Input: `nums = [-1,1,0,-3,3]`
- Output: `[0,0,9,0,0]`
### Constraints:
- `2 <= nums.length <= 10^5`
- `-30 <= nums[i] <= 30`
- The input is generated such that `answer[i]` is guaranteed to fit in a 32-bit integer.
## Solution
```js
/**
* @param {number[]} nums
* @return {number[]}
*/
var productExceptSelf = function(nums) {
const n = nums.length;
const answer = new Array(n).fill(1);
const rightArr = new Array(n).fill(1);
for (let i = 1; i < n; i++) {
answer[i] = nums[i - 1] * answer[i - 1];
}
for (let i = n - 2; i >= 0; i--) {
rightArr[i] = nums[i + 1] * rightArr[i + 1];
}
for (let i = 0; i < n; i++) {
answer[i] *= rightArr[i];
}
return answer;
};
```
@@ -1,55 +0,0 @@
---
title: '268. Missing Number'
description: Given an array nums containing n distinct numbers in the range [0, n], return the only number in the range that is missing from the array
sidebar:
label: 'Missing Number'
badge: 'Easy'
---
<Badge variant="accent">Array</Badge>
::::warning
Could you implement a solution using only O(1) extra space complexity and O(n) runtime complexity?
::::
### Example 1:
- Input: `nums = [3,0,1]`
- Output: `2`
- Explanation: `n = 3` since there are `3` numbers, so all numbers are in the range `[0,3]`. `2` is the missing number in the range since it does not appear in `nums`.
### Example 2:
- Input: `nums = [0,1]`
- Output: `2`
- Explanation: `n = 2` since there are `2` numbers, so all numbers are in the range `[0,2]`. `2` is the missing number in the range since it does not appear in `nums`.
### Example 3:
- Input: `nums = [9,6,4,2,3,5,7,0,1]`
- Output: `8`
- Explanation: `n = 9` since there are `9` numbers, so all numbers are in the range `[0,9]`. `8` is the missing number in the range since it does not appear in `nums`.
### Constraints:
- `n == nums.length`
- `1 <= n <= 10^4`
- `0 <= nums[i] <= n`
- All the numbers of `nums` are unique.
## Solution
```js
/**
* @param {number[]} nums
* @return {number}
*/
var missingNumber = function(nums) {
const numSet = new Set(nums);
const expectedCount = nums.length + 1;
for (let i = 0; i < expectedCount; i++){
if(!numSet.has(i)){
return i;
}
}
return -1;
};
```
@@ -1,52 +0,0 @@
---
title: '303. Range Sum Query - Immutable'
description: Given an integer array nums, handle multiple queries of the following type
sidebar:
label: 'Range Sum Query - Immutable'
badge: 'Easy'
---
<Badge variant="accent">Prefix Sum</Badge>
### Example 1:
- Input: `["NumArray", "sumRange", "sumRange", "sumRange"]` `[[[-2, 0, 3, -5, 2, -1]], [0, 2], [2, 5], [0, 5]]`
- Output: `[null, 1, -1, -3]`
- Explanation: `NumArray numArray = new NumArray([-2, 0, 3, -5, 2, -1]);` `numArray.sumRange(0, 2); // return (-2) + 0 + 3 = 1` `numArray.sumRange(2, 5); // return 3 + (-5) + 2 + (-1) = -1` `numArray.sumRange(0, 5); // return (-2) + 0 + 3 + (-5) + 2 + (-1) = -3`
### Constraints:
- `1 <= nums.length <= 10^4`
- `-10^5 <= nums[i] <= 10^5`
- `0 <= left <= right < nums.length`
- At most `10^4` calls will be made to `sumRange`.
## Solution
```js
/**
* @param {number[]} nums
*/
class NumArray {
constructor(nums) {
this.prefix = [0];
for (let n of nums){
this.prefix.push(this.prefix[this.prefix.length - 1] + n);
}
}
/**
* @param {number} left
* @param {number} right
* @return {number}
*/
sumRange(left, right) {
return this.prefix[right + 1] - this.prefix[left];
}
}
/**
* Your NumArray object will be instantiated and called as such:
* var obj = new NumArray(nums)
* var param_1 = obj.sumRange(left,right)
*/
```
@@ -1,71 +0,0 @@
---
title: '33. Search in Rotated Sorted Array'
description: There is an integer array nums sorted in ascending order (with distinct values)
sidebar:
label: 'Search in Rotated Sorted Array'
badge: 'Medium'
---
<Badge variant="accent">Binary Search</Badge>
::::warning
You must write an algorithm with O(log n) runtime complexity.
::::
### Example 1:
- Input: `nums = [4,5,6,7,0,1,2], target = 0`
- Output: `4`
### Example 2:
- Input: `nums = [4,5,6,7,0,1,2], target = 3`
- Output: `-1`
### Example 3:
- Input: `nums = [1], target = 0`
- Output: `-1`
### Constraints:
- `1 <= nums.length <= 5000`
- `-10^4 <= nums[i] <= 10^4`
- All values of `nums` are unique.
- `nums` is an ascending array that is possibly rotated.
- `-10^4 <= target <= 10^4`
## Solution
```js
/**
* @param {number[]} nums
* @param {number} target
* @return {number}
*/
var search = function(nums, target) {
let l = 0, r = nums.length - 1;
while (l <= r){
let mid = Math.floor((l + r)/2);
if (target === nums[mid]) {
return mid
}
// Left sorted portion
if (nums[l] <= nums[mid]){
if (target > nums[mid] || target < nums[l]){
l = mid + 1;
}else{
r = mid - 1;
}
}
// Right sorted portion
else{
if(target < nums[mid] || target > nums[r]){
r = mid - 1;
}else{
l = mid + 1;
}
}
}
return -1;
};
```
@@ -1,51 +0,0 @@
---
title: '347. Top K Frequent Elements'
description: Given an integer array nums and an integer k, return the k most frequent elements. You may return the answer in any order
sidebar:
label: 'Top K Frequent Elements'
badge: 'Medium'
---
<Badge variant="accent">Hash Table</Badge>
::::warning
Your algorithm's time complexity must be better than O(n log n), where n is the array's size.
::::
### Example 1:
- Input: `nums = [1,1,1,2,2,3], k = 2`
- Output: `[1,2]`
### Example 2:
- Input: `nums = [1], k = 1`
- Output: `[1]`
### Example 3:
- Input: `nums = [1,2,1,2,1,2,3,1,3,2], k = 2`
- Output: `[1,2]`
### Constraints:
- `1 <= nums.length <= 10^5`
- `-10^4 <= nums[i] <= 10^4`
- `k` is in the range `[1, the number of unique elements in the array]`.
- It is guaranteed that the answer is unique.
## Solution
```js
/**
* @param {number[]} nums
* @param {number} k
* @return {number[]}
*/
var topKFrequent = function (nums, k) {
const freq = {};
for (let n of nums) freq[n] = (freq[n] || 0) + 1;
return Object.keys(freq)
.map(Number)
.sort((a, b) => freq[b] - freq[a])
.slice(0, k);
};
```
@@ -1,36 +0,0 @@
---
title: '42. Trapping Rain Water'
description: Given n non-negative integers representing an elevation map where the width of each bar is 1, compute how much water it can trap after raining
sidebar:
label: 'Trapping Rain Water'
badge: 'Hard'
---
<Badge variant="accent">Two Pointers</Badge>
### Example 1:
- Input: `height = [0,1,0,2,1,0,1,3,2,1,2,1]`
- Output: `6`
- Explanation: The above elevation map (black section) is represented by array `[0,1,0,2,1,0,1,3,2,1,2,1]`. In this case, `6` units of rain water (blue section) are being trapped.
### Example 2:
- Input: `height = [4,2,0,3,2,5]`
- Output: `9`
### Constraints:
- `n == height.length`
- `1 <= n <= 2 * 10^4`
- `0 <= height[i] <= 10^5`
## Solution
```js
/**
* @param {number[]} height
* @return {number}
*/
var trap = function(height) {
};
```
@@ -1,51 +0,0 @@
---
title: '49. Group Anagrams'
description: Given an array of strings strs, group the anagrams together. You can return the answer in any order
sidebar:
label: 'Group Anagrams'
badge: 'Medium'
---
<Badge variant="accent">Hash Table</Badge>
### Example 1:
- Input: `strs = ["eat","tea","tan","ate","nat","bat"]`
- Output: `[["bat"],["nat","tan"],["ate","eat","tea"]]`
- Explanation: There is no string in `strs` that can be rearranged to form `"bat"`. The strings `"nat"` and `"tan"` are anagrams as they can be rearranged to form each other. The strings `"ate"`, `"eat"`, and `"tea"` are anagrams as they can be rearranged to form each other.
### Example 2:
- Input: `strs = [""]`
- Output: `[[""]]`
### Example 3:
- Input: `strs = ["a"]`
- Output: `[["a"]]`
### Constraints:
- `1 <= strs.length <= 10^4`
- `0 <= strs[i].length <= 100`
- `strs[i]` consists of lowercase English letters.
## Solution
```js
/**
* @param {string[]} strs
* @return {string[][]}
*/
var groupAnagrams = function(strs) {
let sorted = strs.map(str => str.split("").sort().join(""));
let anagrams = {};
for (let i = 0; i < strs.length; i++){
if(!anagrams[sorted[i]]){
anagrams[sorted[i]] = [strs[i]]
}else{
anagrams[sorted[i]].push(strs[i])
}
}
return Object.values(anagrams);
};
```
@@ -1,51 +0,0 @@
---
title: '560. Subarray Sum Equals K'
description: Given an array of integers nums and an integer k, return the total number of subarrays whose sum equals to k
sidebar:
label: 'Subarray Sum Equals K'
badge: 'Medium'
---
<Badge variant="accent">Prefix Sum</Badge>
### Example 1:
- Input: `nums = [1,1,1], k = 2`
- Output: `2`
### Example 2:
- Input: `nums = [1,2,3], k = 3`
- Output: `2`
### Constraints:
- `1 <= nums.length <= 2 * 10^4`
- `-1000 <= nums[i] <= 1000`
- `-10^7 <= k <= 10^7`
## Solution
```js
/**
* @param {number[]} nums
* @param {number} k
* @return {number}
*/
var subarraySum = function(nums, k) {
const map = new Map();
map.set(0, 1);
let sum = 0;
let count = 0;
for (let n of nums){
sum += n;
if(map.has(sum - k)){
count += map.get(sum - k);
}
map.set(sum, (map.get(sum) || 0) + 1);
}
return count
};
```
@@ -1,56 +0,0 @@
---
title: '704. Binary Search'
description: Given an array of integers nums which is sorted in ascending order, and an integer target, write a function to search target in nums. If target exists, then return its index. Otherwise, return -1
sidebar:
label: 'Binary Search'
badge: 'Easy'
---
<Badge variant="accent">Binary Search</Badge>
::::warning
You must write an algorithm with O(log n) runtime complexity.
::::
### Example 1:
- Input: `nums = [-1,0,3,5,9,12], target = 9`
- Output: `4`
- Explanation: `9` exists in `nums` and its index is `4`
### Example 2:
- Input: `nums = [-1,0,3,5,9,12], target = 2`
- Output: `-1`
- Explanation: `2` does not exist in `nums` so return `-1`
### Constraints:
- `1 <= nums.length <= 10^4`
- `-10^4 < nums[i], target < 10^4`
- All the integers in `nums` are unique.
- `nums` is sorted in ascending order.
## Solution
```js
/**
* @param {number[]} nums
* @param {number} target
* @return {number}
*/
var search = function(nums, target) {
let left = 0;
let right = nums.length - 1;
while(left <= right) {
const mid = left + Math.floor((right - left) / 2);
if (nums[mid] === target) {
return mid;
} else if(nums[mid] < target){
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
}
}
return -1;
};
```
@@ -1,62 +0,0 @@
---
title: '875. Koko Eating Bananas'
description: Koko loves to eat bananas. There are n piles of bananas, the i^th pile has piles[i] bananas. The guards have gone and will come back in h hours
sidebar:
label: 'Koko Eating Bananas'
badge: 'Medium'
---
<Badge variant="accent">Binary Search</Badge>
### Example 1:
- Input: `piles = [3,6,7,11], h = 8`
- Output: `4`
### Example 2:
- Input: `piles = [30,11,23,4,20], h = 5`
- Output: `30`
### Example 3:
- Input: `piles = [30,11,23,4,20], h = 6`
- Output: `23`
### Constraints:
- `1 <= piles.length <= 10^4`
- `piles.length <= h <= 10^9`
- `1 <= piles[i] <= 10^9`
## Solution
```js
/**
* @param {number[]} piles
* @param {number} h
* @return {number}
*/
var minEatingSpeed = function(piles, h) {
// Function to determine if K works for the H
function kWorks(k){
let hours = 0;
for (let p of piles){
hours += Math.ceil(p/k)
}
return hours <= h;
}
// Setup Binary Search
let l = 1;
let r = Math.max(...piles);
// Find the K value which works for H and consumes all bananas
while (l < r){
const mid = Math.floor((l + r) / 2);
if (kWorks(mid)){
r = mid;
}else{
l = mid + 1;
}
}
return l;
};
```
@@ -1,55 +0,0 @@
---
title: '977. Squares of a Sorted Array'
description: Given an integer array nums sorted in non-decreasing order, return an array of the squares of each number sorted in non-decreasing order
sidebar:
label: 'Squares of a Sorted Array'
badge: 'Easy'
---
<Badge variant="accent">Two Pointers</Badge>
::::warning
Squaring each element and sorting the new array is very trivial, could you find an O(n) solution using a different approach?
::::
### Example 1:
- Input: `nums = [-4,-1,0,3,10]`
- Output: `[0,1,9,16,100]`
- Explanation: After squaring, the array becomes `[16,1,0,9,100]`. After sorting, it becomes `[0,1,9,16,100]`.
### Example 2:
- Input: `nums = [-7,-3,2,3,11]`
- Output: `[4,9,9,49,121]`
### Constraints:
- `1 <= nums.length <= 10^4`
- `-10^4 <= nums[i] <= 10^4`
- `nums` is sorted in non-decreasing order.
## Solution
```js
/**
* @param {number[]} nums
* @return {number[]}
*/
var sortedSquares = function(nums) {
let n = nums.length;
let ans = new Array(nums.length);
let left = 0, right = nums.length - 1;
for (let i = n -1; i >= 0; i--){
let square;
if(Math.abs(nums[left]) < Math.abs(nums[right])){
square = nums[right];
right--;
}else{
square = nums[left];
left++;
}
ans[i] = square*square;
}
return ans;
};
```
@@ -1,41 +0,0 @@
---
title: '1832. Check if the Sentence Is Pangram'
description: A pangram is a sentence where every letter of the English alphabet appears at least once
sidebar:
label: 'Check if the Sentence Is Pangram'
badge: 'Easy'
---
<Badge variant="accent">Hash Table</Badge>
### Example 1:
- Input: `sentence = "thequickbrownfoxjumpsoverthelazydog"`
- Output: `true`
- Explanation: `sentence` contains at least one of every letter of the English alphabet.
### Example 2:
- Input: `sentence = "leetcode"`
- Output: `false`
### Constraints:
- `1 <= sentence.length <= 1000`
- `sentence` consists of lowercase English letters.
## Solution
```js
/**
* @param {string} sentence
* @return {boolean}
*/
var checkIfPangram = function(sentence) {
const freq = {};
for (let c of sentence) freq[c] = (freq[c] || 0) + 1;
if (Object.keys(freq).length === 26){
return true;
}
return false;
};
```
@@ -1,48 +0,0 @@
---
title: '242. Valid Anagram'
description: Given two strings s and t, return true if t is an anagram of s, and false otherwise
sidebar:
label: 'Valid Anagram'
badge: 'Easy'
---
<Badge variant="accent">Hash Table</Badge>
::::warning
What if the inputs contain Unicode characters? How would you adapt your solution to such a case?
::::
### Example 1:
- Input: `s = "anagram", t = "nagaram"`
- Output: `true`
### Example 2:
- Input: `s = "rat", t = "car"`
- Output: `false`
### Constraints:
- `1 <= s.length, t.length <= 5 * 10^4`
- `s` and `t` consist of lowercase English letters.
## Solution
```js
/**
* @param {string} s
* @param {string} t
* @return {boolean}
*/
var isAnagram = function(s, t) {
let freq = {};
for (let c of s) freq[c] = (freq[c] || 0) + 1;
for (let c of t) {
if(!freq[c] || freq[c] === 0){
return false;
}
freq[c] = freq[c] - 1;
}
return true;
};
```
@@ -1,47 +0,0 @@
---
title: '387. First Unique Character in a String'
description: Given a string s, find the first non-repeating character in it and return its index. If it does not exist, return -1
sidebar:
label: 'First Unique Character in a String'
badge: 'Easy'
---
<Badge variant="accent">Hash Table</Badge>
### Example 1:
- Input: `s = "leetcode"`
- Output: `0`
- Explanation: The character `'l'` at index `0` is the first character that does not occur at any other index.
### Example 2:
- Input: `s = "loveleetcode"`
- Output: `2`
### Example 3:
- Input: `s = "aabb"`
- Output: `-1`
### Constraints:
- `1 <= s.length <= 10^5`
- `s` consists of only lowercase English letters.
## Solution
```js
/**
* @param {string} s
* @return {number}
*/
var firstUniqChar = function (s) {
const freq = {};
for (let c of s) freq[c] = (freq[c] || 0) + 1;
for (let i = 0; i < s.length; i++) {
if (freq[s[i]] === 1) {
return i;
}
}
return -1;
};
```
@@ -1,49 +0,0 @@
---
title: '451. Sort Characters By Frequency'
description: Given a string s, sort it in decreasing order based on the frequency of the characters. The frequency of a character is the number of times it appears in the string
sidebar:
label: 'Sort Characters By Frequency'
badge: 'Medium'
---
<Badge variant="accent">Hash Table</Badge>
### Example 1:
- Input: `s = "tree"`
- Output: `"eert"`
- Explanation: `'e'` appears twice while `'r'` and `'t'` both appear once. So `'e'` must appear before both `'r'` and `'t'`. Therefore `"eetr"` is also a valid answer.
### Example 2:
- Input: `s = "cccaaa"`
- Output: `"aaaccc"`
- Explanation: Both `'c'` and `'a'` appear three times, so both `"cccaaa"` and `"aaaccc"` are valid answers. Note that `"cacaca"` is incorrect, as the same characters must be together.
### Example 3:
- Input: `s = "Aabb"`
- Output: `"bbAa"`
- Explanation: `"bbaA"` is also a valid answer, but `"Aabb"` is incorrect. Note that `'A'` and `'a'` are treated as two different characters.
### Constraints:
- `1 <= s.length <= 5 * 10^5`
- `s` consists of uppercase and lowercase English letters and digits.
## Solution
```js
/**
* @param {string} s
* @return {string}
*/
var frequencySort = function (s) {
// count the frequency of each character
const freq = {};
for (let c of s) freq[c] = (freq[c] || 0) + 1;
// sort the characters by frequency
return s
.split("")
.sort((a, b) => freq[b] - freq[a] || a.localeCompare(b))
.join("");
};
```
@@ -1,51 +0,0 @@
---
title: '125. Valid Palindrome'
description: A phrase is a palindrome if, after converting all uppercase letters into lowercase letters and removing all non-alphanumeric characters, it reads the same forward and backward. Alphanumeric characters include letters and numbers
sidebar:
label: 'Valid Palindrome'
badge: 'Easy'
---
<Badge variant="accent">Two Pointers</Badge>
### Example 1:
- Input: `s = "A man, a plan, a canal: Panama"`
- Output: `true`
- Explanation: `"amanaplanacanalpanama"` is a palindrome.
### Example 2:
- Input: `s = "race a car"`
- Output: `false`
- Explanation: `"raceacar"` is not a palindrome.
### Example 3:
- Input: `s = " "`
- Output: `true`
- Explanation: `s` is an empty string `""` after removing non-alphanumeric characters. Since an empty string reads the same forward and backward, it is a palindrome.
### Constraints:
- `1 <= s.length <= 2 * 10^5`
- `s` consists only of printable ASCII characters.
## Solution
```js
/**
* @param {string} s
* @return {boolean}
*/
var isPalindrome = function(s) {
let normal = s.replace(/[^a-zA-Z0-9]/g, "").toLowerCase()
let i = 0, j = normal.length - 1;
while (i < j) {
if(normal[i] !== normal[j]) {
return false;
}
i++;
j--;
}
return true;
};
```
@@ -1,45 +0,0 @@
---
title: '344. Reverse String'
description: Write a function that reverses a string. The input string is given as an array of characters s
sidebar:
label: 'Reverse String'
badge: 'Easy'
---
<Badge variant="accent">Two Pointers</Badge>
:::warning
You must do this by modifying the input array in-place with O(1) extra memory.
:::
### Example 1:
- Input: `s = ["h","e","l","l","o"]`
- Output: `["o","l","l","e","h"]`
### Example 2:
- Input: `s = ["H","a","n","n","a","h"]`
- Output: `["h","a","n","n","a","H"]`
### Constraints:
- `1 <= s.length <= 10^5`
- `s[i]` is a printable ascii character.
## Solution
```js
/**
* @param {character[]} s
* @return {void} Do not return anything, modify s in-place instead.
*/
var reverseString = function(s) {
let i = 0;
let j = s.length - 1;
while (i < j) {
[s[i], s[j]] = [s[j], s[i]];
j--;
i++;
}
};
```
@@ -1,44 +0,0 @@
---
title: '392. Is Subsequence'
description: Given two strings s and t, return true if s is a subsequence of t, or false otherwise
sidebar:
label: 'Is Subsequence'
badge: 'Easy'
---
<Badge variant="accent">Two Pointers</Badge>
::::warning
Suppose there are lots of incoming s, say s1, s2, ..., sk where k >= 10^9, and you want to check one by one to see if t has its subsequence. In this scenario, how would you change your code?
::::
### Example 1:
- Input: `s = "abc", t = "ahbgdc"`
- Output: `true`
### Example 2:
- Input: `s = "axc", t = "ahbgdc"`
- Output: `false`
### Constraints:
- `0 <= s.length <= 100`
- `0 <= t.length <= 10^4`
- `s` and `t` consist only of lowercase English letters.
## Solution
```py
class Solution:
def isSubsequence(self, s: str, t: str) -> bool:
if len(s) > len(t):
return False
i, j = 0, 0
while i < len(s) and j < len(t):
if s[i] == t[j]:
i += 1
j += 1
return i == len(s)
```
-351
View File
@@ -1,351 +0,0 @@
/**
* 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<Stage, number>;
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<string, unknown>;
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 01 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 (
<span
style={{
display: "inline-block",
width: "10rem",
maxWidth: "40vw",
height: "0.5rem",
borderRadius: "var(--blume-radius)",
background: "var(--blume-muted)",
verticalAlign: "middle",
overflow: "hidden",
}}
>
<span
style={{
display: "block",
width: `${ratio * 100}%`,
height: "100%",
background: "var(--blume-accent)",
}}
/>
</span>
);
}
// ── sections ─────────────────────────────────────────────────────
function Header({ stats }: { stats: Stats }) {
const { campaign, streak } = stats;
return (
<div style={{ ...card, display: "flex", gap: "1.5rem", flexWrap: "wrap" }}>
<span>
<strong>
Day {campaign.day}/{campaign.days}
</strong>
</span>
<span>Week {campaign.week}</span>
<span>
Streak: <strong>{streak}</strong> day{streak === 1 ? "" : "s"}
</span>
<span style={muted}>started {campaign.start}</span>
</div>
);
}
function Phases({ stats }: { stats: Stats }) {
return (
<div style={card}>
<div style={heading}>Phases</div>
<table style={{ borderCollapse: "collapse", width: "100%" }}>
<tbody>
{stats.phases.map((p) => (
<tr key={p.milestone}>
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>{p.name}</td>
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
<Bar done={p.core_done} total={p.core_total} />{" "}
<span style={muted}>
core {p.core_done}/{p.core_total}
</span>
</td>
<td style={{ padding: "0.2rem 0" }}>
<span style={muted}>
optional {p.optional_done}/{p.optional_total}
{p.deferred_total > 0 &&
` · deferred ${p.deferred_done}/${p.deferred_total}`}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function Ladder({ stats }: { stats: Stats }) {
return (
<div style={card}>
<div style={heading}>SRS ladder</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{STAGES.map((stage) => (
<span
key={stage}
style={{
background: "var(--blume-muted)",
borderRadius: "var(--blume-radius)",
padding: "0.35rem 0.75rem",
}}
>
<span style={muted}>{stage}</span>{" "}
<strong>{stats.ladder[stage] ?? 0}</strong>
</span>
))}
</div>
</div>
);
}
function Queue({ stats }: { stats: Stats }) {
const max = Math.max(1, ...stats.queue.map((q) => q.due));
return (
<div style={card}>
<div style={heading}>Review queue next 14 days</div>
<table style={{ borderCollapse: "collapse" }}>
<tbody>
{stats.queue.map((q) => (
<tr key={q.date}>
<td style={{ ...muted, padding: "0.1rem 1rem 0.1rem 0" }}>
{dayLabel(q.date)}
</td>
<td style={{ padding: "0.1rem 0.75rem 0.1rem 0" }}>
<Bar done={q.due} total={max} />
</td>
<td>{q.due}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function Gates({ stats }: { stats: Stats }) {
return (
<div style={card}>
<div style={heading}>Gate log</div>
{stats.gates.length === 0 ? (
<span style={muted}>No gates yet.</span>
) : (
<table style={{ borderCollapse: "collapse", width: "100%" }}>
<thead>
<tr>
{["Week", "Issue", "Pass rate", "Closed"].map((h) => (
<th key={h} style={{ ...muted, textAlign: "left", padding: "0.2rem 1rem 0.2rem 0" }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{stats.gates.map((g) => (
<tr key={g.week}>
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>{g.week}</td>
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
{g.issue === null ? (
<span style={muted}></span>
) : (
<a
href={`https://github.com/prdlk/leetcode/issues/${g.issue}`}
target="_blank"
rel="noreferrer"
>
#{g.issue}
</a>
)}
</td>
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
{g.pass_rate === null ? (
<span style={muted}>open</span>
) : (
percent(g.pass_rate)
)}
</td>
<td style={{ padding: "0.2rem 0" }}>
{g.closed_on ?? <span style={muted}></span>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
function Recent({ stats }: { stats: Stats }) {
return (
<div style={card}>
<div style={heading}>Last 7 days</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{stats.recent.map((r) => (
<span
key={r.date}
style={{
border: "1px solid var(--blume-border)",
borderRadius: "var(--blume-radius)",
padding: "0.35rem 0.6rem",
textAlign: "center",
}}
>
<span style={{ ...muted, display: "block" }}>{dayLabel(r.date)}</span>
<strong>{r.passes}</strong>
<span style={muted}>/{r.attempts}</span>
</span>
))}
</div>
<div style={{ ...muted, marginTop: "0.4rem" }}>passes / attempts</div>
</div>
);
}
// ── dashboard ────────────────────────────────────────────────────
type Load =
| { phase: "loading" }
| { phase: "error" }
| { phase: "ready"; stats: Stats };
export default function ProgressDashboard() {
const [load, setLoad] = useState<Load>({ 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 <p style={muted}>Loading progress</p>;
}
if (load.phase === "error") {
return (
<p style={muted}>
API unreachable stats are served live from the SRS Worker and it did
not respond.
</p>
);
}
const { stats } = load;
return (
<div>
<Header stats={stats} />
<Phases stats={stats} />
<Ladder stats={stats} />
<Queue stats={stats} />
<Gates stats={stats} />
<Recent stats={stats} />
<p style={muted}>Generated {stats.generated}</p>
</div>
);
}
-305
View File
@@ -1,305 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LeetCode Dependency Spine</title>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--color-paper: #020202;
--color-ink: #f2f2f2;
--color-muted: #989898;
--color-accent: #449df0;
--font-sans: 'Inter', system-ui, sans-serif;
--font-serif: 'Instrument Serif', serif;
--font-mono: 'IBM Plex Mono', ui-monospace, monospace;
}
body {
font-family: var(--font-sans);
background: var(--color-paper);
color: var(--color-ink);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
}
.frame { max-width: 1360px; width: 100%; }
.eyebrow {
font-family: var(--font-mono);
font-size: 0.66rem;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-muted);
margin-bottom: 0.5rem;
}
h1 {
font-family: var(--font-serif);
font-size: clamp(1.5rem, 2.4vw + 0.75rem, 2rem);
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.15;
color: var(--color-ink);
margin-bottom: 1.5rem;
}
svg { width: 100%; min-width: 900px; display: block; }
</style>
</head>
<body>
<div class="frame">
<p class="eyebrow">prdlk / leetcode · dependency spine</p>
<h1>How the 24 topics build on each other</h1>
<svg viewBox="0 0 1472 648" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="dependency-spine-title dependency-spine-desc">
<title id="dependency-spine-title">LeetCode topic dependency spine</title>
<desc id="dependency-spine-desc">Dependency graph of 24 LeetCode study topics across five phases, showing which topics build on which, with Tree DFS and 1-D dynamic programming as the two central hubs.</desc>
<defs>
<marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#989898"/></marker>
<marker id="arrow-accent" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#449df0"/></marker>
<marker id="arrow-link" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#6ab0f5"/></marker>
</defs>
<rect width="100%" height="100%" fill="#020202"/>
<!-- ============ zones (painted first) ============ -->
<rect x="40" y="40" width="368" height="312" rx="8" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
<rect x="56" y="44" width="168" height="12" rx="2" fill="#020202"/>
<text x="60" y="53" fill="rgba(242,242,242,0.40)" font-size="7" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">PHASE I — LINEAR STRUCTURES</text>
<rect x="456" y="40" width="368" height="312" rx="8" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
<rect x="472" y="44" width="152" height="12" rx="2" fill="#020202"/>
<text x="476" y="53" fill="rgba(242,242,242,0.40)" font-size="7" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">PHASE III — HIERARCHICAL</text>
<rect x="456" y="384" width="368" height="168" rx="8" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
<rect x="472" y="388" width="144" height="12" rx="2" fill="#020202"/>
<text x="476" y="397" fill="rgba(242,242,242,0.40)" font-size="7" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">PHASE II — NODAL &amp; GRID</text>
<rect x="872" y="40" width="560" height="312" rx="8" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
<rect x="888" y="44" width="264" height="12" rx="2" fill="#020202"/>
<text x="892" y="53" fill="rgba(242,242,242,0.40)" font-size="7" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">PHASES IVV — RELATIONAL &amp; DECISION-SPACE</text>
<!-- ============ arrows (before boxes) ============ -->
<!-- same-row horizontals -->
<line x1="200" y1="168" x2="248" y2="168" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 3 → 4 -->
<line x1="200" y1="240" x2="248" y2="240" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 6 → 7 -->
<line x1="200" y1="312" x2="248" y2="312" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 1 → 2 -->
<line x1="616" y1="440" x2="664" y2="440" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 8 → 9 -->
<line x1="1032" y1="96" x2="1080" y2="96" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 16 → 18 -->
<line x1="1224" y1="240" x2="1272" y2="240" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 21 → 22 -->
<line x1="1224" y1="312" x2="1272" y2="312" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 23 → 24 -->
<line x1="616" y1="84" x2="888" y2="84" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 11 → 16 -->
<line x1="616" y1="312" x2="888" y2="312" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 12 → 17 -->
<!-- same-column verticals -->
<line x1="544" y1="120" x2="544" y2="144" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 11 → 13 -->
<line x1="960" y1="120" x2="960" y2="144" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 16 → 19 -->
<line x1="1152" y1="120" x2="1152" y2="144" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 18 → 20 -->
<line x1="1152" y1="192" x2="1152" y2="216" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 20 → 21 -->
<line x1="1152" y1="264" x2="1152" y2="288" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 21 → 23 -->
<!-- rounded right-angle elbows -->
<path d="M200,96 H416 Q424,96 424,104 V160 Q424,168 432,168 H472" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 5 → 13 -->
<path d="M616,96 H640 Q648,96 648,104 V160 Q648,168 656,168 H664" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 11 → 14 -->
<path d="M616,108 H624 Q632,108 632,116 V232 Q632,240 640,240 H664" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 11 → 15 -->
<path d="M392,184 H432 Q440,184 440,192 V504 Q440,512 448,512 H472" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 4 → 10 -->
<path d="M128,336 V560 Q128,568 136,568 H728 Q736,568 736,560 V464" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 1 → 9 -->
<!-- ============ nodes ============ -->
<!-- 05 Binary Search — entry -->
<rect x="56" y="72" width="144" height="48" rx="6" fill="#020202"/>
<rect x="56" y="72" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
<rect x="64" y="78" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="76" y="87" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">05</text>
<text x="128" y="104" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Binary Search</text>
<!-- 03 Two Pointers — entry -->
<rect x="56" y="144" width="144" height="48" rx="6" fill="#020202"/>
<rect x="56" y="144" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
<rect x="64" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="76" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">03</text>
<text x="128" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Two Pointers</text>
<!-- 04 Sliding Window -->
<rect x="248" y="144" width="144" height="48" rx="6" fill="#020202"/>
<rect x="248" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="256" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="268" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">04</text>
<text x="320" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Sliding Window</text>
<!-- 06 Stack — entry -->
<rect x="56" y="216" width="144" height="48" rx="6" fill="#020202"/>
<rect x="56" y="216" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
<rect x="64" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="76" y="231" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">06</text>
<text x="128" y="248" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Stack</text>
<!-- 07 Monotonic Stack -->
<rect x="248" y="216" width="144" height="48" rx="6" fill="#020202"/>
<rect x="248" y="216" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="256" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="268" y="231" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">07</text>
<text x="320" y="248" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Monotonic Stack</text>
<!-- 01 Hash-Based Lookup — entry -->
<rect x="56" y="288" width="144" height="48" rx="6" fill="#020202"/>
<rect x="56" y="288" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
<rect x="64" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="76" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">01</text>
<text x="128" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Hash Lookup</text>
<!-- 02 Prefix Sum -->
<rect x="248" y="288" width="144" height="48" rx="6" fill="#020202"/>
<rect x="248" y="288" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="256" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="268" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">02</text>
<text x="320" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Prefix Sum</text>
<!-- 11 Tree DFS — FOCAL -->
<rect x="472" y="72" width="144" height="48" rx="6" fill="#020202"/>
<rect x="472" y="72" width="144" height="48" rx="6" fill="rgba(68,157,240,0.12)" stroke="#449df0" stroke-width="1.2"/>
<rect x="480" y="78" width="24" height="12" rx="2" fill="transparent" stroke="rgba(68,157,240,0.50)" stroke-width="0.8"/>
<text x="492" y="87" fill="#449df0" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">11</text>
<text x="544" y="104" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Tree DFS</text>
<!-- 13 Binary Search Tree -->
<rect x="472" y="144" width="144" height="48" rx="6" fill="#020202"/>
<rect x="472" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="480" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="492" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">13</text>
<text x="544" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Binary Search Tree</text>
<!-- 14 Heap -->
<rect x="664" y="144" width="144" height="48" rx="6" fill="#020202"/>
<rect x="664" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="672" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="684" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">14</text>
<text x="736" y="170" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Heap</text>
<text x="736" y="184" fill="#989898" font-size="9" font-family="'IBM Plex Mono', monospace" text-anchor="middle">priority queue</text>
<!-- 15 Trie -->
<rect x="664" y="216" width="144" height="48" rx="6" fill="#020202"/>
<rect x="664" y="216" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="672" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="684" y="231" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">15</text>
<text x="736" y="248" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Trie</text>
<!-- 12 Tree BFS — entry -->
<rect x="472" y="288" width="144" height="48" rx="6" fill="#020202"/>
<rect x="472" y="288" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
<rect x="480" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="492" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">12</text>
<text x="544" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Tree BFS</text>
<!-- 08 Linked List — entry -->
<rect x="472" y="416" width="144" height="48" rx="6" fill="#020202"/>
<rect x="472" y="416" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
<rect x="480" y="422" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="492" y="431" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">08</text>
<text x="544" y="448" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Linked List</text>
<!-- 09 Hybrid Structures -->
<rect x="664" y="416" width="144" height="48" rx="6" fill="#020202"/>
<rect x="664" y="416" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="672" y="422" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="684" y="431" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">09</text>
<text x="736" y="448" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Hybrid Structures</text>
<!-- 10 Matrix Index Math -->
<rect x="472" y="488" width="144" height="48" rx="6" fill="#020202"/>
<rect x="472" y="488" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="480" y="494" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="492" y="503" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">10</text>
<text x="544" y="520" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Matrix Index Math</text>
<!-- 16 Graph DFS -->
<rect x="888" y="72" width="144" height="48" rx="6" fill="#020202"/>
<rect x="888" y="72" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="896" y="78" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="908" y="87" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">16</text>
<text x="960" y="104" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Graph DFS</text>
<!-- 18 Topological Sort -->
<rect x="1080" y="72" width="144" height="48" rx="6" fill="#020202"/>
<rect x="1080" y="72" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="1088" y="78" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="1100" y="87" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">18</text>
<text x="1152" y="104" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Topological Sort</text>
<!-- 19 Union-Find -->
<rect x="888" y="144" width="144" height="48" rx="6" fill="#020202"/>
<rect x="888" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="896" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="908" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">19</text>
<text x="960" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Union-Find</text>
<!-- 20 Backtracking -->
<rect x="1080" y="144" width="144" height="48" rx="6" fill="#020202"/>
<rect x="1080" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="1088" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="1100" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">20</text>
<text x="1152" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Backtracking</text>
<!-- 21 1-D DP — FOCAL -->
<rect x="1080" y="216" width="144" height="48" rx="6" fill="#020202"/>
<rect x="1080" y="216" width="144" height="48" rx="6" fill="rgba(68,157,240,0.12)" stroke="#449df0" stroke-width="1.2"/>
<rect x="1088" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(68,157,240,0.50)" stroke-width="0.8"/>
<text x="1100" y="231" fill="#449df0" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">21</text>
<text x="1152" y="242" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">1-D DP</text>
<text x="1152" y="256" fill="#989898" font-size="9" font-family="'IBM Plex Mono', monospace" text-anchor="middle">dynamic programming</text>
<!-- 22 Multi-D / Grid DP -->
<rect x="1272" y="216" width="144" height="48" rx="6" fill="#020202"/>
<rect x="1272" y="216" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="1280" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="1292" y="231" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">22</text>
<text x="1344" y="242" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Multi-D / Grid DP</text>
<!-- 17 Graph BFS -->
<rect x="888" y="288" width="144" height="48" rx="6" fill="#020202"/>
<rect x="888" y="288" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="896" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="908" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">17</text>
<text x="960" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Graph BFS</text>
<!-- 23 Greedy -->
<rect x="1080" y="288" width="144" height="48" rx="6" fill="#020202"/>
<rect x="1080" y="288" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="1088" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="1100" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">23</text>
<text x="1152" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Greedy</text>
<!-- 24 Intervals -->
<rect x="1272" y="288" width="144" height="48" rx="6" fill="#020202"/>
<rect x="1272" y="288" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="1280" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="1292" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">24</text>
<text x="1344" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Intervals</text>
<!-- ============ legend ============ -->
<line x1="40" y1="600" x2="1432" y2="600" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
<text x="40" y="620" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">LEGEND</text>
<rect x="160" y="608" width="20" height="12" rx="2" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
<text x="188" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">ENTRY — NO PREREQS</text>
<rect x="360" y="608" width="20" height="12" rx="2" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<text x="388" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">TOPIC</text>
<rect x="480" y="608" width="20" height="12" rx="2" fill="rgba(68,157,240,0.12)" stroke="#449df0" stroke-width="1.2"/>
<text x="508" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">FOCAL HUB</text>
<line x1="624" y1="614" x2="656" y2="614" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/>
<text x="668" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">BUILDS ON</text>
<rect x="792" y="608" width="20" height="12" rx="2" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
<text x="820" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">PHASE ZONE</text>
</svg>
</div>
</body>
</html>
-244
View File
@@ -1,244 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 1472 648" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="dependency-spine-title dependency-spine-desc">
<title id="dependency-spine-title">LeetCode topic dependency spine</title>
<desc id="dependency-spine-desc">Dependency graph of 24 LeetCode study topics across five phases, showing which topics build on which, with Tree DFS and 1-D dynamic programming as the two central hubs.</desc>
<defs>
<style>@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&amp;family=Inter:wght@400;500;600&amp;family=IBM+Plex+Mono:wght@400;500;600&amp;display=swap');</style>
<marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#989898"/></marker>
<marker id="arrow-accent" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#449df0"/></marker>
<marker id="arrow-link" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#6ab0f5"/></marker>
</defs>
<rect width="100%" height="100%" fill="#020202"/>
<!-- ============ zones (painted first) ============ -->
<rect x="40" y="40" width="368" height="312" rx="8" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
<rect x="56" y="44" width="168" height="12" rx="2" fill="#020202"/>
<text x="60" y="53" fill="rgba(242,242,242,0.40)" font-size="7" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">PHASE I — LINEAR STRUCTURES</text>
<rect x="456" y="40" width="368" height="312" rx="8" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
<rect x="472" y="44" width="152" height="12" rx="2" fill="#020202"/>
<text x="476" y="53" fill="rgba(242,242,242,0.40)" font-size="7" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">PHASE III — HIERARCHICAL</text>
<rect x="456" y="384" width="368" height="168" rx="8" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
<rect x="472" y="388" width="144" height="12" rx="2" fill="#020202"/>
<text x="476" y="397" fill="rgba(242,242,242,0.40)" font-size="7" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">PHASE II — NODAL &amp; GRID</text>
<rect x="872" y="40" width="560" height="312" rx="8" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
<rect x="888" y="44" width="264" height="12" rx="2" fill="#020202"/>
<text x="892" y="53" fill="rgba(242,242,242,0.40)" font-size="7" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">PHASES IVV — RELATIONAL &amp; DECISION-SPACE</text>
<!-- ============ arrows (before boxes) ============ -->
<!-- same-row horizontals -->
<line x1="200" y1="168" x2="248" y2="168" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 3 → 4 -->
<line x1="200" y1="240" x2="248" y2="240" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 6 → 7 -->
<line x1="200" y1="312" x2="248" y2="312" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 1 → 2 -->
<line x1="616" y1="440" x2="664" y2="440" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 8 → 9 -->
<line x1="1032" y1="96" x2="1080" y2="96" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 16 → 18 -->
<line x1="1224" y1="240" x2="1272" y2="240" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 21 → 22 -->
<line x1="1224" y1="312" x2="1272" y2="312" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 23 → 24 -->
<line x1="616" y1="84" x2="888" y2="84" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 11 → 16 -->
<line x1="616" y1="312" x2="888" y2="312" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 12 → 17 -->
<!-- same-column verticals -->
<line x1="544" y1="120" x2="544" y2="144" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 11 → 13 -->
<line x1="960" y1="120" x2="960" y2="144" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 16 → 19 -->
<line x1="1152" y1="120" x2="1152" y2="144" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 18 → 20 -->
<line x1="1152" y1="192" x2="1152" y2="216" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 20 → 21 -->
<line x1="1152" y1="264" x2="1152" y2="288" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 21 → 23 -->
<!-- rounded right-angle elbows -->
<path d="M200,96 H416 Q424,96 424,104 V160 Q424,168 432,168 H472" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 5 → 13 -->
<path d="M616,96 H640 Q648,96 648,104 V160 Q648,168 656,168 H664" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 11 → 14 -->
<path d="M616,108 H624 Q632,108 632,116 V232 Q632,240 640,240 H664" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 11 → 15 -->
<path d="M392,184 H432 Q440,184 440,192 V504 Q440,512 448,512 H472" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 4 → 10 -->
<path d="M128,336 V560 Q128,568 136,568 H728 Q736,568 736,560 V464" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 1 → 9 -->
<!-- ============ nodes ============ -->
<!-- 05 Binary Search — entry -->
<rect x="56" y="72" width="144" height="48" rx="6" fill="#020202"/>
<rect x="56" y="72" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
<rect x="64" y="78" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="76" y="87" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">05</text>
<text x="128" y="104" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Binary Search</text>
<!-- 03 Two Pointers — entry -->
<rect x="56" y="144" width="144" height="48" rx="6" fill="#020202"/>
<rect x="56" y="144" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
<rect x="64" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="76" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">03</text>
<text x="128" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Two Pointers</text>
<!-- 04 Sliding Window -->
<rect x="248" y="144" width="144" height="48" rx="6" fill="#020202"/>
<rect x="248" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="256" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="268" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">04</text>
<text x="320" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Sliding Window</text>
<!-- 06 Stack — entry -->
<rect x="56" y="216" width="144" height="48" rx="6" fill="#020202"/>
<rect x="56" y="216" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
<rect x="64" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="76" y="231" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">06</text>
<text x="128" y="248" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Stack</text>
<!-- 07 Monotonic Stack -->
<rect x="248" y="216" width="144" height="48" rx="6" fill="#020202"/>
<rect x="248" y="216" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="256" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="268" y="231" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">07</text>
<text x="320" y="248" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Monotonic Stack</text>
<!-- 01 Hash-Based Lookup — entry -->
<rect x="56" y="288" width="144" height="48" rx="6" fill="#020202"/>
<rect x="56" y="288" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
<rect x="64" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="76" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">01</text>
<text x="128" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Hash Lookup</text>
<!-- 02 Prefix Sum -->
<rect x="248" y="288" width="144" height="48" rx="6" fill="#020202"/>
<rect x="248" y="288" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="256" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="268" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">02</text>
<text x="320" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Prefix Sum</text>
<!-- 11 Tree DFS — FOCAL -->
<rect x="472" y="72" width="144" height="48" rx="6" fill="#020202"/>
<rect x="472" y="72" width="144" height="48" rx="6" fill="rgba(68,157,240,0.12)" stroke="#449df0" stroke-width="1.2"/>
<rect x="480" y="78" width="24" height="12" rx="2" fill="transparent" stroke="rgba(68,157,240,0.50)" stroke-width="0.8"/>
<text x="492" y="87" fill="#449df0" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">11</text>
<text x="544" y="104" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Tree DFS</text>
<!-- 13 Binary Search Tree -->
<rect x="472" y="144" width="144" height="48" rx="6" fill="#020202"/>
<rect x="472" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="480" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="492" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">13</text>
<text x="544" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Binary Search Tree</text>
<!-- 14 Heap -->
<rect x="664" y="144" width="144" height="48" rx="6" fill="#020202"/>
<rect x="664" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="672" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="684" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">14</text>
<text x="736" y="170" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Heap</text>
<text x="736" y="184" fill="#989898" font-size="9" font-family="'IBM Plex Mono', monospace" text-anchor="middle">priority queue</text>
<!-- 15 Trie -->
<rect x="664" y="216" width="144" height="48" rx="6" fill="#020202"/>
<rect x="664" y="216" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="672" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="684" y="231" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">15</text>
<text x="736" y="248" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Trie</text>
<!-- 12 Tree BFS — entry -->
<rect x="472" y="288" width="144" height="48" rx="6" fill="#020202"/>
<rect x="472" y="288" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
<rect x="480" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="492" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">12</text>
<text x="544" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Tree BFS</text>
<!-- 08 Linked List — entry -->
<rect x="472" y="416" width="144" height="48" rx="6" fill="#020202"/>
<rect x="472" y="416" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
<rect x="480" y="422" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="492" y="431" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">08</text>
<text x="544" y="448" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Linked List</text>
<!-- 09 Hybrid Structures -->
<rect x="664" y="416" width="144" height="48" rx="6" fill="#020202"/>
<rect x="664" y="416" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="672" y="422" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="684" y="431" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">09</text>
<text x="736" y="448" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Hybrid Structures</text>
<!-- 10 Matrix Index Math -->
<rect x="472" y="488" width="144" height="48" rx="6" fill="#020202"/>
<rect x="472" y="488" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="480" y="494" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="492" y="503" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">10</text>
<text x="544" y="520" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Matrix Index Math</text>
<!-- 16 Graph DFS -->
<rect x="888" y="72" width="144" height="48" rx="6" fill="#020202"/>
<rect x="888" y="72" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="896" y="78" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="908" y="87" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">16</text>
<text x="960" y="104" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Graph DFS</text>
<!-- 18 Topological Sort -->
<rect x="1080" y="72" width="144" height="48" rx="6" fill="#020202"/>
<rect x="1080" y="72" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="1088" y="78" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="1100" y="87" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">18</text>
<text x="1152" y="104" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Topological Sort</text>
<!-- 19 Union-Find -->
<rect x="888" y="144" width="144" height="48" rx="6" fill="#020202"/>
<rect x="888" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="896" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="908" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">19</text>
<text x="960" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Union-Find</text>
<!-- 20 Backtracking -->
<rect x="1080" y="144" width="144" height="48" rx="6" fill="#020202"/>
<rect x="1080" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="1088" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="1100" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">20</text>
<text x="1152" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Backtracking</text>
<!-- 21 1-D DP — FOCAL -->
<rect x="1080" y="216" width="144" height="48" rx="6" fill="#020202"/>
<rect x="1080" y="216" width="144" height="48" rx="6" fill="rgba(68,157,240,0.12)" stroke="#449df0" stroke-width="1.2"/>
<rect x="1088" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(68,157,240,0.50)" stroke-width="0.8"/>
<text x="1100" y="231" fill="#449df0" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">21</text>
<text x="1152" y="242" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">1-D DP</text>
<text x="1152" y="256" fill="#989898" font-size="9" font-family="'IBM Plex Mono', monospace" text-anchor="middle">dynamic programming</text>
<!-- 22 Multi-D / Grid DP -->
<rect x="1272" y="216" width="144" height="48" rx="6" fill="#020202"/>
<rect x="1272" y="216" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="1280" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="1292" y="231" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">22</text>
<text x="1344" y="242" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Multi-D / Grid DP</text>
<!-- 17 Graph BFS -->
<rect x="888" y="288" width="144" height="48" rx="6" fill="#020202"/>
<rect x="888" y="288" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="896" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="908" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">17</text>
<text x="960" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Graph BFS</text>
<!-- 23 Greedy -->
<rect x="1080" y="288" width="144" height="48" rx="6" fill="#020202"/>
<rect x="1080" y="288" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="1088" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="1100" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">23</text>
<text x="1152" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Greedy</text>
<!-- 24 Intervals -->
<rect x="1272" y="288" width="144" height="48" rx="6" fill="#020202"/>
<rect x="1272" y="288" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<rect x="1280" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
<text x="1292" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">24</text>
<text x="1344" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Intervals</text>
<!-- ============ legend ============ -->
<line x1="40" y1="600" x2="1432" y2="600" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
<text x="40" y="620" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">LEGEND</text>
<rect x="160" y="608" width="20" height="12" rx="2" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
<text x="188" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">ENTRY — NO PREREQS</text>
<rect x="360" y="608" width="20" height="12" rx="2" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
<text x="388" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">TOPIC</text>
<rect x="480" y="608" width="20" height="12" rx="2" fill="rgba(68,157,240,0.12)" stroke="#449df0" stroke-width="1.2"/>
<text x="508" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">FOCAL HUB</text>
<line x1="624" y1="614" x2="656" y2="614" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/>
<text x="668" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">BUILDS ON</text>
<rect x="792" y="608" width="20" height="12" rx="2" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
<text x="820" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">PHASE ZONE</text>
</svg>

Before

Width:  |  Height:  |  Size: 23 KiB

-216
View File
@@ -1,216 +0,0 @@
#!/usr/bin/env bun
/**
* Close the GitHub issue for every LeetCode problem actually solved under work/.
*
* Reconciles state instead of reacting to a push diff: any `problem`-labelled
* issue whose LC number has an *implemented* solution file in work/ gets
* closed. Only open issues are touched, so re-runs are no-ops and backfilling
* needs no special casing. Removing a solution never reopens an issue.
*
* `bun run pick` scaffolds a statement header plus an empty function body, so
* file existence alone means nothing — a stub must not close its issue. See
* isImplemented().
*
* bun scripts/close-solved.ts # close matches
* bun scripts/close-solved.ts --dry-run # report only, touch nothing
*
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
*/
import { basename, join } from "node:path";
import { github } from "./github.ts";
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
const ROOT = join(import.meta.dir, "..");
const WORK = join(ROOT, "work");
const DRY =
process.argv.includes("--dry-run") ||
process.env.DRY_RUN === "1" ||
process.env.DRY_RUN === "true";
// ── is the file a real solution or just a scaffolded stub? ────────
/** Placeholder bodies that leetcode-cli / a human leaves behind. */
const PLACEHOLDERS: Record<string, true> = { pass: true, "...": true, TODO: true };
/**
* Decide whether a work/ file contains an implementation.
*
* The header comment is dropped the same way sync.ts splitSource() does it
* (duplicated rather than imported, because sync.ts runs its whole pipeline on
* import). Comments must go before any brace analysis: the scaffold's JSDoc
* carries `@param {number[]}`, whose braces would otherwise read as a body.
*
* A brace-language file counts as implemented when at least one *innermost*
* brace pair holds real content. That distinguishes a bare stub
* (`function(nums) {}`) and a class-shaped design stub (every method body
* empty) from any genuine solution, whose innermost block always has code.
*/
function isImplemented(src: string, lang: "js" | "py"): boolean {
if (lang === "py") {
const open = src.indexOf('"""');
const close = src.indexOf('"""', open + 3);
const code = open === -1 || close === -1 ? src : src.slice(close + 3);
for (const raw of code.split("\n")) {
const line = raw.replace(/#.*$/, "").trim();
if (!line || PLACEHOLDERS[line]) continue;
if (/^(?:@|def\s|class\s)/.test(line)) continue;
return true; // a statement inside some def body
}
return false;
}
const headerEnd = src.indexOf("*/");
const body = headerEnd === -1 ? src : src.slice(headerEnd + 2);
const code = body.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/.*$/gm, " ");
let innermost = -1;
for (let i = 0; i < code.length; i++) {
if (code[i] === "{") {
innermost = i;
} else if (code[i] === "}" && innermost !== -1) {
const inner = code.slice(innermost + 1, i).replace(/[\s;]/g, "");
if (inner && !PLACEHOLDERS[inner]) return true;
innermost = -1; // measured; the enclosing pair is not innermost
}
}
return false;
}
// ── repo + auth ──────────────────────────────────────────────────
const gh = await github();
// ── work/ inventory ──────────────────────────────────────────────
interface WorkEntry {
files: string[];
implemented: boolean;
}
/** LC number -> solution files (a problem may have both js and py). */
const work = new Map<number, WorkEntry>();
for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
const m = basename(rel).match(/^(\d+)\.(.+)\.(?:js|py)$/);
if (!m) {
console.warn(`skip (unrecognized name): work/${rel}`);
continue;
}
const num = Number(m[1]);
const src = await Bun.file(join(WORK, rel)).text();
const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js");
const entry = work.get(num);
if (entry) {
entry.files.push(`work/${rel}`);
entry.implemented ||= implemented;
} else {
work.set(num, { files: [`work/${rel}`], implemented });
}
}
// ── open problem issues ──────────────────────────────────────────
interface ProblemIssue {
number: number;
set: string;
}
/**
* Narrow one element of the /issues payload to the fields this script needs.
* Returns undefined for pull requests and for anything whose title is not a
* `LC <num> ...` problem, which is how non-curriculum rows get skipped.
*/
function readProblemIssue(
value: unknown,
): { lc: number; issue: ProblemIssue } | undefined {
if (!value || typeof value !== "object") return;
if ("pull_request" in value) return; // the /issues route also lists PRs
if (!("number" in value) || typeof value.number !== "number") return;
if (!("title" in value) || typeof value.title !== "string") return;
const lc = value.title.match(/^LC (\d+) /);
if (!lc) return;
let set = "—";
if ("labels" in value && Array.isArray(value.labels)) {
for (const label of value.labels) {
if (
label &&
typeof label === "object" &&
"name" in label &&
typeof label.name === "string" &&
label.name.startsWith("set:")
) {
set = label.name;
}
}
}
return { lc: Number(lc[1]), issue: { number: value.number, set } };
}
/** LC number -> open issue carrying the `problem` label. */
const open = new Map<number, ProblemIssue>();
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=open`)) {
const parsed = readProblemIssue(raw);
if (parsed) open.set(parsed.lc, parsed.issue);
}
// ── reconcile ────────────────────────────────────────────────────
const sha = process.env.GITHUB_SHA;
const report: string[][] = [];
let closed = 0;
for (const num of [...work.keys()].sort((a, b) => a - b)) {
const entry = work.get(num)!;
const issue = open.get(num);
// Warm-ups and out-of-curriculum practice have no issue. Not an error.
if (!issue) {
report.push([`${num}`, "—", "—", "no open issue"]);
continue;
}
if (!entry.implemented) {
report.push([`${num}`, `#${issue.number}`, issue.set, "stub — skipped"]);
continue;
}
if (DRY) {
report.push([`${num}`, `#${issue.number}`, issue.set, "would close"]);
continue;
}
const links = entry.files
.map((f) =>
sha ? `[\`${f}\`](https://github.com/${gh.repo}/blob/${sha}/${encodeURI(f)})` : `\`${f}\``,
)
.join(", ");
await gh.closeIssue(
issue.number,
`Solved — solution committed at ${links}.\n\n` +
"Closed automatically by `close-solved`. Fill in the close-out block above if you have not already.",
);
report.push([`${num}`, `#${issue.number}`, issue.set, "closed"]);
closed++;
}
// ── report ───────────────────────────────────────────────────────
const rows = [["lc", "issue", "set", "status"], ...report];
printTable(rows);
const implemented = [...work.values()].filter((e) => e.implemented).length;
const actionable = report.filter((r) => r[3] !== "no open issue");
console.log(
`\n${work.size} in work/ · ${implemented} implemented · ` +
`${actionable.length} matched an open issue · ` +
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}`,
);
await writeStepSummary(
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
`${work.size} files in \`work/\`, ${implemented} implemented, ` +
`${actionable.length} matched an open issue.\n\n` +
(actionable.length
? `${markdownTable([rows[0]!, ...actionable])}\n`
: "Nothing to close.\n"),
);
-150
View File
@@ -1,150 +0,0 @@
#!/usr/bin/env bun
/**
* Close the GitHub issue for every topic whose required (core) problems are done.
*
* Each `topic`-labelled issue owns its problems as GitHub sub-issues, and each
* problem carries exactly one `set:` label — `set:core` is required, while
* `set:optional` and `set:deferred` are extra credit. A topic is finished when
* every one of its core sub-issues is closed; optional/deferred state is
* ignored, matching the day rule in each topic body ("Core problems first").
*
* Like close-solved.ts this reconciles state instead of reacting to an event
* payload: re-runs are no-ops, backfilling needs no special casing, and only
* open topics are touched, so reopening a problem never reopens its topic.
*
* bun scripts/close-topics.ts # close finished topics
* bun scripts/close-topics.ts --dry-run # report only, touch nothing
*
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
*/
import { github } from "./github.ts";
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
const DRY =
process.argv.includes("--dry-run") ||
process.env.DRY_RUN === "1" ||
process.env.DRY_RUN === "true";
const gh = await github();
// ── open topic issues ────────────────────────────────────────────
interface Topic {
number: number;
title: string;
}
/**
* Narrow one element of the /issues payload to the fields this script needs.
* The `topic` label filter cannot exclude pull requests, so drop those here.
*/
function readTopic(value: unknown): Topic | undefined {
if (!value || typeof value !== "object") return;
if ("pull_request" in value) return; // the /issues route also lists PRs
if (!("number" in value) || typeof value.number !== "number") return;
if (!("title" in value) || typeof value.title !== "string") return;
return { number: value.number, title: value.title };
}
const topics: Topic[] = [];
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=open`)) {
const topic = readTopic(raw);
if (topic) topics.push(topic);
}
topics.sort((a, b) => a.number - b.number);
// ── core sub-issue state per topic ───────────────────────────────
interface Core {
/** Core sub-issues, closed and open alike, lowest number first. */
all: number[];
/** The core sub-issues still open — non-empty means the topic stays open. */
pending: number[];
}
/**
* Read a topic's core sub-issue state. The sub_issues payload carries the full
* issue objects (state + labels), so no per-problem follow-up request is needed.
*/
async function readCore(topic: number): Promise<Core> {
const all: number[] = [];
const pending: number[] = [];
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
if (!raw || typeof raw !== "object") continue;
if (!("number" in raw) || typeof raw.number !== "number") continue;
if (!("state" in raw) || typeof raw.state !== "string") continue;
if (!("labels" in raw) || !Array.isArray(raw.labels)) continue;
const core = raw.labels.some(
(label) =>
label &&
typeof label === "object" &&
"name" in label &&
label.name === "set:core",
);
if (!core) continue;
all.push(raw.number);
if (raw.state === "open") pending.push(raw.number);
}
all.sort((a, b) => a - b);
pending.sort((a, b) => a - b);
return { all, pending };
}
// ── reconcile ────────────────────────────────────────────────────
const report: string[][] = [];
let closed = 0;
for (const topic of topics) {
const core = await readCore(topic.number);
const progress = `${core.all.length - core.pending.length}/${core.all.length}`;
// A topic with no core sub-issues has nothing to complete: never close it,
// since that would be indistinguishable from a mis-labelled problem set.
if (core.all.length === 0) {
report.push([`#${topic.number}`, topic.title, progress, "no core set"]);
continue;
}
if (core.pending.length > 0) {
report.push([
`#${topic.number}`,
topic.title,
progress,
`open: ${core.pending.map((n) => `#${n}`).join(" ")}`,
]);
continue;
}
if (DRY) {
report.push([`#${topic.number}`, topic.title, progress, "would close"]);
continue;
}
await gh.closeIssue(
topic.number,
`Core set complete — all ${core.all.length} core problems closed ` +
`(${core.all.map((n) => `#${n}`).join(", ")}).\n\n` +
"Closed automatically by `close-topics`. Optional and deferred problems " +
"stay open as extra credit.",
);
report.push([`#${topic.number}`, topic.title, progress, "closed"]);
closed++;
}
// ── report ───────────────────────────────────────────────────────
const rows = [["topic", "title", "core", "status"], ...report];
printTable(rows);
const finished = report.filter((r) => r[3] === "closed" || r[3] === "would close");
console.log(
`\n${topics.length} open topics · ` +
`${DRY ? `would close ${finished.length}` : `closed ${closed}`}`,
);
await writeStepSummary(
`### close-topics${DRY ? " (dry run)" : ""}\n\n` +
`${topics.length} open topic issues, ${finished.length} with a complete core set.\n\n` +
(report.length ? `${markdownTable(rows)}\n` : "No open topics.\n"),
);
-93
View File
@@ -1,93 +0,0 @@
/**
* GitHub REST plumbing shared by the issue reconcilers (close-solved,
* close-topics).
*
* Importing this module is side-effect free — nothing resolves credentials or
* touches the network until github() is awaited — so a script can import it
* without inheriting another script's pipeline.
*
* Repo: GITHUB_REPOSITORY, else the `origin` remote.
* Auth: GH_TOKEN / GITHUB_TOKEN, else `gh auth token`.
*/
import { $ } from "bun";
import { join } from "node:path";
const ROOT = join(import.meta.dir, "..");
/** Page size used for every list endpoint; also the "more pages" threshold. */
const PER_PAGE = 100;
export interface GitHub {
/** `owner/name`. */
repo: string;
/** One authenticated request against api.github.com; throws on non-2xx. */
api(path: string, init?: RequestInit): Promise<unknown>;
/** Every page of a list endpoint, flattened into one stream of elements. */
list(path: string): AsyncGenerator<unknown, void, void>;
/** Comment on an issue, then close it as completed. */
closeIssue(issue: number, comment: string): Promise<void>;
}
async function resolveRepo(): Promise<string> {
if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY;
const url = (await $`git -C ${ROOT} remote get-url origin`.text()).trim();
const m = url.match(/github\.com[:/](.+?)(?:\.git)?$/);
if (!m) throw new Error(`cannot derive owner/repo from remote: ${url}`);
return m[1]!;
}
async function resolveToken(): Promise<string> {
const env = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
if (env) return env;
const token = (await $`gh auth token`.text()).trim();
if (!token) throw new Error("no credentials: set GH_TOKEN or run `gh auth login`");
return token;
}
export async function github(): Promise<GitHub> {
const repo = await resolveRepo();
const token = await resolveToken();
async function api(path: string, init: RequestInit = {}): Promise<unknown> {
const res = await fetch(`https://api.github.com${path}`, {
...init,
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
"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.json();
}
async function* list(path: string): AsyncGenerator<unknown, void, void> {
const sep = path.includes("?") ? "&" : "?";
for (let page = 1; ; page++) {
const batch = await api(`${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;
}
}
/**
* Comment before closing: if the PATCH fails, the issue still carries a
* visible note of what the automation decided, instead of failing silently.
*/
async function closeIssue(issue: number, comment: string): Promise<void> {
await api(`/repos/${repo}/issues/${issue}/comments`, {
method: "POST",
body: JSON.stringify({ body: comment }),
});
await api(`/repos/${repo}/issues/${issue}`, {
method: "PATCH",
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
});
}
return { repo, api, list, closeIssue };
}
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env bun
/**
* Fuzzy-pick a LeetCode problem and scaffold it via leetcode-cli.
* Problem index is fetched from leetcode.com and cached for 24h in .cache/.
*/
import { autocomplete, cancel, isCancel, spinner } from "@clack/prompts";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
const ROOT = join(import.meta.dir, "..");
const CACHE_FILE = join(ROOT, ".cache", "leetcode-problems.json");
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
interface Problem {
id: number;
title: string;
slug: string;
difficulty: "Easy" | "Medium" | "Hard";
paidOnly: boolean;
}
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
async function loadProblems(): Promise<Problem[]> {
const file = Bun.file(CACHE_FILE);
if (await file.exists()) {
const stale = Date.now() - file.lastModified > CACHE_TTL_MS;
if (!stale) return file.json();
}
const s = spinner();
s.start("Fetching problem index from leetcode.com");
const res = await fetch("https://leetcode.com/api/problems/all/", {
headers: { "user-agent": "Mozilla/5.0" },
});
if (!res.ok) {
s.error(`Fetch failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const data = (await res.json()) as {
stat_status_pairs: Array<{
stat: {
frontend_question_id: number;
question__title: string;
question__title_slug: string;
};
difficulty: { level: 1 | 2 | 3 };
paid_only: boolean;
}>;
};
const problems: Problem[] = data.stat_status_pairs
.map((p) => ({
id: p.stat.frontend_question_id,
title: p.stat.question__title,
slug: p.stat.question__title_slug,
difficulty: DIFFICULTY[p.difficulty.level] as Problem["difficulty"],
paidOnly: p.paid_only,
}))
.sort((a, b) => a.id - b.id);
s.stop(`Loaded ${problems.length} problems`);
await mkdir(join(ROOT, ".cache"), { recursive: true });
await Bun.write(CACHE_FILE, JSON.stringify(problems));
return problems;
}
const problems = await loadProblems();
const picked = await autocomplete<Problem>({
message: "Pick a problem",
placeholder: "Type to search by number or title...",
maxItems: 12,
options: problems.map((p) => ({
value: p,
label: `${p.id}. ${p.title}`,
hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty,
})),
filter: (search, option) => {
const haystack = option.label!.toLowerCase();
return search
.toLowerCase()
.split(/\s+/)
.every((token) => haystack.includes(token));
},
});
if (isCancel(picked)) {
cancel("Nothing picked.");
process.exit(0);
}
const proc = Bun.spawn(
[join(ROOT, "node_modules", ".bin", "leetcode"), "pick", picked.slug],
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
);
process.exit(await proc.exited);
-37
View File
@@ -1,37 +0,0 @@
/**
* Console + GitHub Actions reporting shared by the issue reconcilers.
*
* Both reconcilers end the same way: an aligned table on stdout, and the same
* table as Markdown in the step summary when running under Actions.
*/
/** Print rows[0] as a header, a rule, then the body — every column padded. */
export function printTable(rows: string[][]): void {
const widths = rows[0]!.map((_, i) => Math.max(...rows.map((r) => r[i]!.length)));
const render = (r: string[]) =>
r
.map((cell, i) => cell.padEnd(widths[i]!))
.join(" ")
.trimEnd();
console.log(render(rows[0]!));
console.log(widths.map((n) => "─".repeat(n)).join(" "));
for (const row of rows.slice(1)) console.log(render(row));
}
/** Same rows as a GitHub-flavoured Markdown table; rows[0] is the header. */
export function markdownTable(rows: string[][]): string {
const [header, ...body] = rows;
return [
`| ${header!.join(" | ")} |`,
`| ${header!.map(() => "---").join(" | ")} |`,
...body.map((r) => `| ${r.join(" | ")} |`),
].join("\n");
}
/** Append to the Actions step summary; a no-op outside Actions. */
export async function writeStepSummary(markdown: string): Promise<void> {
const path = process.env.GITHUB_STEP_SUMMARY;
if (!path) return;
await Bun.write(path, markdown);
}
-407
View File
@@ -1,407 +0,0 @@
#!/usr/bin/env bun
/**
* Sync work/ leetcode solutions into docs/solutions/ pages.
*
* - New problems get a full page (frontmatter, badge, warning, examples,
* constraints, solution) in the gold-standard format.
* - Already-ported pages only get their `## Solution` section regenerated,
* so hand-curated prose is never touched.
* - When a problem is solved in both JavaScript and Python the solution
* renders as a <CodeGroup> (Python first); single-language solutions
* render as a plain fence.
*/
import { mkdir, rename } from "node:fs/promises";
import { basename, join } from "node:path";
const ROOT = join(import.meta.dir, "..");
const WORK = join(ROOT, "work");
const DOCS = join(ROOT, "docs", "solutions");
type Lang = "js" | "py";
interface Example {
input: string;
output: string;
explanation: string[]; // [] = none; 1 entry = inline; >1 = bullet list
}
interface Header {
num: number;
title: string;
difficulty: string;
statement: string[]; // unwrapped paragraphs
examples: Example[];
constraints: string[];
followUp: string;
}
interface Solution {
num: number;
slug: string;
category: string;
header: Header;
code: Partial<Record<Lang, string>>;
}
// ── work/ parsing ────────────────────────────────────────────────
/** Split a source file into (header comment text, code). */
function splitSource(src: string, lang: Lang): { header: string; code: string } {
let header: string, code: string;
if (lang === "js") {
const end = src.indexOf("*/");
if (!src.trimStart().startsWith("/*") || end === -1) throw new Error("missing /* header */");
header = src
.slice(src.indexOf("/*") + 2, end)
.split("\n")
.map((l) => l.replace(/^\s*\* ?/, ""))
.join("\n");
code = src.slice(end + 2);
} else {
const open = src.indexOf('"""');
const close = src.indexOf('"""', open + 3);
if (open === -1 || close === -1) throw new Error('missing """ header """');
header = src.slice(open + 3, close);
code = src.slice(close + 3);
}
return { header, code: code.replace(/^\s*\n/, "").trimEnd() };
}
/** Unwrap hard-wrapped lines into logical paragraphs / bullets. */
function paragraphs(lines: string[]): string[] {
const out: string[] = [];
let cur = "";
const flush = () => {
if (cur) out.push(cur);
cur = "";
};
for (const raw of lines) {
const line = raw.replace(/\t/g, " ").trimEnd();
const text = line.trim();
if (!text || /^[─-]{3,}$/.test(text)) {
flush();
continue;
}
if (/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(text)) {
flush();
cur = text;
} else {
cur = cur ? `${cur} ${text}` : text;
}
}
flush();
return out;
}
function parseHeader(header: string): Header {
const lines = header.split("\n");
const titleLine = lines.find((l) => /^\s*\d+\.\s/.test(l))?.trim();
if (!titleLine) throw new Error("missing '<num>. <title>' line");
const num = Number.parseInt(titleLine, 10);
const title = titleLine.replace(/^\d+\.\s*/, "");
const difficulty =
lines.find((l) => l.trim().startsWith("Difficulty:"))?.split(":")[1]?.trim() ?? "";
// Body: everything after the ───── separator.
const sep = lines.findIndex((l) => /^[─]{3,}/.test(l.trim()));
const paras = paragraphs(lines.slice(sep + 1));
const statement: string[] = [];
const examples: Example[] = [];
const constraints: string[] = [];
let followUp = "";
let section: "statement" | "example" | "constraints" = "statement";
let ex: Example | null = null;
for (const p of paras) {
if (/^Example \d+:?$/.test(p)) {
if (ex) examples.push(ex);
ex = { input: "", output: "", explanation: [] };
section = "example";
continue;
}
if (/^Constraints:$/.test(p)) {
if (ex) examples.push(ex);
ex = null;
section = "constraints";
continue;
}
const fu = p.match(/^Follow[- ]?ups?:\s*(.*)$/i);
if (fu) {
followUp = fu[1]!;
continue;
}
if (section === "statement") statement.push(p.replace(/^• /, ""));
else if (section === "constraints") constraints.push(p.replace(/^• /, ""));
else if (ex) {
// Example paragraphs: Input/Output/Explanation, wrapped arbitrarily.
// A paragraph may fuse "Input: … Output: …" only across real lines,
// but source always keeps them on separate wrapped paragraphs.
if (p.startsWith("Input:")) ex.input = p.slice(6).trim();
else if (p.startsWith("Output:")) ex.output = p.slice(7).trim();
else if (p.startsWith("Explanation:")) {
const rest = p.slice(12).trim();
if (rest) ex.explanation.push(rest);
} else if (p.startsWith("- ")) ex.explanation.push(p.slice(2));
else if (ex.explanation.length)
ex.explanation[ex.explanation.length - 1] += ` ${p}`;
else ex.explanation.push(p);
}
}
if (ex) examples.push(ex);
return { num, title, difficulty, statement, examples, constraints, followUp };
}
// ── README topic map ─────────────────────────────────────────────
const TOPIC_ALIASES: Record<string, string> = {
"Hash-Based Lookup": "Hash Table",
};
async function readmeTopics(): Promise<Map<number, string>> {
const md = await Bun.file(join(ROOT, "README.md")).text();
const map = new Map<number, string>();
let topic = "";
for (const line of md.split("\n")) {
const t = line.match(/<strong>\d+\.\s*([^<]+)<\/strong>/);
if (t) topic = TOPIC_ALIASES[t[1]!.trim()] ?? t[1]!.trim();
if (!topic) continue;
const n = line.match(/<td align="center">(\d+)<\/td>/);
if (n) map.set(Number(n[1]), topic);
}
return map;
}
// ── inline-code formatting heuristics ────────────────────────────
/** Identifiers worth backticking in prose, harvested from the code. */
function identifiers(sol: Solution): Set<string> {
const ids = new Set<string>(["n", "m", "k"]);
for (const code of Object.values(sol.code)) {
for (const m of code.matchAll(/@param\s*\{[^}]*\}\s*(\w+)/g)) ids.add(m[1]!);
for (const m of code.matchAll(/def \w+\(self,?\s*([^)]*)\)/g))
for (const arg of m[1]!.split(","))
if (arg.trim()) ids.add(arg.split(":")[0]!.trim());
for (const m of code.matchAll(/(?:var|const|let) (\w+) = function\s*\(([^)]*)\)/g))
for (const arg of m[2]!.split(","))
if (arg.trim()) ids.add(arg.trim());
}
return ids;
}
function formatConstraint(c: string, ids: Set<string>): string {
if (/<=|>=|==|!=|<|>/.test(c)) return `\`${c}\``;
// Prose constraint: backtick identifier-ish tokens only.
return c
.split(" ")
.map((word) => {
const m = word.match(/^([\w.]+(?:\[[^\]]*\])?)([.,;:]?)$/);
if (!m) return word;
const [, tok, punct] = m;
if (/\[[^\]]*\]/.test(tok!) || ids.has(tok!)) return `\`${tok}\`${punct}`;
return word;
})
.join(" ");
}
/** Wrap value literals / expressions in an explanation sentence. */
function backtickify(text: string, ids: Set<string>): string {
const words = text.split(" ");
type Tok = { pre: string; core: string; post: string; codey: boolean };
const toks: Tok[] = words.map((w) => {
const m = w.match(/^([("']*)(.*?)([)"'.,;:!?]*)$/)!;
const core = m[2]!;
const codey =
/^-?\d+(\.\d+)?([+\-*/]-?\d+(\.\d+)?)*$/.test(core) || // number / compact arithmetic
/^\[[^\]]*\]$/.test(core) || // array literal
/^[a-zA-Z_]\w*\[[^\]]*\]$/.test(core) || // indexed identifier
/^[+\-*/=%]$/.test(core) || // operator
ids.has(core);
return { pre: m[1]!, core, post: m[3]!, codey };
});
const out: string[] = [];
let i = 0;
while (i < toks.length) {
if (!toks[i]!.codey || /^[+\-*/=%]$/.test(toks[i]!.core)) {
out.push(words[i]!); // prose, or an operator with no codey run to its left
i++;
continue;
}
// Extend a run of codey tokens; only unbroken by punctuation.
let j = i;
while (
j + 1 < toks.length &&
toks[j + 1]!.codey &&
!toks[j]!.post && // punctuation after a token ends the run
!toks[j + 1]!.pre.includes('"')
)
j++;
// Trim trailing operators from the run (e.g. "5 and" keeps "and" out anyway).
while (j > i && /^[+\-*/=%]$/.test(toks[j]!.core)) j--;
const span = toks
.slice(i, j + 1)
.map((tok, idx, arr) => (idx === arr.length - 1 ? `${tok.pre}${tok.core}` : `${tok.pre}${tok.core}${tok.post}`))
.join(" ");
out.push(`\`${span}\`${toks[j]!.post}`);
i = j + 1;
}
return out.join(" ");
}
// ── page rendering ───────────────────────────────────────────────
const FENCE: Record<Lang, { info: string; label: string }> = {
py: { info: "py", label: "Python" },
js: { info: "js", label: "JavaScript" },
};
function solutionSection(sol: Solution): string {
const langs = (["py", "js"] as const).filter((l) => sol.code[l]);
const fence = (l: Lang, titled: boolean) =>
`\`\`\`${FENCE[l].info}${titled ? ` ${FENCE[l].label}` : ""}\n${sol.code[l]}\n\`\`\``;
if (langs.length === 1) return `## Solution\n\n${fence(langs[0]!, false)}\n`;
return `## Solution\n\n<CodeGroup>\n\n${langs.map((l) => fence(l, true)).join("\n\n")}\n\n</CodeGroup>\n`;
}
function warningText(h: Header): string {
if (h.followUp) return h.followUp;
return (
h.statement
.slice(1)
.find((p) => /^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(p)) ?? ""
);
}
function renderPage(sol: Solution, topic: string): string {
const h = sol.header;
const ids = identifiers(sol);
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
const parts: string[] = [];
parts.push(
"---",
`title: '${h.num}. ${h.title.replaceAll("'", "''")}'`,
/[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`,
"sidebar:",
` label: '${h.title.replaceAll("'", "''")}'`,
` badge: '${h.difficulty}'`,
"---",
"",
`<Badge variant="accent">${topic}</Badge>`,
"",
);
const warning = warningText(h);
if (warning) parts.push("::::warning", warning, "::::", "");
h.examples.forEach((ex, i) => {
parts.push(`### Example ${i + 1}:`);
parts.push(`- Input: \`${ex.input}\``);
parts.push(`- Output: \`${ex.output}\``);
if (ex.explanation.length === 1)
parts.push(`- Explanation: ${backtickify(ex.explanation[0]!, ids)}`);
else if (ex.explanation.length > 1) {
parts.push("- Explanation:");
for (const item of ex.explanation) parts.push(` - ${backtickify(item, ids)}`);
}
parts.push("");
});
parts.push("### Constraints:", "");
for (const c of h.constraints) parts.push(`- ${formatConstraint(c, ids)}`);
parts.push("", solutionSection(sol));
return parts.join("\n");
}
// ── main ─────────────────────────────────────────────────────────
const solutions = new Map<number, Solution>();
for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
const lang = rel.endsWith(".py") ? "py" : ("js" as Lang);
const file = basename(rel);
const m = file.match(/^(\d+)\.(.+)\.(?:js|py)$/);
if (!m) {
console.warn(`skip (unrecognized name): work/${rel}`);
continue;
}
const num = Number(m[1]);
const slug = m[2]!;
const { header, code } = splitSource(await Bun.file(join(WORK, rel)).text(), lang);
const parsed = parseHeader(header);
const existing = solutions.get(num);
if (existing) {
existing.code[lang] = code;
if (lang === "js") existing.header = parsed; // js header wins when both exist
} else {
solutions.set(num, {
num,
slug,
category: rel.split("/")[1] ?? "",
header: parsed,
code: { [lang]: code },
});
}
}
const topics = await readmeTopics();
/** Insert a number-free `sidebar.label` into existing frontmatter when missing. */
function ensureLabel(page: string, h: Header): string {
const fm = page.match(/^---\n[\s\S]*?\n---/);
if (!fm || /^ {2}label: /m.test(fm[0])) return page;
const label = ` label: '${h.title.replaceAll("'", "''")}'`;
return page.replace(/^sidebar:$/m, `sidebar:\n${label}`);
}
// Existing docs pages, keyed by leetcode number from the frontmatter title.
const pages = new Map<number, string>(); // num -> path relative to DOCS
for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).sort()) {
const text = await Bun.file(join(DOCS, rel)).text();
const t = text.match(/^title: '(\d+)\./m);
if (t) pages.set(Number(t[1]), rel);
}
const report: [string, string, string][] = [];
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
for (const sol of ordered) {
const langs = Object.keys(sol.code).sort().join("+");
const workRef = `${sol.num}.${sol.slug} (${langs})`;
// (category) group folder + leetcode-number prefix: numeric sidebar order, both stripped from the URL.
const group = `(${sol.category.toLowerCase().replaceAll(" ", "-")})`;
const name = `${group}/${sol.num}-${sol.slug}.mdx`;
const existing = pages.get(sol.num);
await mkdir(join(DOCS, group), { recursive: true });
if (existing) {
const page = await Bun.file(join(DOCS, existing)).text();
const at = page.indexOf("## Solution");
if (at === -1) {
report.push([workRef, existing, "ERROR: no '## Solution' heading"]);
continue;
}
const next = ensureLabel(page.slice(0, at) + solutionSection(sol), sol.header);
const actions: string[] = [];
if (existing !== name) {
await rename(join(DOCS, existing), join(DOCS, name));
actions.push("moved");
}
if (next !== page) {
await Bun.write(join(DOCS, name), next);
actions.push("updated");
}
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
} else {
const topic = topics.get(sol.num) ?? sol.category;
await Bun.write(join(DOCS, name), renderPage(sol, topic));
report.push([workRef, name, "created"]);
}
}
const w0 = Math.max(...report.map((r) => r[0].length), 9);
const w1 = Math.max(...report.map((r) => r[1].length), 9);
console.log(`${"work file".padEnd(w0)} ${"docs page".padEnd(w1)} status`);
console.log(`${"─".repeat(w0)} ${"─".repeat(w1)} ${"─".repeat(20)}`);
for (const [a, b, c] of report) console.log(`${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`);
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env bun
/**
* Fuzzy-select a solution file from work/ and run leetcode-cli tests on it.
*/
import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
import { join, relative } from "node:path";
const ROOT = join(import.meta.dir, "..");
const WORK_DIR = join(ROOT, "work");
const files = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })]
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
if (files.length === 0) {
log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`);
process.exit(1);
}
const picked = await autocomplete<string>({
message: "Test which solution?",
placeholder: "Type to search...",
maxItems: 12,
options: files.map((f) => {
// work layout: Difficulty/Category/<id>.<slug>.<ext>
const [difficulty, category, name] = f.split("/");
return {
value: f,
label: name ?? f,
hint: category ? `${difficulty} · ${category}` : difficulty,
};
}),
filter: (search, option) => {
const haystack = option.value.toLowerCase();
return search
.toLowerCase()
.split(/\s+/)
.every((token) => haystack.includes(token));
},
});
if (isCancel(picked)) {
cancel("Nothing selected.");
process.exit(0);
}
const proc = Bun.spawn(
[
join(ROOT, "node_modules", ".bin", "leetcode"),
"test",
join(WORK_DIR, picked),
],
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
);
process.exit(await proc.exited);