feat: refactored docs structure

This commit is contained in:
Prad Nukala committed 2026-10-05 13:53:14 -04:00
1 parent d388b3000e
commit 9de25fecff
212 files changed
+641 -4169

No files matched your search

+14 -15
View File
@@ -2,7 +2,7 @@
## Project Overview ## Project Overview
LeetCode interview-prep campaign (2026-08-17 → Nov 3: eight weeks of topics, then the Review-issue phase; the end is the last date in `apps/api/data/schedule.json` — `CAMPAIGN_END`/`CAMPAIGN_DAYS` in `apps/api/src/srs.ts` are derived from it, never declared) 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 (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state. LeetCode interview-prep campaign (2026-08-17 → Nov 3: eight weeks of topics, then the Review-issue phase; the end is the last date in `apps/api/data/schedule.json` — `CAMPAIGN_END`/`CAMPAIGN_DAYS` in `apps/api/src/srs.ts` are derived from it, never declared) run as a repo: solution files under `work/`, the docs content under `docs/` (MDX only — the Blume site that publishes it lives in [prdlk/cs](https://github.com/prdlk/cs) and reads `docs/` over HTTP), GitHub Actions that reconcile issues, 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 (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
## Architecture & Data Flow ## Architecture & Data Flow
@@ -10,7 +10,8 @@ Three owners, one direction of truth:
```mermaid ```mermaid
flowchart LR flowchart LR
work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx] work[work/ solutions] -->|bun run sync| docs[docs/*.mdx + docs/site.json]
docs -->|mdxRemote + site.json fetch| Site[prdlk/cs docs site]
work -->|close-solved.yml| PI[problem issues] work -->|close-solved.yml| PI[problem issues]
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)] work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
PI -->|close-topics.yml| TI[topic + Review N issues] PI -->|close-topics.yml| TI[topic + Review N issues]
@@ -38,7 +39,7 @@ flowchart LR
- **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). - **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 `apps/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.
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first, then the problems closed on the finish day / earlier, with solution links at the tag — problems still open at the finish are left out). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs `/changelog` is ONE custom page, `apps/docs/pages/changelog.astro` (a NeonUI-style timeline: date left, a card per release right, no per-release subpages, so nothing of it enters the sidebar or prev/next), linked from the header by `navigation.actions` (not a tab). `apps/docs/lib/releases.ts` fetches the releases at build (`GITHUB_TOKEN` in `deploy.yml`; a failed fetch fails the build), dates each by its timever tag, not `published_at` (which the API cannot backdate), tags it `Topic` or `Review` from the release name, and lists the rows of its `### Closed …` tables — so the tag format and that table shape are the page's contract with `render()`. There is no changelog RSS feed. - **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first, then the problems closed on the finish day / earlier, with solution links at the tag — problems still open at the finish are left out). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs site's `/changelog` (prdlk/cs `pages/changelog.astro` + `lib/releases.ts`) fetches this repo's releases at its build, dates each by its timever tag, not `published_at` (which the API cannot backdate), tags it `Topic` or `Review` from the release name, and lists the rows of its `### Closed …` tables — so the tag format and that table shape are its cross-repo contract with `render()`.
- **Review issues re-parent problems; topics still own them.** The transfer phase's 20 `Review <N> — <Phase>` issues (label `review`, milestone `Review — Transfer & Mocks`; not the Worker's `Review — Week N` gate issues) hold open optional/deferred problems as sub-issues in solve order, moved there with `replace_parent`. `apps/api/src/reviews.ts` is the ONE recovery of ownership — `readReviews()` (one GraphQL request per 50 `review` issues, cheap enough for the Worker's cron subrequest budget) reads each moved problem's topic off its last `parent_issue_removed` event naming a topic, `movedFromTopics()` groups them — shared by the Worker's catalog reconcile (a moved problem's D1 row keeps syncing its labels and `topic_issue`) and `apps/cli/topics.ts` (`readProblems()` returns a topic's sub-issues plus its moved problems, so the finished rule and existing release bodies do not change when a problem moves). A Review issue finishes by `completion()` in `topics.ts` — every sub-issue closed, any set — which `close-topics.ts` closes it on and `release-topics.ts` releases it on, exactly like a topic: timever of the last close, same `<!-- release-topics: #N -->` marker (issue numbers never collide), body fields from the review's `**Scheduled:**`/`**Phase:**`/`**Load:**` lines. - **Review issues re-parent problems; topics still own them.** The transfer phase's 20 `Review <N> — <Phase>` issues (label `review`, milestone `Review — Transfer & Mocks`; not the Worker's `Review — Week N` gate issues) hold open optional/deferred problems as sub-issues in solve order, moved there with `replace_parent`. `apps/api/src/reviews.ts` is the ONE recovery of ownership — `readReviews()` (one GraphQL request per 50 `review` issues, cheap enough for the Worker's cron subrequest budget) reads each moved problem's topic off its last `parent_issue_removed` event naming a topic, `movedFromTopics()` groups them — shared by the Worker's catalog reconcile (a moved problem's D1 row keeps syncing its labels and `topic_issue`) and `apps/cli/topics.ts` (`readProblems()` returns a topic's sub-issues plus its moved problems, so the finished rule and existing release bodies do not change when a problem moves). A Review issue finishes by `completion()` in `topics.ts` — every sub-issue closed, any set — which `close-topics.ts` closes it on and `release-topics.ts` releases it on, exactly like a topic: timever of the last close, same `<!-- release-topics: #N -->` marker (issue numbers never collide), body fields from the review's `**Scheduled:**`/`**Phase:**`/`**Load:**` lines.
- **Review days are plan days.** `schedule.json` books each Review issue on its own date — Mon/Tue/Thu/Fri only, Oct 1 → Nov 3 — as `review: { issue, title, problems }` (solve order). The digest renders it as a linked "Today's review" section with the issue's day rules (`REVIEW_RULES` in `email.tsx`, one sentence for HTML and text), its problems join `RESERVED` so drills and sampled gates never draw them early, and `gate: []` skips a Saturday's checkpoint (Oct 3). The issue's sub-issues stay the truth: the catalog reconcile compares every booked review's title and problem list with GitHub and returns any mismatch as `reviewDrift` (also logged), so a re-arranged Review issue shows up the next sync instead of silently disagreeing with the email. Move a review by editing its `schedule.json` date AND the issue's `**Scheduled:**` line (the release body reads it). - **Review days are plan days.** `schedule.json` books each Review issue on its own date — Mon/Tue/Thu/Fri only, Oct 1 → Nov 3 — as `review: { issue, title, problems }` (solve order). The digest renders it as a linked "Today's review" section with the issue's day rules (`REVIEW_RULES` in `email.tsx`, one sentence for HTML and text), its problems join `RESERVED` so drills and sampled gates never draw them early, and `gate: []` skips a Saturday's checkpoint (Oct 3). The issue's sub-issues stay the truth: the catalog reconcile compares every booked review's title and problem list with GitHub and returns any mismatch as `reviewDrift` (also logged), so a re-arranged Review issue shows up the next sync instead of silently disagreeing with the email. Move a review by editing its `schedule.json` date AND the issue's `**Scheduled:**` line (the release body reads it).
@@ -48,10 +49,10 @@ flowchart LR
|---|---| |---|---|
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape | | `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
| `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 | | `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 |
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `pages/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/(problems)/<num>_<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync (a phase's once, then hand-owned): the phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment, and `(problems)` is a second `(group)` inside it, so `tree-dfs/(problems)/226_invert-binary-tree.mdx` routes to `/tree-dfs/invert-binary-tree` (Blume sorts by the `<num>_` prefix and strips it from the route). The `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order; every topic meta is `display: "page"`, so a topic is one sidebar row whose panel holds its landing page and a "Problems" section. A problem page's badge row (between the description and the body) is rendered, not written: the MDX `wrapper` (`components/ProblemBadges.astro`, registered as `mdx.wrapper` in `components.ts` — Blume's page template renders the title and description itself with no slot before the body, so the wrapper, which MDX hands no route, reads it off `Astro.url`) reads the page's `topics:` (LeetCode's topic tags, from sync) and `video:` (NeetCode YouTube id, from `bun run videos`) frontmatter — both declared via `frontmatter.extend` in `blume.config.ts`, hand-rolled Standard Schemas, no zod — and links a tag the campaign teaches to its topic landing page (filled), any other tag to its card on the Appendix glossary `/appendix#<slug>` (outlined; LeetCode's problem list if the tag has no term, gray and iconless), and the video as a red `Explanation` badge. Every badge in the row is a pill. A tag badge carries the icon its target shows (a landing's: the topic `meta.ts` icon read off `data.navigation`; an Appendix term's: its `icon` in `appendix.ts`) and the tag's own colour from `lib/tag-colors.ts` — unique per tag site-wide, assigned greedily over every tag with a landing or a term so tags that share problem pages sit furthest apart in OKLab, in a lightness band that keeps ≥ 3:1 contrast in both themes (Blume's Badge takes one hex for both). The reverse direction is the `PageFooter` slot (`components/BacklinksList.astro`): an Obsidian-style `<num> <name>` list under every topic landing page of the problems whose badges link there, plus a topic's own folder members (so a topic no tag names, `hybrid-structures`, still lists its problems), in a native `<details>` open on load whose top-right chevron collapses it (no JS); problem pages are recognised by their `<num>. <name>` title and render no list. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), and where a badge points is ONE function beside it, `tagTarget()` — read by both components and the Appendix page (so badges, backlinks and term cards cannot disagree) and the map by sync. Every other tag a problem carries is ONE hand-written table, `apps/docs/appendix.ts` (`APPENDIX`: slug → `{ name, icon, category, definition }`), rendered by ONE custom page, `pages/appendix.astro` — a glossary with an A–Z letter index and a filter box (an `<appendix-filter>` custom element over static markup; no JS, the full list), one card per term (icon tinted the tag's colour, category, definition, LeetCode list link, and a `<details>` "Solved" list of the problem pages whose badge `tagTarget()` sends there, paged `PAGE_SIZE` (5) rows at a time by numbered buttons under it — a `<solved-pages>` custom element; without JS every row shows and the buttons stay hidden), each card `id`'d by slug and highlighted as `:target`. It is no content folder, so it enters neither the sidebar tree nor prev/next: `navigation.featured` pins it above the tree. Sync writes nothing for it — it warns about a carried tag with no term, a term whose tag a topic now teaches (delete it), and a term `icon` that is a `TOPIC_ICONS` value or another term's. The former per-term pages `/appendix/<slug>` are exact redirects to `/appendix#<slug>` in `blume.config.ts`, generated from `APPENDIX`'s keys. A hand-written `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is that topic's landing page: Blume ≥ 1.7.3 links the sidebar group row to it (`sliding-window/index.mdx` → `/sliding-window`) and the hidden flag drops the duplicate row. Two kinds of custom MDX component, both import-free on every page: `islands/<Name>.tsx` is hydrated React (auto-discovered; the five `/progress` cards `SolvedRing`/`SubmissionHeatmap`/`SubmissionChart`/`TopicBubbles`/`RecentSolves`, and `DependencySpine` — the README topic map as data, zoom/pan, nodes link to whatever route Blume's `/api/docs/navigation.json` reports for the topic folder, inert until a topic has pages), `components/<Name>.astro` registered in `components.ts` is static markup plus aLine truncated | `docs/` | The docs content, and nothing of the site: no `blume.config.ts`, no `meta.ts`, no components. prdlk/cs publishes it with Blume's `mdxRemote()` source, which fetches `.md`/`.mdx` files only. `docs/(0N-<phase>)/<topic>/(problems)/<num>_<slug>.mdx` is generated by sync; `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is a topic's hand-written landing page, and `index.mdx`/`keywords.mdx`/`progress.mdx` sit at the root. The phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment, and `(problems)` is a second `(group)` inside it, so `tree-dfs/(problems)/226_invert-binary-tree.mdx` routes to `/tree-dfs/invert-binary-tree` (Blume sorts by the `<num>_` prefix and strips it from the route). `docs/site.json` is sync's generated manifest and the site's ONE other input: `folders` (path under `docs/` → `{ title, order, icon?, display? }`, exactly what a `meta.ts` in that folder would say — every topic `display: "page"`, so it is one sidebar row whose panel holds its landing page and a "Problems" section) and `redirects` (the `/<num>` short links). Images are root-absolute (`![](/topo_intro_1.png)`, `<CarouselImage src='/binary_search.001.png' />`) and served from the site's `public/` — a page-relative path cannot resolve once the MDX is fetched over HTTP. Frontmatter keys beyond Blume's (`topics:`, `video:`, `issue:`) and the MDX components pages use (`<CodeGroup>`, `<Carousel>`/`<CarouselImage>`, the islands) are the site's to declare — add a new one there before a page uses it |
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | review | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) | | `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | review | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
| `.github/workflows/` | deploy (docs → Pages with `GITHUB_TOKEN` for the releases-backed changelog, Worker → Cloudflare with the `OPENROUTER_API_KEY` Actions secret put into it first, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) | | `.github/workflows/` | deploy (Worker → Cloudflare with the `OPENROUTER_API_KEY` Actions secret put into it first, on every main push; the docs site deploys from prdlk/cs), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
| `.githooks/` | Tracked git hooks; `bun install` points `core.hooksPath` here (root `prepare` script, no husky). `pre-commit` runs `bun run sync` and stages only what sync changed (pages, `apps/docs/redirects.ts`), so a solution, its page and its `/<num>` short link land in one commit; it refuses to run while `work/1` has anything unstaged (sync reads the tree, so the page would not match the commit), and leaves pre-existing dirt under `apps/docs/content` (in-progress `## Approach` prose) unstaged. `git commit --no-verify` for a deliberate partial commit | | `.githooks/` | `bun install` points `core.hooksPath` here (root `prepare` script, no husky). Its `pre-commit` hook (run `bun run sync`, stage only what sync changed — pages and `docs/site.json`) was removed in `e63d517`, so nothing runs on commit today: run `bun run sync` yourself before committing a solution, or its page and short link ship a commit later |
## Development Commands ## Development Commands
@@ -71,12 +72,11 @@ bun run test # run ONE solution against LeetCode's judge (not a test
# most recently tested first, stamped in the db on exit 0) # most recently tested first, stamped in the db on exit 0)
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
# hidden; last_submitted stamped in the db on exit 0) # hidden; last_submitted stamped in the db on exit 0)
bun run sync # work/1 → apps/docs/content/ pages + apps/docs/redirects.ts (/<num> short bun run sync # work/1 → docs/ pages + docs/site.json (folder meta + /<num> short
# links; re-solves are never published) # links; re-solves are never published)
bun run videos # docs pages with no `video:` frontmatter → NeetCode video per page bun run videos # docs pages with no `video:` frontmatter → NeetCode video per page
# (channel search scraped, no API key; --write adds the # (channel search scraped, no API key; --write adds the
# `video:` key; "no NeetCode match" pages stay untouched) # `video:` key; "no NeetCode match" pages stay untouched)
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 release-topics -- --dry-run # prints every would-be topic release body bun run release-topics -- --dry-run # prints every would-be topic release body
@@ -113,21 +113,20 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
- `apps/api/src/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice. - `apps/api/src/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice.
- `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/<CAMPAIGN_DAYS>) LeetCode Daily Digest` — 79 with the plan ending Nov 3; booking a later day in `schedule.json` lengthens it, and the heatmaps and `/api/stats` `heat` with it. - `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/<CAMPAIGN_DAYS>) LeetCode Daily Digest` — 79 with the plan ending Nov 3; booking a later day in `schedule.json` lengthens it, and the heatmaps and `/api/stats` `heat` with it.
- `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. - `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.
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`, with every problem page in the topic's `(problems)/` group as `<num>_<slug>.mdx` (a page is found by its frontmatter title, wherever it sits, so a layout change is one sync of `rename`s). The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "page"`); the phase `meta.ts` is written once when missing (`Phase N: Name`, `order: N`) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing through an emptied `(problems)` and topic into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`). - `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `docs/(0N-<phase>)/<topic>/`, with every problem page in the topic's `(problems)/` group as `<num>_<slug>.mdx` (a page is found by its frontmatter title, wherever it sits, so a layout change is one sync of `rename`s). `docs/site.json` is wholly script-owned and rewritten whenever it differs, because `mdxRemote()` reads no `meta.ts`: its `folders` are what a `meta.ts` per phase and topic folder would carry — a phase `N. Name` at `order: N`, a topic its exact label, curriculum order, `TOPIC_ICONS` icon and `display: "page"` — all derived from the README, so a rename there is a rename on the site; its `redirects` are the `/<num>` → `/<topic>/<slug>` short links. Which tags the site teaches as topics (`topic-landings.ts`) and its Appendix glossary (`appendix.ts`) are the site's, in prdlk/cs: sync no longer checks them. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing through an emptied `(problems)` and topic into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here. - `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` / `apps/docs/lib/stats.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs `/progress` islands and `bun run stats`, so its shape is their contract: change the Worker and both readers together (additive fields a reader ignores are safe). `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule. - `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs site's `/progress` islands (prdlk/cs `lib/stats.ts`) and `bun run stats`, so its shape is a cross-repo contract: change the Worker and both readers together (additive fields a reader ignores are safe). `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache). The docs `/progress` page is five LeetCode-style islands — `SolvedRing` (difficulty gauge + "Attempting"), `SubmissionHeatmap` and `SubmissionChart` sharing one row (the heatmap: the last six months in month blocks, the README's colour ramp as theme.css `--heat-0..4`, its attempts / active days / max streak counted from the cells it draws; the chart: this calendar year as stacked Easy/Med./Hard bars per month or week, first solves or every attempt, via two shadcn `ToggleGroup`s), `TopicBubbles` (the campaign topics circle-packed by `d3-hierarchy`, filled by solved share), `RecentSolves` (the ten most recent, linked through `redirects.ts`) — composed from shadcn/ui (`components/ui/{card,chart,skeleton,toggle,toggle-group}.tsx`, Recharts under `chart`, Radix under the toggles) on Blume's tokens: `apps/docs/theme.css` maps shadcn's names onto `--blume-*` (never `accent`, which is Blume's brand colour — vendored components that use it, like `toggle.tsx`, are retokened to `background`/`muted`) and gives shadcn's `data-slot` parts their border colour. They share `lib/stats.ts` (`useStats()`), so the page makes ONE `/api/stats` request however many islands hydrate. shadcn sources live outside `islands/` (every `.tsx` there becomes an island) and import `lib/utils` relatively; `components.json` + `tsconfig.json` exist for the shadcn CLI and `tsc`, so after `shadcn add` rewrite `@/…` imports to relative ones (house style) — and check `package.json`: the CLI has twice installed an unrelated `cn` package for `lib/utils` and skipped real deps (`class-variance-authority`). Blume turns every tsconfig `paths` target into a Vite alias AND a dev dep-scan glob (`<target>/**/*.astro`…), so `paths` maps only `@/components`, `@/lib`, `@/hooks` — each bare and `/*`, since `components.json` names the bare forms — never `@/*` → `./*`: that glob reaches through `node_modules/blume`, where Vite's scanner reads a `<script>` inside a `RootLayout.astro` comment as a tag and `blume dev` skips dependency pre-bundling. Blume's `.prose :where(a, p, ol)` rules ignore `not-prose`, so island markup overrides them with `!` utilities, as Blume's own components do. - `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`, `PHASE_ALIASES`). `/api/stats` and `/ask` reflect a requesting origin only when it is in the `DOCS_ORIGIN` var (`apps/api/wrangler.jsonc`, comma-separated: `https://lc.prd.lk,https://prdlk.github.io`) — a new host for the docs site must be added there.
- `apps/docs/blume.config.ts` — site base `/leetcode`; imports `apps/docs/redirects.ts`, sync-generated (`/<num>` → `/<topic>/<slug>`, one row per page, exact root-relative paths — Blume adds the base and, for the static Pages build, emits a meta-refresh page per rule plus `blume-redirects.json`); `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`, `PHASE_ALIASES`).
- Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record. - Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
## Runtime/Tooling Preferences ## Runtime/Tooling Preferences
- **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. - **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`.
- **Near-zero runtime npm dependencies** — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. Two exceptions, each confined to one surface. 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`. And the docs `/progress` islands: shadcn/ui (`recharts`, `radix-ui`, `class-variance-authority`, `clsx`, `tailwind-merge`) plus `d3-hierarchy` for the bubble packing, client-side in `apps/docs` only. Do not extend either exception to any other module. - **Near-zero runtime npm dependencies** — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. One exception, confined to one surface: 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 it to any other module. (The docs site's own dependencies are prdlk/cs's.)
- 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: `apps/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` (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). - Docs changes: the site builds in prdlk/cs, which must pass `bunx blume build --isolated` with no new warnings against this repo's `docs/`; after sync, review only *created* pages (prose transforms are heuristic) and `docs/site.json`'s diff.
-1
View File
@@ -1 +0,0 @@
.blume-verify/
-232
View File
@@ -1,232 +0,0 @@
/**
* The Appendix glossary: one term per LeetCode topic tag that problem pages
* carry but no campaign topic teaches (not a key of `topic-landings.ts`).
* `pages/appendix.astro` renders each as a card — icon, category, definition,
* and the solved problems carrying the tag — anchored at `/appendix#<slug>`,
* which is where `tagTarget()` sends that tag's badge on a problem page.
*
* Hand-written: `apps/cli/sync.ts` only checks it, warning about a carried
* tag with no entry (its card renders bare), an entry whose tag a campaign
* topic now teaches (dead weight, delete it), and an icon that is a topic's
* (`TOPIC_ICONS`) or another term's — the icon is also the one on every
* badge linking here, so it must not read as anything else. Plain data, no
* Astro imports: sync imports this file under Bun.
*/
export type AppendixCategory = "Data Structures" | "Algorithms" | "Techniques";
export interface AppendixTerm {
/** LeetCode's tag name. */
name: string;
/** Lucide icon, unique across terms and topics. */
icon: string;
category: AppendixCategory;
/** One sentence: what the term means, the way a solution uses it. */
definition: string;
}
export const APPENDIX: Record<string, AppendixTerm> = {
// ── data structures ──────────────────────────────────────────────
array: {
name: "Array",
icon: "list-ordered",
category: "Data Structures",
definition: "A contiguous, index-addressed sequence: O(1) access by position, O(n) to insert or delete in the middle.",
},
"binary-tree": {
name: "Binary Tree",
icon: "git-branch",
category: "Data Structures",
definition: "A rooted tree in which every node has at most two children, left and right.",
},
"directed-acyclic-graph": {
name: "Directed Acyclic Graph",
icon: "route",
category: "Data Structures",
definition: "A directed graph with no cycles, which is exactly what lets its nodes be put in a topological order.",
},
"doubly-linked-list": {
name: "Doubly-Linked List",
icon: "link-2",
category: "Data Structures",
definition: "A linked list whose nodes point to both neighbours, so a node you already hold is unlinked in O(1).",
},
graph: {
name: "Graph Theory",
icon: "share-2",
category: "Data Structures",
definition: "Nodes joined by edges, directed or not, weighted or not, stored as an adjacency list or matrix.",
},
"k-d-tree": {
name: "K-D Tree",
icon: "axis-3d",
category: "Data Structures",
definition: "A binary tree that splits k-dimensional points on alternating axes, for nearest-neighbour and range queries.",
},
queue: {
name: "Queue",
icon: "list-end",
category: "Data Structures",
definition: "First in, first out: push at the back, pop from the front, both O(1).",
},
string: {
name: "String",
icon: "quote",
category: "Data Structures",
definition: "A sequence of characters; solutions lean on its small alphabet, its substrings, and its immutability.",
},
tree: {
name: "Tree",
icon: "tree-deciduous",
category: "Data Structures",
definition: "A connected acyclic graph, usually rooted, in which every node but the root has exactly one parent.",
},
// ── algorithms ───────────────────────────────────────────────────
"bidirectional-search": {
name: "Bidirectional Search",
icon: "move-horizontal",
category: "Algorithms",
definition: "Search from the start and the goal at once and stop where the frontiers meet, about 2·b^(d/2) states instead of b^d.",
},
"binary-lifting": {
name: "Binary Lifting",
icon: "chevrons-up",
category: "Algorithms",
definition: "Precompute every node's 2^k-th ancestor so any k-th ancestor, and so the LCA, takes O(log n) jumps.",
},
"bit-manipulation": {
name: "Bit Manipulation",
icon: "binary",
category: "Algorithms",
definition: "Treat integers as bit vectors: masks, shifts, and XOR in place of arithmetic or extra memory.",
},
"bracket-sequences": {
name: "Bracket Sequences",
icon: "parentheses",
category: "Algorithms",
definition: "Strings of opening and closing brackets; balanced means the running depth never dips below zero and ends at zero.",
},
"bucket-sort": {
name: "Bucket Sort",
icon: "container",
category: "Algorithms",
definition: "Scatter values into buckets by range or frequency, then read the buckets in order: linear when the key range is bounded.",
},
"complete-knapsack": {
name: "Complete Knapsack",
icon: "package-plus",
category: "Algorithms",
definition: "Knapsack in which every item may be taken any number of times, so the DP walks capacity upward and reuses the row.",
},
"divide-and-conquer": {
name: "Divide and Conquer",
icon: "split",
category: "Algorithms",
definition: "Split the input into independent parts, solve each recursively, and combine their answers.",
},
"floyds-cycle-finding-algorithm": {
name: "Floyd's Cycle Finding Algorithm",
icon: "repeat",
category: "Algorithms",
definition: "A slow and a fast pointer meet inside any cycle; restarting one from the head finds the cycle's entry, in O(1) space.",
},
"knapsack-problem": {
name: "Knapsack Problem",
icon: "backpack",
category: "Algorithms",
definition: "Pick items with weights and values to maximise value under a capacity; the 0/1 version is an O(n·W) DP.",
},
"longest-common-subsequence": {
name: "Longest Common Subsequence",
icon: "diff",
category: "Algorithms",
definition: "The longest sequence that appears in order, not necessarily contiguously, in two strings: the classic O(n·m) 2-D DP.",
},
"lowest-common-ancestor": {
name: "Lowest Common Ancestor",
icon: "git-merge",
category: "Algorithms",
definition: "The deepest node of a rooted tree that is an ancestor of both given nodes.",
},
memoization: {
name: "Memoization",
icon: "database",
category: "Algorithms",
definition: "Cache a pure function's result per argument so every subproblem is solved once: top-down dynamic programming.",
},
"merge-sort": {
name: "Merge Sort",
icon: "blend",
category: "Algorithms",
definition: "Sort each half recursively, then merge the two sorted halves: O(n log n), stable, O(n) extra space.",
},
quickselect: {
name: "Quickselect",
icon: "crosshair",
category: "Algorithms",
definition: "Partition around a pivot and recurse into one side only, finding the k-th smallest element in O(n) on average.",
},
quicksort: {
name: "Quicksort",
icon: "shuffle",
category: "Algorithms",
definition: "Partition around a pivot, then sort each side in place: O(n log n) on average, O(n²) at worst.",
},
recursion: {
name: "Recursion",
icon: "iteration-ccw",
category: "Algorithms",
definition: "Solve a problem by calling the same function on smaller inputs until a base case answers directly.",
},
simulation: {
name: "Simulation",
icon: "monitor-play",
category: "Algorithms",
definition: "Carry out the process the statement describes, step by step; the work is modelling the state faithfully.",
},
sorting: {
name: "Sorting",
icon: "arrow-down-wide-narrow",
category: "Algorithms",
definition: "Order the input first so a later step, such as two pointers, a greedy choice, or binary search, becomes possible.",
},
"tournament-sort": {
name: "Tournament Sort",
icon: "trophy",
category: "Algorithms",
definition: "A winner tree of pairwise comparisons: the root is the extreme element, and replaying one path yields the next in O(log n).",
},
// ── techniques ───────────────────────────────────────────────────
combinatorics: {
name: "Combinatorics",
icon: "dices",
category: "Techniques",
definition: "Count arrangements and selections (permutations, combinations, Catalan numbers), usually modulo a prime.",
},
counting: {
name: "Counting",
icon: "tally-5",
category: "Techniques",
definition: "Tally occurrences in a hash map or fixed array, then answer from the counts instead of the raw input.",
},
design: {
name: "Design",
icon: "pencil-ruler",
category: "Techniques",
definition: "Implement a class to an API spec, choosing the internals so every operation meets its time bound.",
},
geometry: {
name: "Geometry",
icon: "shapes",
category: "Techniques",
definition: "Points, lines, and areas on a plane or grid: distances, slopes, overlaps, and orientation by cross product.",
},
math: {
name: "Math",
icon: "calculator",
category: "Techniques",
definition: "An arithmetic or number-theory insight (modular arithmetic, GCD, digit manipulation, a closed form) in place of brute force.",
},
};
-152
View File
@@ -1,152 +0,0 @@
import { defineConfig } from "blume";
import type { StandardSchema } from "blume/core/standard-schema.ts";
import { filesystem } from "blume/sources";
import { APPENDIX } from "./appendix";
import redirects from "./redirects";
// Blume's defaults (`inter`, `ibm-plex-mono`) are fetched from fonts.gstatic.com
// on every build, which has timed out on the Pages runner and, fully offline,
// silently ships no fonts at all. The same two families from Fontsource, as
// local files, keep the build network-free. Blume resolves `src` from this
// directory; the workspace links these packages under ./node_modules.
const inter = {
name: "Inter",
variants: [
{
src: "./node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2",
weight: "100..900",
},
],
};
const plexMono = {
name: "IBM Plex Mono",
variants: [
{
src: "./node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-400-normal.woff2",
weight: 400,
},
{
src: "./node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
weight: 500,
},
],
};
/**
* Custom frontmatter, rendered as the badge row by
* components/ProblemBadges.astro. Problem pages: `topics:` is LeetCode's topic
* tags and `issue:` the problem's GitHub issue URL (both written by
* apps/cli/sync.ts), `video:` the NeetCode walkthrough's
* YouTube id (apps/cli/videos.ts). Blume rejects undeclared keys and takes
* any Standard Schema; these are hand-rolled so the docs workspace needs no
* zod. All optional — every other page carries none.
*/
const optional = <T>(
message: string,
valid: (value: unknown) => value is T,
): StandardSchema<unknown, T | undefined> => ({
"~standard": {
version: 1,
vendor: "prdlk",
validate: (value) =>
value === undefined || valid(value)
? { value }
: { issues: [{ message }] },
},
});
const topicsSchema = optional(
"expected a list of { name, slug } LeetCode topic tags",
(value): value is { name: string; slug: string }[] =>
Array.isArray(value) &&
value.every(
(t) => typeof t?.name === "string" && typeof t?.slug === "string",
),
);
const videoSchema = optional(
"expected an 11-character YouTube video id",
(value): value is string =>
typeof value === "string" && /^[\w-]{11}$/.test(value),
);
const issueSchema = optional(
"expected a https://github.com/<owner>/<repo>/issues/<n> URL",
(value): value is string =>
typeof value === "string" &&
/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+$/.test(value),
);
export default defineConfig({
title: "prdlk",
description: "Documentation for my LeetCode solutions",
logo: "./public/logo.svg",
deployment: {
site: "https://lc.prd.lk",
},
github: {
owner: "prdlk",
repo: "leetcode",
},
lastModified: "git",
// Sync's `/<num>` short links, then the Appendix's former per-term pages
// (`/appendix/<slug>`, published until the glossary became one page) onto
// their cards, so old links and search results still land.
redirects: [
...redirects,
...Object.keys(APPENDIX).map((slug) => ({
from: `/appendix/${slug}`,
to: `/appendix#${slug}`,
})),
],
frontmatter: {
extend: {
topics: topicsSchema,
video: videoSchema,
issue: issueSchema,
},
},
seo: {
og: { enabled: true },
// No `rss`: the changelog is one custom page (pages/changelog.astro), not
// `type: changelog` entries, so a changelog feed would have nothing in it.
sitemap: true,
robots: true,
structuredData: true,
x: { handle: "@prdlk" },
},
// Blume's own assistant route is server-rendered (it reads the provider key
// per request), which a static Pages build cannot ship. The SRS Worker
// answers instead (apps/api/src/ask.ts: OpenRouter, grounded on this site's
// llms-full.txt, which `agents.llmsTxt` publishes).
ai: {
assistant: {
enabled: true,
endpoint: "https://srs-api.prdlk.workers.dev/ask",
},
},
agents: {
llmsTxt: true,
mcp: { enabled: false },
},
// The only content source. `/changelog` and `/appendix` are custom pages
// (pages/*.astro): `/changelog` reads the GitHub releases itself — one
// timeline, no per-release pages — and `/appendix` is one glossary page
// built from appendix.ts, so neither enters the sidebar tree or prev/next.
content: {
sources: [filesystem({ root: "content" })],
},
theme: {
fonts: { display: inter, body: inter, mono: plexMono },
},
navigation: {
repo: "https://github.com/prdlk/leetcode",
sidebar: {
display: "flat", // "flat" | "group" | "page"
},
// The glossary is no content folder any more, so it is pinned above the
// sidebar tree, on every page and breakpoint.
featured: [
{ label: "Changelog", href: "/changelog", icon: "clock" },
{ label: "Appendix", href: "/appendix", icon: "library" },
],
},
});
-21
View File
@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "theme.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
-20
View File
@@ -1,20 +0,0 @@
/**
* Project MDX components, available on every page with no import, and layout
* slot overrides. Static `.astro` files ship no framework JS (a custom element
* carries any behaviour); interactive React lives in `islands/` instead.
*/
import { defineComponents } from "blume";
export default defineComponents({
mdx: {
Carousel: "./components/Carousel.astro",
CarouselImage: "./components/CarouselImage.astro",
// MDX's own content wrapper: problem pages' topic + explanation badges, from
// `topics:` / `video:` frontmatter, between the description and the body.
wrapper: "./components/ProblemBadges.astro",
},
layout: {
// Topic landing pages' "what links here": every problem pointing at the page.
PageFooter: "./components/BacklinksList.astro",
},
});
-95
View File
@@ -1,95 +0,0 @@
---
/**
* The `PageFooter` slot: an Obsidian-style backlinks list under a topic landing
* page — every problem page that points at it, as `<num> <name>` rows in
* LeetCode-number order. Pages nothing points at (problem pages, the
* overview) render nothing; a tag no topic teaches lists its problems on its
* Appendix card instead (`pages/appendix.astro`), from the same answer.
*
* A problem points at a page when either
* - one of its topic badges links there — the same `tagTarget()` answer
* `ProblemBadges.astro` renders, so this list and the badges cannot drift
* (a DFS problem is a backlink of the one DFS landing its badge picks); or
* - it lives under the page's route (`/hybrid-structures/lru-cache` under
* `/hybrid-structures`), so a topic no LeetCode tag names still lists its
* own problems.
*
* Problem pages are recognised by their `<num>. <name>` title, the shape
* `apps/cli/sync.ts` writes.
*
* The list is a native `<details>`, open on load: the chevron at the top
* right of the header row collapses it like an accordion, with no JS.
*/
import { getEntry } from "astro:content";
import data from "blume:data";
import { withBase } from "blume/components/islands/base-path.ts";
import Icon from "blume/components/Icon.astro";
import { tagTarget, type TopicTag } from "../topic-landings.ts";
interface Props {
route: string;
}
interface Route {
path: string;
title: string;
collection: string;
entryId: string;
}
const { route } = Astro.props;
const routes: Route[] = data.routes;
const served = new Set(routes.map((r) => r.path));
const problems = routes.flatMap((r) => {
const m = r.title.match(/^(\d+)\.\s+(.+)$/);
return m ? [{ route: r, num: Number(m[1]), name: m[2]! }] : [];
});
// A problem page is never a backlink target: skip the per-problem lookups there.
const backlinks = problems.some((p) => p.route.path === route)
? []
: (
await Promise.all(
problems.map(async (p) => {
if (p.route.path.startsWith(`${route}/`)) return p;
const entry = await getEntry(p.route.collection, p.route.entryId);
const tags: TopicTag[] = entry?.data.topics ?? [];
const page = { route: p.route.path, tags };
return tags.some((tag) => tagTarget(tag, page, served).href === route) ? p : undefined;
}),
)
)
.filter((p) => p !== undefined)
.sort((a, b) => a.num - b.num);
---
{
backlinks.length > 0 && (
<details
class="group not-prose mx-auto mt-12 max-w-content border-border border-t pt-6"
open
>
<summary class="mb-2 flex cursor-pointer list-none items-center justify-between gap-2 text-muted-foreground transition-colors hover:text-foreground [&::-webkit-details-marker]:hidden">
<h2 class="flex items-baseline gap-2 font-medium text-xs uppercase tracking-wide" id="backlinks">
Backlinks
<span class="tabular-nums">{backlinks.length}</span>
</h2>
<Icon class="shrink-0 transition-transform group-open:rotate-180" name="chevron-down" size={16} />
</summary>
<ul class="text-sm">
{backlinks.map((p) => (
<li>
<a
class="-mx-2 flex items-baseline gap-3 rounded-md px-2 py-1 text-foreground no-underline transition-colors hover:bg-muted"
href={withBase(p.route.path)}
>
<span class="w-10 shrink-0 text-right font-mono text-muted-foreground text-xs tabular-nums">{p.num}</span>
<span class="truncate">{p.name}</span>
</a>
</li>
))}
</ul>
</details>
)
}
-170
View File
@@ -1,170 +0,0 @@
---
/**
* A horizontal carousel of <CarouselImage> slides, built the way Blume's own
* content components are: static markup, a custom element for the behaviour,
* no framework. The track is a CSS scroll-snap strip (so it also swipes on
* touch and the browser does the animation); prev/next sit outside it and
* the play toggle sits below with a "n / N" counter.
*
* `time` is seconds per slide while playing (default 1.25); `autoplay` starts
* playing on load. Play wraps around, pauses whenever the tab is hidden, and
* never starts on its own for a reader who prefers reduced motion — the
* button still works for them. Manual navigation (buttons, arrow keys, swipe)
* does not stop playback; it only restarts the interval so a step is never
* doubled.
*/
import Icon from "blume/components/Icon.astro";
interface Props {
/** Seconds each slide stays while playing. */
time?: number;
autoplay?: boolean;
}
const { time = 1.25, autoplay = false } = Astro.props;
const buttonClass =
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors hover:border-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40";
---
<blume-carousel
class="not-prose my-6 block"
data-time={String(time)}
data-autoplay={autoplay ? "" : undefined}
role="region"
aria-roledescription="carousel"
>
<div class="flex items-center gap-3">
<button class={buttonClass} data-prev type="button" aria-label="Previous slide">
<Icon class="rtl:-scale-x-100" name="chevron-left" size={18} />
</button>
<div
class="flex min-w-0 flex-1 snap-x snap-mandatory overflow-x-auto scroll-smooth rounded-blume [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
data-track
tabindex="0"
aria-live="polite"
>
<slot />
</div>
<button class={buttonClass} data-next type="button" aria-label="Next slide">
<Icon class="rtl:-scale-x-100" name="chevron-right" size={18} />
</button>
</div>
<div class="mt-3 flex items-center justify-center gap-3">
<button class={buttonClass} data-play type="button" aria-label="Play" aria-pressed="false">
<Icon class="[[aria-pressed=true]_&]:hidden" name="play" size={16} />
<Icon class="[[aria-pressed=false]_&]:hidden" name="pause" size={16} />
</button>
<span class="text-muted-foreground text-xs tabular-nums leading-5" data-count></span>
</div>
</blume-carousel>
<script>
class BlumeCarousel extends HTMLElement {
#track!: HTMLElement;
#play!: HTMLButtonElement;
#count!: HTMLElement;
#timer: number | null = null;
#index = 0;
connectedCallback() {
this.#track = this.querySelector("[data-track]")!;
this.#play = this.querySelector("[data-play]")!;
this.#count = this.querySelector("[data-count]")!;
const prev = this.querySelector<HTMLButtonElement>("[data-prev]")!;
const next = this.querySelector<HTMLButtonElement>("[data-next]")!;
const slides = this.#slides().length;
if (slides < 2) {
for (const b of [prev, next, this.#play]) b.disabled = true;
}
this.#render();
prev.addEventListener("click", () => this.#step(-1));
next.addEventListener("click", () => this.#step(1));
this.#play.addEventListener("click", () => (this.#timer === null ? this.#start() : this.#stop()));
this.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft") this.#step(-1);
else if (event.key === "ArrowRight") this.#step(1);
else return;
event.preventDefault();
});
// Swipes and trackpad scrolls move the track without #step; follow them
// once the scroll settles. Settle-only, not per event: a long smooth
// scroll from #step passes over intermediate slides and must not be
// read back as the reader's position mid-flight.
let settle: number | undefined;
this.#track.addEventListener(
"scroll",
() => {
window.clearTimeout(settle);
settle = window.setTimeout(() => this.#syncFromScroll(), 150);
},
{ passive: true }
);
document.addEventListener("visibilitychange", this.#onVisibility);
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (this.hasAttribute("data-autoplay") && slides > 1 && !reduced) this.#start();
}
disconnectedCallback() {
this.#stop();
document.removeEventListener("visibilitychange", this.#onVisibility);
}
#slides(): HTMLElement[] {
return [...this.#track.querySelectorAll<HTMLElement>(":scope > blume-carousel-image")];
}
#step(delta: number) {
const slides = this.#slides();
this.#index = (this.#index + delta + slides.length) % slides.length;
slides[this.#index]!.scrollIntoView({ block: "nearest", inline: "start", behavior: "smooth" });
this.#render();
if (this.#timer !== null) this.#start(); // restart the interval from this slide
}
#syncFromScroll() {
const at = Math.round(this.#track.scrollLeft / this.#track.clientWidth);
if (at !== this.#index && Number.isFinite(at)) {
this.#index = Math.min(Math.max(at, 0), this.#slides().length - 1);
this.#render();
}
}
#start() {
this.#stop();
const ms = Number(this.dataset.time) * 1000;
this.#timer = window.setInterval(() => this.#step(1), ms > 0 ? ms : 1250);
this.#play.setAttribute("aria-pressed", "true");
this.#play.setAttribute("aria-label", "Pause");
}
#stop() {
if (this.#timer !== null) window.clearInterval(this.#timer);
this.#timer = null;
this.#play.setAttribute("aria-pressed", "false");
this.#play.setAttribute("aria-label", "Play");
}
// A background tab keeps its timers but not its layout; resume on return.
#wasPlaying = false;
#onVisibility = () => {
if (document.hidden) {
this.#wasPlaying = this.#timer !== null;
this.#stop();
} else if (this.#wasPlaying) this.#start();
};
#render() {
const slides = this.#slides();
this.#count.textContent = `${this.#index + 1} / ${slides.length}`;
slides.forEach((s, i) => s.setAttribute("aria-hidden", i === this.#index ? "false" : "true"));
}
}
if (!customElements.get("blume-carousel")) {
customElements.define("blume-carousel", BlumeCarousel);
}
</script>
-45
View File
@@ -1,45 +0,0 @@
---
/**
* One slide of a <Carousel>: Blume's own <Frame> around an <img>, so a slide
* is pixel-identical to a framed image elsewhere on the site — `alt` doubles
* as the frame's hint line and `caption` is Markdown, exactly as in Frame.
*
* `src` accepts what MDX authors already write for images: a URL, a
* root-relative `/file.png` (served from public/, prefixed with the deploy
* base), or the page-relative `../../public/file.png` form. A relative path
* that does not pass through public/ has nothing to serve it, so it is a
* build error rather than a silent 404.
*/
import Frame from "blume/components/content/Frame.astro";
import { withBase } from "blume/components/islands/base-path.ts";
interface Props {
src: string;
alt?: string;
caption?: string;
}
const { src, alt, caption } = Astro.props;
const resolveSrc = (value: string): string => {
if (URL.canParse(value)) return value;
if (value.startsWith("/")) return withBase(value);
const at = value.indexOf("public/");
if (at === -1) {
throw new Error(
`<CarouselImage src="${value}">: a relative src must point into public/ (or use /file.png)`
);
}
return withBase(`/${value.slice(at + "public/".length)}`);
};
---
<blume-carousel-image
class="block w-full shrink-0 snap-center [&_blume-frame]:my-0"
role="group"
aria-roledescription="slide"
>
<Frame caption={caption} hint={alt}>
<img alt={alt ?? ""} src={resolveSrc(src)} loading="lazy" draggable="false" />
</Frame>
</blume-carousel-image>
-134
View File
@@ -1,134 +0,0 @@
---
/**
* The MDX `wrapper`: the badge row between a page's description and its body.
* Blume's page template renders the title and description itself and then
* the MDX content, with no slot in between, so wrapping the content is the
* only way to land right after the description. MDX hands a wrapper no
* route, so it is read off the URL, and the page's frontmatter is then
* looked up through the route manifest — the same `getEntry` call Blume's own
* page template makes. Pages with none of the keys below (landing pages, the
* changelog) render only their content.
*
* - `topics:` (problem pages, written by `apps/cli/sync.ts`) is LeetCode's own
* topic tags. A tag the campaign teaches (`topic-landings.ts`) links to that
* topic's landing page and is filled; any other tag links to its card on the
* Appendix glossary, `/appendix#<slug>` (`appendix.ts`), and is outlined.
* `tagTarget()` picks the route (and between two landings, e.g. DFS); the
* `PageFooter` backlinks list and the Appendix page read the same answer.
* Each tag badge is a pill carrying the icon its target shows (a topic's
* `meta.ts` icon in the sidebar, a term's `icon` on its card) and the tag's
* own colour (`lib/tag-colors.ts`), assigned over every tag the site has a
* page or card for — no two tags share one, and tags that share problem
* pages are spread furthest apart. A tag with neither (the LeetCode-list
* fallback) stays gray and iconless.
* - `video:` (problem pages, written by `apps/cli/videos.ts`) is the NeetCode
* walkthrough's YouTube id, the red "Explanation" badge.
* - `issue:` (problem pages, written by sync) is the problem's GitHub issue
* URL, a neutral "Issue #N" badge with the GitHub mark, right after
* "Explanation" at the end of the row.
*/
import { getEntry } from "astro:content";
import data from "blume:data";
import Badge from "blume/components/content/Badge.astro";
import { stripBase, withBase } from "blume/components/islands/base-path.ts";
import { APPENDIX } from "../appendix.ts";
import { tagColors } from "../lib/tag-colors.ts";
import { TOPIC_LANDINGS, tagTarget, type TopicTag } from "../topic-landings.ts";
// `/sub/tree-dfs/invert-binary-tree/` (or `.../index.html` in a static build)
// -> `/tree-dfs/invert-binary-tree`, the base-less shape of `data.routes`.
const route =
stripBase(import.meta.env.BASE_URL ?? "/", Astro.url.pathname)
.replace(/(\/index)?\.html$/u, "")
.replace(/(.)\/+$/u, "$1");
const at = data.routes.find((r: { path: string }) => r.path === route);
const entry = at ? await getEntry(at.collection, at.entryId) : undefined;
const tags: TopicTag[] = entry?.data.topics ?? [];
const video: string | undefined = entry?.data.video;
const issue: string | undefined = entry?.data.issue;
const served = new Set<string>(data.routes.map((r: { path: string }) => r.path));
// route → the icon its sidebar row shows: a page's own, or a topic group's
// (from its `meta.ts`) for the landing page the group row links to.
interface NavNode {
route?: string;
icon?: string;
children?: NavNode[];
}
const icons = new Map<string, string>();
const walk = (nodes: NavNode[]) => {
for (const node of nodes) {
if (node.route && node.icon && !icons.has(node.route)) icons.set(node.route, node.icon);
if (node.children) walk(node.children);
}
};
walk(data.navigation.sidebar);
// Every tag with a landing or an Appendix card, coloured against how problem
// pages (`<num>. <name>` titles, the shape sync writes) combine them. Only a
// page with tags needs the answer.
const colors = tags.length
? tagColors(
[...Object.keys(TOPIC_LANDINGS), ...Object.keys(APPENDIX)],
await Promise.all(
data.routes
.filter((r: { title: string }) => /^\d+\.\s/u.test(r.title))
.map(async (r: { collection: string; entryId: string }) => {
const page = await getEntry(r.collection, r.entryId);
return (page?.data.topics ?? []).map((t: TopicTag) => t.slug);
}),
),
)
: new Map<string, string>();
const badges = tags.map((tag) => {
const target = tagTarget(tag, { route, tags }, served);
const external = target.kind === "external";
return {
name: tag.name,
href: external ? target.href : withBase(target.href),
external,
icon: target.kind === "appendix" ? APPENDIX[tag.slug]?.icon : external ? undefined : icons.get(target.href),
color: external ? undefined : colors.get(tag.slug),
stroke: target.kind !== "landing",
};
});
const linkClass = "no-underline transition-opacity hover:opacity-80";
---
{
(badges.length > 0 || video || issue) && (
<div class="not-prose mb-6 flex flex-wrap gap-1.5">
{badges.map((b) => (
<a
href={b.href}
target={b.external ? "_blank" : undefined}
rel={b.external ? "noopener" : undefined}
class={linkClass}
>
<Badge color={b.color} icon={b.icon} shape="pill" stroke={b.stroke}>
{b.name}
</Badge>
</a>
))}
{video && (
<a href={`https://www.youtube.com/watch?v=${video}`} target="_blank" rel="noopener" class={linkClass}>
<Badge variant="danger" icon="youtube" shape="pill">
Explanation
</Badge>
</a>
)}
{issue && (
<a href={issue} target="_blank" rel="noopener" class={linkClass}>
<Badge icon="github" shape="pill">
Issue #{issue.slice(issue.lastIndexOf("/") + 1)}
</Badge>
</a>
)}
</div>
)
}
<slot />
-67
View File
@@ -1,67 +0,0 @@
/**
* What the four progress islands share besides the data: the not-ready body
* (a skeleton while /api/stats loads, one muted line if it fails) and the
* hover tooltip for the two charts drawn as plain SVG (the heatmap calendar
* and the topic bubbles — Recharts has neither layout). The tooltip wears the
* exact classes of shadcn's ChartTooltipContent, so all four cards' tooltips
* look like one component.
*/
import { type PointerEvent, type ReactNode, useRef, useState } from "react";
import { cn } from "../../lib/utils";
import type { Load } from "../../lib/stats";
import { Skeleton } from "../ui/skeleton";
export function StatsPending({ load, className }: { load: Exclude<Load, { phase: "ready" }>; className: string }) {
if (load.phase === "loading") return <Skeleton className={className} />;
return (
<p className="my-0! text-muted-foreground text-sm">
Stats unavailable — they are served live from the SRS Worker, and it did not respond.
</p>
);
}
interface Tip {
x: number;
y: number;
content: ReactNode;
}
/**
* A pointer-following tooltip for SVG marks. Put `ref` on a `relative`
* wrapper, call `show` from a mark's pointer events, render `tip` inside the
* wrapper. Positions are relative to the wrapper, so page scroll never skews it.
*/
export function useHoverTip<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [tip, setTip] = useState<Tip | null>(null);
const show = (event: PointerEvent, content: ReactNode) => {
const box = ref.current?.getBoundingClientRect();
if (box) setTip({ x: event.clientX - box.left, y: event.clientY - box.top, content });
};
const hide = () => setTip(null);
const node = tip && (
<div
className={cn(
"pointer-events-none absolute z-10 grid min-w-[8rem] -translate-x-1/2 -translate-y-[calc(100%+0.75rem)] items-start gap-1.5",
"rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
)}
style={{ left: tip.x, top: tip.y }}
>
{tip.content}
</div>
);
return { ref, show, hide, node };
}
/** One tooltip row, the shape ChartTooltipContent draws: colour dot, label, mono value. */
export function TipRow({ color, label, value }: { color: string; label: ReactNode; value: ReactNode }) {
return (
<div className="flex w-full items-center gap-2">
<div className="size-2.5 shrink-0 rounded-[2px]" style={{ background: color }} />
<div className="flex flex-1 items-center justify-between gap-4 leading-none">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium font-mono text-foreground tabular-nums">{value}</span>
</div>
</div>
);
}
-91
View File
@@ -1,91 +0,0 @@
import * as React from "react"
import { cn } from "../../lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
-372
View File
@@ -1,372 +0,0 @@
import * as React from "react"
import { cn } from "../../lib/utils"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
// Format: { THEME_NAME: CSS_SELECTOR }. Blume switches dark mode with
// [data-theme="dark"] on <html>, not shadcn's .dark class.
const THEMES = { light: "", dark: ':root[data-theme="dark"]' } as const
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
initialDimension?: {
width: number
height: number
}
}) {
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color ?? item.payload?.fill ?? item.color
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
-13
View File
@@ -1,13 +0,0 @@
import { cn } from "../../lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
-83
View File
@@ -1,83 +0,0 @@
"use client"
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
import { toggleVariants } from "./toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
-47
View File
@@ -1,47 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
import { Toggle as TogglePrimitive } from "radix-ui"
// Blume's `accent` is its brand colour, not shadcn's subtle surface: the on
// and hover states use background/muted instead (see theme.css).
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-muted hover:text-foreground",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Binary Search", order: 5, icon: "search", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Hash Table", order: 1, icon: "hash", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "1. Linear Structures", order: 1 });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Monotonic Stack", order: 7, icon: "trending-up", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Prefix Sum", order: 2, icon: "sigma", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Sliding Window", order: 4, icon: "app-window", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Stack", order: 6, icon: "layers", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Two Pointers", order: 3, icon: "arrow-left-right", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Hybrid Structures", order: 9, icon: "combine", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Linked List", order: 8, icon: "link", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Matrix Index Math", order: 10, icon: "grid-3x3", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "2. Nodal & Grid", order: 2 });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Binary Search Tree", order: 13, icon: "git-fork", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Heap / Priority Queue", order: 14, icon: "triangle", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "3. Hierarchical", order: 3 });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Tree BFS", order: 12, icon: "network", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Tree DFS", order: 11, icon: "folder-tree", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Trie", order: 15, icon: "spell-check", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Graph BFS", order: 17, icon: "radar", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Graph DFS", order: 16, icon: "waypoints", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "4. Relational", order: 4 });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Topological Sort", order: 18, icon: "workflow", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Union-Find", order: 19, icon: "merge", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "1-D Dynamic Programming", order: 21, icon: "columns-3", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Backtracking", order: 20, icon: "undo-2", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Greedy", order: 23, icon: "coins", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Intervals", order: 24, icon: "calendar-range", display: "page" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "5. Decision Space", order: 5 });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Multi-D / Grid DP", order: 22, icon: "table", display: "page" });
-381
View File
@@ -1,381 +0,0 @@
/**
* Dependency spine island — the "how the 24 topics build on each other" map
* from the README, redrawn as data (nodes on a 7×6 grid, edges as SVG path
* strings) so it is themed, zoomable, and clickable.
*
* - Wheel over the figure zooms about the cursor (trackpad pinch arrives as a
* ctrl+wheel and behaves the same); dragging pans; the toolbar and +/-/0
* keys do the same. Zooming OUT at the minimum scale is deliberately left
* to the page, so a reader scrolling past the map is never trapped in it.
* - A node is a link to its topic. Routes come from Blume's own navigation
* endpoint at mount: the topic's landing page when the folder has an
* `index.mdx`, else its first solution page. A topic with no pages yet is
* drawn inert rather than linked to a 404, so the map stays honest as the
* campaign fills in without a rebuild of this file.
*
* Invariants: the first frame is the figure at HOME_ZOOM, centred, with no
* links (server and client render identically); the geometry is the README
* SVG's, to the pixel, so `viewBox` maths below is in that 1472×648 space.
*/
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { withBase } from "blume/components/islands/base-path.ts";
export const client = "visible";
// ── figure data (README dependency-spine geometry) ───────────────
const W = 1472;
const H = 648;
const MAX_ZOOM = 6;
const NODE_W = 144;
const NODE_H = 48;
const COL_X = [56, 248, 472, 664, 888, 1080, 1272] as const;
const ROW_Y = [72, 144, 216, 288, 416, 488] as const;
type Kind = "entry" | "topic" | "focal";
interface Topic {
n: number;
name: string;
sub?: string;
/** Sync's group slug for the README topic name — the docs folder. */
slug: string;
kind: Kind;
col: number;
row: number;
}
const TOPICS: Topic[] = [
{ n: 5, name: "Binary Search", slug: "binary-search", kind: "entry", col: 0, row: 0 },
{ n: 3, name: "Two Pointers", slug: "two-pointers", kind: "entry", col: 0, row: 1 },
{ n: 4, name: "Sliding Window", slug: "sliding-window", kind: "topic", col: 1, row: 1 },
{ n: 6, name: "Stack", slug: "stack", kind: "entry", col: 0, row: 2 },
{ n: 7, name: "Monotonic Stack", slug: "monotonic-stack", kind: "topic", col: 1, row: 2 },
{ n: 1, name: "Hash Lookup", slug: "hash-table", kind: "entry", col: 0, row: 3 },
{ n: 2, name: "Prefix Sum", slug: "prefix-sum", kind: "topic", col: 1, row: 3 },
{ n: 11, name: "Tree DFS", slug: "tree-dfs", kind: "focal", col: 2, row: 0 },
{ n: 13, name: "Binary Search Tree", slug: "binary-search-tree", kind: "topic", col: 2, row: 1 },
{ n: 14, name: "Heap", sub: "priority queue", slug: "heap-priority-queue", kind: "topic", col: 3, row: 1 },
{ n: 15, name: "Trie", slug: "trie", kind: "topic", col: 3, row: 2 },
{ n: 12, name: "Tree BFS", slug: "tree-bfs", kind: "entry", col: 2, row: 3 },
{ n: 8, name: "Linked List", slug: "linked-list", kind: "entry", col: 2, row: 4 },
{ n: 9, name: "Hybrid Structures", slug: "hybrid-structures", kind: "topic", col: 3, row: 4 },
{ n: 10, name: "Matrix Index Math", slug: "matrix-index-math", kind: "topic", col: 2, row: 5 },
{ n: 16, name: "Graph DFS", slug: "graph-dfs", kind: "topic", col: 4, row: 0 },
{ n: 18, name: "Topological Sort", slug: "topological-sort", kind: "topic", col: 5, row: 0 },
{ n: 19, name: "Union-Find", slug: "union-find", kind: "topic", col: 4, row: 1 },
{ n: 20, name: "Backtracking", slug: "backtracking", kind: "topic", col: 5, row: 1 },
{ n: 21, name: "1-D DP", sub: "dynamic programming", slug: "1-d-dynamic-programming", kind: "focal", col: 5, row: 2 },
{ n: 22, name: "Multi-D / Grid DP", slug: "multi-d-grid-dp", kind: "topic", col: 6, row: 2 },
{ n: 17, name: "Graph BFS", slug: "graph-bfs", kind: "topic", col: 4, row: 3 },
{ n: 23, name: "Greedy", slug: "greedy", kind: "topic", col: 5, row: 3 },
{ n: 24, name: "Intervals", slug: "intervals", kind: "topic", col: 6, row: 3 },
];
/** "builds on" arrows, source → target, in the figure's coordinate space. */
const EDGES: [string, string][] = [
["3 → 4", "M200,168 H248"],
["6 → 7", "M200,240 H248"],
["1 → 2", "M200,312 H248"],
["8 → 9", "M616,440 H664"],
["16 → 18", "M1032,96 H1080"],
["21 → 22", "M1224,240 H1272"],
["23 → 24", "M1224,312 H1272"],
["11 → 16", "M616,84 H888"],
["12 → 17", "M616,312 H888"],
["11 → 13", "M544,120 V144"],
["16 → 19", "M960,120 V144"],
["18 → 20", "M1152,120 V144"],
["20 → 21", "M1152,192 V216"],
["21 → 23", "M1152,264 V288"],
["5 → 13", "M200,96 H416 Q424,96 424,104 V160 Q424,168 432,168 H472"],
["11 → 14", "M616,96 H640 Q648,96 648,104 V160 Q648,168 656,168 H664"],
["11 → 15", "M616,108 H624 Q632,108 632,116 V232 Q632,240 640,240 H664"],
["4 → 10", "M392,184 H432 Q440,184 440,192 V504 Q440,512 448,512 H472"],
["1 → 9", "M128,336 V560 Q128,568 136,568 H728 Q736,568 736,560 V464"],
];
const ZONES: [string, number, number, number, number, number][] = [
// label, x, y, w, h, label plate width
["PHASE I — LINEAR STRUCTURES", 40, 40, 368, 312, 168],
["PHASE III — HIERARCHICAL", 456, 40, 368, 312, 152],
["PHASE II — NODAL & GRID", 456, 384, 368, 168, 144],
["PHASES IV–V — RELATIONAL & DECISION-SPACE", 872, 40, 560, 312, 264],
];
// ── theme ────────────────────────────────────────────────────────
// Everything is a Blume token so the map follows the site's light/dark
// scheme instead of the README's fixed black; tints go through color-mix.
const fg = "var(--blume-foreground)";
const muted = "var(--blume-muted-foreground)";
const accent = "var(--blume-accent)";
const tint = (color: string, pct: number) => `color-mix(in oklab, ${color} ${pct}%, transparent)`;
const NODE_STYLE: Record<Kind, { fill: string; stroke: string; strokeWidth: number; tag: string }> = {
entry: { fill: tint(muted, 10), stroke: tint(muted, 70), strokeWidth: 1, tag: muted },
topic: { fill: tint(fg, 4), stroke: fg, strokeWidth: 1, tag: muted },
focal: { fill: tint(accent, 12), stroke: accent, strokeWidth: 1.2, tag: accent },
};
const mono: CSSProperties = { fontFamily: "var(--blume-font-mono, ui-monospace, monospace)" };
const sans: CSSProperties = { fontFamily: "var(--blume-font-sans, system-ui, sans-serif)" };
const toolButton: CSSProperties = {
width: "1.75rem",
height: "1.75rem",
border: "1px solid var(--blume-border)",
borderRadius: "9999px",
background: "var(--blume-background)",
color: muted,
font: "inherit",
lineHeight: 1,
cursor: "pointer",
};
// ── navigation lookup ────────────────────────────────────────────
interface NavGroup {
kind: "group";
path: string;
route?: string | null;
children: unknown[];
}
const isGroup = (v: unknown): v is NavGroup =>
typeof v === "object" && v !== null && (v as NavGroup).kind === "group" &&
typeof (v as NavGroup).path === "string" && Array.isArray((v as NavGroup).children);
/** The first page route under a sidebar entry, depth-first. */
const firstRoute = (items: unknown[]): string | null => {
for (const item of items) {
if (typeof item !== "object" || item === null) continue;
const v = item as { route?: unknown; children?: unknown };
if (typeof v.route === "string") return v.route;
if (Array.isArray(v.children)) {
const inner = firstRoute(v.children);
if (inner) return inner;
}
}
return null;
};
/** folder path (`/tree-dfs`) → the route a node should open. */
const topicRoutes = (nav: unknown): Record<string, string> => {
const out: Record<string, string> = {};
const sidebar = (nav as { sidebar?: unknown })?.sidebar;
if (!Array.isArray(sidebar)) return out;
for (const entry of sidebar) {
if (!isGroup(entry)) continue;
const route = entry.route ?? firstRoute(entry.children);
if (route) out[entry.path] = route;
}
return out;
};
// ── component ────────────────────────────────────────────────────
interface ViewBox { x: number; y: number; w: number; h: number }
/** The resting view — 1.6× on the centre so node labels read at the content width; 1× stays reachable by zooming out. */
const HOME_ZOOM = 1.6;
const HOME: ViewBox = { x: (W - W / HOME_ZOOM) / 2, y: (H - H / HOME_ZOOM) / 2, w: W / HOME_ZOOM, h: H / HOME_ZOOM };
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);
export default function DependencySpine() {
const svgRef = useRef<SVGSVGElement>(null);
const [vb, setVbState] = useState<ViewBox>(HOME);
// The native wheel listener and back-to-back events read the latest view
// from the ref, which is written synchronously — state alone lags a render.
const vbRef = useRef(vb);
const setVb = (next: ViewBox) => {
vbRef.current = next;
setVbState(next);
};
const [routes, setRoutes] = useState<Record<string, string> | null>(null);
const drag = useRef<{ x: number; y: number; moved: boolean } | null>(null);
// The cursor reads this, not `drag`: a ref read during render never re-renders.
const [panning, setPanning] = useState(false);
useEffect(() => {
let live = true;
fetch(withBase("/api/docs/navigation.json"))
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((nav) => live && setRoutes(topicRoutes(nav)))
.catch(() => live && setRoutes({}));
return () => { live = false; };
}, []);
/** Zoom by `factor` (>1 in) about a client-space point; omitted = centre. */
const zoomAt = (factor: number, clientX?: number, clientY?: number) => {
const rect = svgRef.current?.getBoundingClientRect();
const cur = vbRef.current;
if (!rect) return;
const px = clientX === undefined ? cur.x + cur.w / 2 : cur.x + ((clientX - rect.left) / rect.width) * cur.w;
const py = clientY === undefined ? cur.y + cur.h / 2 : cur.y + ((clientY - rect.top) / rect.height) * cur.h;
const w = clamp(cur.w / factor, W / MAX_ZOOM, W);
const f = cur.w / w;
const h = H * (w / W);
setVb({ x: clamp(px - (px - cur.x) / f, 0, W - w), y: clamp(py - (py - cur.y) / f, 0, H - h), w, h });
};
// React registers `wheel` as passive, so preventDefault has to go native.
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
const onWheel = (e: WheelEvent) => {
if (e.deltaY > 0 && vbRef.current.w >= W) return; // fully zoomed out: let the page scroll
e.preventDefault();
zoomAt(Math.exp(-e.deltaY * 0.0015), e.clientX, e.clientY);
};
svg.addEventListener("wheel", onWheel, { passive: false });
return () => svg.removeEventListener("wheel", onWheel);
}, []);
const onPointerDown = (e: PointerEvent<SVGSVGElement>) => {
if (e.button !== 0) return;
drag.current = { x: e.clientX, y: e.clientY, moved: false };
};
const onPointerMove = (e: PointerEvent<SVGSVGElement>) => {
const d = drag.current;
const rect = svgRef.current?.getBoundingClientRect();
if (!d || !rect) return;
const dx = e.clientX - d.x;
const dy = e.clientY - d.y;
if (!d.moved && Math.hypot(dx, dy) < 4) return; // a click, not a drag
if (!d.moved) {
// Capture only once it is a drag: capturing on pointerdown would retarget
// pointerup (and so the click) to the <svg>, and a node's link would never fire.
d.moved = true;
setPanning(true);
e.currentTarget.setPointerCapture(e.pointerId);
}
d.x = e.clientX;
d.y = e.clientY;
const cur = vbRef.current;
const scale = cur.w / rect.width;
setVb({ ...cur, x: clamp(cur.x - dx * scale, 0, W - cur.w), y: clamp(cur.y - dy * scale, 0, H - cur.h) });
};
const onPointerUp = (e: PointerEvent<SVGSVGElement>) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
setPanning(false);
// Cleared on the next tick so a node's click handler can still see `moved`.
setTimeout(() => { drag.current = null; }, 0);
};
const zoom = W / vb.w;
const atHome = vb.x === HOME.x && vb.y === HOME.y && vb.w === HOME.w;
return (
<figure
className="not-prose"
style={{ margin: "1.5rem 0", border: "1px solid var(--blume-border)", borderRadius: "var(--blume-radius)", background: tint("var(--blume-muted)", 30), overflow: "hidden" }}
aria-label="Dependency spine: how the 24 topics build on each other"
onKeyDown={(e) => {
if (e.key === "+" || e.key === "=") zoomAt(1.25);
else if (e.key === "-") zoomAt(0.8);
else if (e.key === "0") setVb(HOME);
else return;
e.preventDefault();
}}
tabIndex={0}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.75rem", padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--blume-border)" }}>
<span style={{ ...mono, fontSize: "0.7rem", letterSpacing: "0.12em", textTransform: "uppercase", color: muted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
scroll to zoom · drag to pan · click a topic
</span>
<span style={{ display: "flex", alignItems: "center", gap: "0.35rem", flexShrink: 0 }}>
<span style={{ ...mono, fontSize: "0.7rem", color: muted, minWidth: "4ch", textAlign: "right", whiteSpace: "nowrap" }}>{zoom.toFixed(1)}×</span>
<button type="button" style={toolButton} aria-label="Zoom out" onClick={() => zoomAt(0.8)} disabled={zoom <= 1.001}>−</button>
<button type="button" style={toolButton} aria-label="Zoom in" onClick={() => zoomAt(1.25)} disabled={zoom >= MAX_ZOOM - 0.001}>+</button>
<button type="button" style={toolButton} aria-label="Reset zoom" onClick={() => setVb(HOME)} disabled={atHome}>⟲</button>
</span>
</div>
<svg
ref={svgRef}
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="dependency-spine-title dependency-spine-desc"
style={{ display: "block", width: "100%", height: "auto", touchAction: "none", cursor: panning ? "grabbing" : zoom > 1.001 ? "grab" : "default", userSelect: "none" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onDoubleClick={() => setVb(HOME)}
>
<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="spine-arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" style={{ fill: muted }} />
</marker>
</defs>
{ZONES.map(([label, x, y, w, h, plate]) => (
<g key={label}>
<rect x={x} y={y} width={w} height={h} rx={8} style={{ fill: tint(fg, 2), stroke: tint(fg, 10), strokeWidth: 0.8 }} />
<rect x={x + 16} y={y + 4} width={plate} height={12} rx={2} style={{ fill: "var(--blume-background)" }} />
<text x={x + 20} y={y + 13} fontSize={7} letterSpacing="0.14em" style={{ ...mono, fill: tint(fg, 40) }}>{label}</text>
</g>
))}
{EDGES.map(([id, d]) => (
<path key={id} d={d} fill="none" strokeWidth={1} markerEnd="url(#spine-arrow)" style={{ stroke: muted }}>
<title>{id}</title>
</path>
))}
{TOPICS.map((t) => {
const x = COL_X[t.col]!;
const y = ROW_Y[t.row]!;
const s = NODE_STYLE[t.kind];
const route = routes?.[`/${t.slug}`];
const tag = String(t.n).padStart(2, "0");
const body = (
<>
<title>{route ? `${tag} · ${t.name} — open topic` : `${tag} · ${t.name} — no pages yet`}</title>
<rect x={x} y={y} width={NODE_W} height={NODE_H} rx={6} style={{ fill: "var(--blume-background)" }} />
<rect x={x} y={y} width={NODE_W} height={NODE_H} rx={6} style={{ fill: s.fill, stroke: s.stroke, strokeWidth: s.strokeWidth }} />
<rect x={x + 8} y={y + 6} width={24} height={12} rx={2} fill="transparent" style={{ stroke: tint(s.tag, 50), strokeWidth: 0.8 }} />
<text x={x + 20} y={y + 15} fontSize={7} textAnchor="middle" letterSpacing="0.08em" style={{ ...mono, fill: s.tag }}>{tag}</text>
<text x={x + NODE_W / 2} y={y + (t.sub ? 26 : 32)} fontSize={12} fontWeight={600} textAnchor="middle" style={{ ...sans, fill: fg }}>{t.name}</text>
{t.sub && <text x={x + NODE_W / 2} y={y + 40} fontSize={9} textAnchor="middle" style={{ ...mono, fill: muted }}>{t.sub}</text>}
</>
);
return route ? (
<a
key={t.n}
href={withBase(route)}
className="spine-node"
style={{ cursor: "pointer", textDecoration: "none" }}
onClick={(e) => { if (drag.current?.moved) e.preventDefault(); }}
>
{body}
</a>
) : (
<g key={t.n} style={{ opacity: routes ? 0.55 : 1 }} aria-disabled="true">{body}</g>
);
})}
<line x1={40} y1={600} x2={1432} y2={600} strokeWidth={0.8} style={{ stroke: tint(fg, 10) }} />
<text x={40} y={620} fontSize={8} letterSpacing="0.14em" style={{ ...mono, fill: muted }}>LEGEND</text>
<rect x={160} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.entry.fill, stroke: NODE_STYLE.entry.stroke, strokeWidth: 1 }} />
<text x={188} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>ENTRY — NO PREREQS</text>
<rect x={360} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.topic.fill, stroke: NODE_STYLE.topic.stroke, strokeWidth: 1 }} />
<text x={388} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>TOPIC</text>
<rect x={480} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.focal.fill, stroke: NODE_STYLE.focal.stroke, strokeWidth: 1.2 }} />
<text x={508} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>FOCAL HUB</text>
<line x1={624} y1={614} x2={656} y2={614} strokeWidth={1} markerEnd="url(#spine-arrow)" style={{ stroke: muted }} />
<text x={668} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>BUILDS ON</text>
<rect x={792} y={608} width={20} height={12} rx={2} style={{ fill: tint(fg, 2), stroke: tint(fg, 10), strokeWidth: 0.8 }} />
<text x={820} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>PHASE ZONE</text>
<rect x={1000} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.topic.fill, stroke: NODE_STYLE.topic.stroke, strokeWidth: 1, opacity: 0.55 }} />
<text x={1028} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>NO PAGES YET</text>
</svg>
<style>{`.spine-node:hover rect:nth-of-type(2), .spine-node:focus-visible rect:nth-of-type(2) { stroke: ${accent}; stroke-width: 1.6; } .spine-node:focus { outline: none; }`}</style>
</figure>
);
}
-81
View File
@@ -1,81 +0,0 @@
/**
* The ten most recently solved problems — LeetCode's "Recent AC" list. Each
* row is dated by the problem's first pass (an ET calendar date: the Worker
* logs days, not times), read relative to today in ET with the full date on
* hover. A row links to the problem's docs page when one exists: the
* sync-generated short links (`redirects.ts`) are exactly the pages built.
*/
import { StatsPending } from "../components/progress/shared";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { useStats, utcDate } from "../lib/stats";
import redirects from "../redirects";
export const client = "load";
const PAGES = new Map(redirects.map((r) => [r.from, r.to]));
const ET_TODAY = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York" });
const RELATIVE = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const FULL = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" });
const DAY_MS = 86_400_000;
export default function RecentSolves() {
const load = useStats();
if (load.phase !== "ready") {
return (
<Card>
<CardHeader>
<CardTitle>Recently solved</CardTitle>
</CardHeader>
<CardContent>
<StatsPending load={load} className="h-80 w-full" />
</CardContent>
</Card>
);
}
const today = utcDate(ET_TODAY.format(new Date())).getTime();
return (
<Card>
<CardHeader>
<CardTitle>Recently solved</CardTitle>
</CardHeader>
<CardContent>
{load.stats.recent.length === 0 ? (
<p className="my-0! text-muted-foreground text-sm">Nothing solved yet.</p>
) : (
<ol className="my-0! flex flex-col">
{load.stats.recent.map((p) => {
const date = utcDate(p.date);
const page = PAGES.get(`/${p.lc}`);
const name = (
<>
<span className="text-muted-foreground tabular-nums">{p.lc}.</span> {p.title}
</>
);
return (
<li
key={p.lc}
className="flex items-center justify-between gap-4 rounded-lg px-4 py-3 text-sm odd:bg-muted"
>
{page ? (
<a href={page} className="truncate font-normal! text-foreground no-underline! hover:underline!">
{name}
</a>
) : (
<span className="truncate">{name}</span>
)}
<time dateTime={p.date} title={FULL.format(date)} className="shrink-0 text-muted-foreground">
{RELATIVE.format(Math.round((date.getTime() - today) / DAY_MS), "day")}
</time>
</li>
);
})}
</ol>
)}
</CardContent>
</Card>
);
}
-145
View File
@@ -1,145 +0,0 @@
/**
* Solved-by-difficulty ring — LeetCode's profile gauge. A 270° arc split into
* one segment per difficulty, each sized by that difficulty's share of the
* catalog; the faint track is the whole set, the solid arc the solved part.
* The centre reads the total, or one difficulty while its segment (or its
* stat box) is hovered. "Attempting" is the open-miss count: problems whose
* latest attempt failed.
*/
import { useState } from "react";
import { Pie, PieChart } from "recharts";
import { StatsPending } from "../components/progress/shared";
import { Card, CardContent } from "../components/ui/card";
import { type ChartConfig, ChartContainer } from "../components/ui/chart";
import { DIFFICULTIES, type Difficulty, useStats } from "../lib/stats";
import { cn } from "../lib/utils";
export const client = "load";
const chartConfig = {
easy: { label: "Easy", color: "var(--chart-1)" },
medium: { label: "Med.", color: "var(--chart-2)" },
hard: { label: "Hard", color: "var(--chart-3)" },
} satisfies ChartConfig;
// Static class names, so Tailwind's scanner sees every one (the chart's
// --color-* variables exist only inside ChartContainer).
const DIFFICULTY_TEXT: Record<Difficulty, string> = {
easy: "text-chart-1",
medium: "text-chart-2",
hard: "text-chart-3",
};
// The gauge opens at the bottom: from 225° (lower left) clockwise to -45°
// (lower right), in Recharts' counter-clockwise-from-3-o'clock degrees.
const START = 225;
const SWEEP = 270;
const GAP = 8;
export default function SolvedRing() {
const load = useStats();
const [focus, setFocus] = useState<Difficulty | null>(null);
if (load.phase !== "ready") {
return (
<Card>
<CardContent>
<StatsPending load={load} className="h-52 w-full" />
</CardContent>
</Card>
);
}
const rows = DIFFICULTIES.map(
(difficulty) => load.stats.difficulty.find((d) => d.difficulty === difficulty) ?? { difficulty, total: 0, done: 0 },
);
const total = rows.reduce((n, r) => n + r.total, 0);
const done = rows.reduce((n, r) => n + r.done, 0);
const span = SWEEP - GAP * (rows.length - 1);
const segments: ((typeof rows)[number] & { from: number; to: number; solvedTo: number })[] = [];
let cursor = START;
for (const r of rows) {
const size = total > 0 ? (span * r.total) / total : 0;
const solved = r.total > 0 ? size * (r.done / r.total) : 0;
segments.push({ ...r, from: cursor, to: cursor - size, solvedTo: cursor - solved });
cursor -= size + GAP;
}
const focused = focus ? rows.find((r) => r.difficulty === focus) : undefined;
const arc = { innerRadius: "80%", outerRadius: "88%", cornerRadius: 99, stroke: "none", dataKey: "value" } as const;
return (
<Card>
<CardContent className="flex flex-col items-center gap-6 sm:flex-row">
<div className="relative w-full max-w-56 shrink-0">
<ChartContainer config={chartConfig} className="aspect-square w-full">
<PieChart>
{segments.map((s) => (
<Pie
key={`${s.difficulty}-track`}
{...arc}
data={[{ value: 1 }]}
startAngle={s.from}
endAngle={s.to}
fill={`var(--color-${s.difficulty})`}
fillOpacity={focus && focus !== s.difficulty ? 0.12 : 0.25}
isAnimationActive={false}
onMouseEnter={() => setFocus(s.difficulty)}
onMouseLeave={() => setFocus(null)}
/>
))}
{segments
.filter((s) => s.done > 0)
.map((s) => (
<Pie
key={`${s.difficulty}-solved`}
{...arc}
data={[{ value: 1 }]}
startAngle={s.from}
endAngle={s.solvedTo}
fill={`var(--color-${s.difficulty})`}
fillOpacity={focus && focus !== s.difficulty ? 0.35 : 1}
onMouseEnter={() => setFocus(s.difficulty)}
onMouseLeave={() => setFocus(null)}
/>
))}
</PieChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1">
<div className="flex items-baseline">
<span className="font-semibold text-4xl tabular-nums">{focused ? focused.done : done}</span>
<span className="text-muted-foreground tabular-nums">/{focused ? focused.total : total}</span>
</div>
<span className={cn("text-sm", focused ? DIFFICULTY_TEXT[focused.difficulty] : "text-muted-foreground")}>
{focused ? chartConfig[focused.difficulty].label : "Solved"}
</span>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-3 text-center text-muted-foreground text-xs">
<span className="font-semibold text-foreground tabular-nums">{load.stats.attempting}</span> Attempting
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-1">
{rows.map((r) => (
<div
key={r.difficulty}
className={cn(
"flex flex-col items-center gap-0.5 rounded-lg bg-muted px-4 py-2 transition-opacity",
focus && focus !== r.difficulty && "opacity-50",
)}
onPointerEnter={() => setFocus(r.difficulty)}
onPointerLeave={() => setFocus(null)}
>
<span className={cn("font-medium text-sm", DIFFICULTY_TEXT[r.difficulty])}>
{chartConfig[r.difficulty].label}
</span>
<span className="text-sm tabular-nums">
{r.done}/{r.total}
</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
-222
View File
@@ -1,222 +0,0 @@
/**
* This calendar year by difficulty — LeetCode's solved/submissions bar chart,
* sharing its row with the heatmap. Stacked Easy / Med. / Hard bars per month
* or per week (Monday-start, like the heatmap rows); "Solved" counts each
* problem once, on its first-pass date (the date `recent` uses), while
* "Submissions" counts every logged attempt. The totals under the chart are
* the year's, in the selected mode. Buckets are cut from `year`, which always
* covers January 1 to today.
*/
import { useState } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { StatsPending } from "../components/progress/shared";
import { Card, CardAction, CardContent, CardFooter, CardHeader } from "../components/ui/card";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "../components/ui/chart";
import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group";
import { DIFFICULTIES, type Day, type Difficulty, useStats, utcDate } from "../lib/stats";
export const client = "load";
const chartConfig = {
easy: { label: "Easy", color: "var(--chart-1)" },
medium: { label: "Med.", color: "var(--chart-2)" },
hard: { label: "Hard", color: "var(--chart-3)" },
} satisfies ChartConfig;
// Static class names, so Tailwind's scanner sees every one.
const DIFFICULTY_TEXT: Record<Difficulty, string> = {
easy: "text-chart-1",
medium: "text-chart-2",
hard: "text-chart-3",
};
type Mode = "solved" | "attempts";
type Grain = "month" | "week";
const DAY_MS = 86_400_000;
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
const MONTH_YEAR = new Intl.DateTimeFormat("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
const SHORT = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
interface Bucket extends Record<Difficulty, number> {
index: number;
/** Axis tick: the month the bucket starts in. */
tick: string;
/** Tooltip heading. */
label: string;
}
/** The year's buckets, every one present (future ones empty), filled from `days`. */
function buckets(days: Day[], year: number, grain: Grain, mode: Mode): Bucket[] {
const jan1 = Date.UTC(year, 0, 1);
const lead = (new Date(jan1).getUTCDay() + 6) % 7; // Jan 1's Monday-based weekday
const length = Math.round((Date.UTC(year + 1, 0, 1) - jan1) / DAY_MS);
const out: Bucket[] = [];
if (grain === "month") {
for (let m = 0; m < 12; m++) {
const start = new Date(Date.UTC(year, m, 1));
out.push({ index: m, tick: MONTH.format(start), label: MONTH_YEAR.format(start), easy: 0, medium: 0, hard: 0 });
}
} else {
const weeks = Math.floor((length - 1 + lead) / 7) + 1;
for (let w = 0; w < weeks; w++) {
const start = new Date(Math.max(jan1, jan1 + (w * 7 - lead) * DAY_MS));
const end = new Date(Math.min(jan1 + (length - 1) * DAY_MS, jan1 + (w * 7 - lead + 6) * DAY_MS));
out.push({
index: w,
tick: MONTH.format(start),
label: `${SHORT.format(start)} – ${SHORT.format(end)}`,
easy: 0,
medium: 0,
hard: 0,
});
}
}
for (const day of days) {
const date = utcDate(day.date);
if (date.getUTCFullYear() !== year) continue;
const i = grain === "month" ? date.getUTCMonth() : Math.floor(((date.getTime() - jan1) / DAY_MS + lead) / 7);
const bucket = out[i];
if (!bucket) continue;
for (const d of DIFFICULTIES) bucket[d] += day[mode][d];
}
return out;
}
export default function SubmissionChart() {
const load = useStats();
const [mode, setMode] = useState<Mode>("solved");
const [grain, setGrain] = useState<Grain>("month");
if (load.phase !== "ready") {
return (
<Card>
<CardContent>
<StatsPending load={load} className="h-40 w-full" />
</CardContent>
</Card>
);
}
const year = utcDate(load.stats.year.at(-1)?.date ?? "1970-01-01").getUTCFullYear();
const data = buckets(load.stats.year, year, grain, mode);
const totals = Object.fromEntries(
DIFFICULTIES.map((d) => [d, data.reduce((n, b) => n + b[d], 0)]),
);
const peak = Math.max(...data.map((b) => b.easy + b.medium + b.hard));
// A round ceiling for the one labelled gridline, as LeetCode draws it.
const top = peak <= 5 ? 5 : Math.ceil(peak / 10) * 10;
return (
<Card>
<CardHeader className="flex flex-wrap items-center justify-between gap-2">
<ToggleGroup
type="single"
size="sm"
spacing={1}
value={mode}
onValueChange={(value: string) => value && setMode(value as Mode)}
className="rounded-full bg-muted p-0.5"
aria-label="Count"
>
<ToggleGroupItem value="solved" className="h-7 rounded-full px-2.5 text-xs">
Solved
</ToggleGroupItem>
<ToggleGroupItem value="attempts" className="h-7 rounded-full px-2.5 text-xs">
Submissions
</ToggleGroupItem>
</ToggleGroup>
<div className="flex items-center">
<ToggleGroup
type="single"
size="sm"
spacing={1}
value={grain}
onValueChange={(value: string) => value && setGrain(value as Grain)}
className="rounded-full bg-muted p-0.5"
aria-label="Bucket size"
>
<ToggleGroupItem value="month" className="h-7 rounded-full px-2.5 text-xs" aria-label="Per month">
M
</ToggleGroupItem>
<ToggleGroupItem value="week" className="h-7 rounded-full px-2.5 text-xs" aria-label="Per week">
W
</ToggleGroupItem>
</ToggleGroup>
</div>
</CardHeader>
<CardContent className="flex-1">
<ChartContainer config={chartConfig} className="aspect-auto h-40 w-full">
<BarChart accessibilityLayer data={data} margin={{ top: 8, right: 0, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="index"
type="category"
tickLine={false}
axisLine={false}
tickMargin={6}
ticks={[0, data.length - 1]}
interval={0}
// First label anchored at its start, last at its end, so neither
// is clipped when a week band is only a few pixels wide.
tick={({ x, y, payload }) => (
<text
x={x}
y={y}
dy={10}
textAnchor={payload.value === 0 ? "start" : "end"}
className="fill-muted-foreground"
>
{data[Number(payload.value)]?.tick}
</text>
)}
/>
<YAxis
orientation="right"
domain={[0, top]}
ticks={[0, top]}
tickLine={false}
axisLine={false}
width={28}
allowDecimals={false}
/>
<ChartTooltip
cursor={false}
// Recharts applies itemSorter only in its default tooltip; this
// content gets the stack's bottom-up order, so flip it to read
// top-down, Easy first, like the bars.
content={({ active, label, payload }) => (
<ChartTooltipContent
active={active}
label={label}
payload={[...(payload ?? [])].reverse()}
labelFormatter={(_, items) => items[0]?.payload?.label}
indicator="dot"
/>
)}
/>
{(["hard", "medium", "easy"] as const).map((d) => (
<Bar
key={d}
dataKey={d}
stackId="solved"
fill={`var(--color-${d})`}
radius={2}
maxBarSize={grain === "month" ? 10 : 5}
/>
))}
</BarChart>
</ChartContainer>
</CardContent>
<CardFooter className="gap-4 text-sm">
{DIFFICULTIES.map((d) => (
<span key={d} className="flex items-baseline gap-1">
<span className={DIFFICULTY_TEXT[d]}>{chartConfig[d].label}</span>
<span className="text-muted-foreground tabular-nums">{totals[d]}</span>
</span>
))}
<span className="ml-auto text-muted-foreground tabular-nums">{year}</span>
</CardFooter>
</Card>
);
}
-165
View File
@@ -1,165 +0,0 @@
/**
* The last six months of attempts — LeetCode's calendar heatmap, halved to
* share its row with the submissions chart: one cell per day, weeks as
* columns, each month its own block, and the window's attempt count, active
* days and longest streak above it. The figures are counted from exactly the
* cells drawn, so they cannot disagree with the grid. The colour ramp is the
* README/email heatmap's (theme.css `--heat-0..4`, level = attempts capped at
* 4) and rows start on Monday like it, so the three heatmaps read the same.
* Drawn as SVG in a viewBox, so it scales to its column instead of scrolling.
*/
import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { type Day, useStats, utcDate } from "../lib/stats";
export const client = "load";
const MONTHS = 6;
const CELL = 10;
const STEP = 13; // cell + gap
const MONTH_GAP = 8;
const LABEL_H = 16;
const HEAT = ["var(--heat-0)", "var(--heat-1)", "var(--heat-2)", "var(--heat-3)", "var(--heat-4)"];
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
interface Cell {
x: number;
y: number;
date: Date;
attempts: number;
passes: number;
}
interface Block {
label: string;
x: number;
width: number;
cells: Cell[];
}
/**
* Month blocks laid left to right over the last MONTHS calendar months. Within
* a block a day sits at row = weekday (Mon = 0) and column = its week counted
* from the 1st, so a month starting on a Thursday opens with a short column.
*/
function layout(year: Day[]): { blocks: Block[]; width: number } {
const last = utcDate(year.at(-1)?.date ?? "1970-01-01");
const from = Date.UTC(last.getUTCFullYear(), last.getUTCMonth() - (MONTHS - 1), 1);
const blocks: Block[] = [];
let x = 0;
for (const day of year) {
const date = utcDate(day.date);
if (date.getTime() < from) continue;
let block = blocks.at(-1);
if (date.getUTCDate() === 1 || !block) {
if (block) x += block.width + MONTH_GAP;
block = { label: MONTH.format(date), x, width: 0, cells: [] };
blocks.push(block);
}
const first = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
const index = (first.getUTCDay() + 6) % 7 + date.getUTCDate() - 1;
const col = Math.floor(index / 7);
block.cells.push({
x: x + col * STEP,
y: (index % 7) * STEP,
date,
attempts: day.attempts.easy + day.attempts.medium + day.attempts.hard,
passes: day.passes,
});
block.width = col * STEP + CELL;
}
const end = blocks.at(-1);
return { blocks, width: end ? end.x + end.width : 0 };
}
export default function SubmissionHeatmap() {
const load = useStats();
const { ref, show, hide, node } = useHoverTip<HTMLDivElement>();
if (load.phase !== "ready") {
return (
<Card>
<CardContent>
<StatsPending load={load} className="h-40 w-full" />
</CardContent>
</Card>
);
}
const { blocks, width } = layout(load.stats.year);
const cells = blocks.flatMap((b) => b.cells);
let run = 0;
let streak = 0;
for (const c of cells) {
run = c.attempts > 0 ? run + 1 : 0;
streak = Math.max(streak, run);
}
const height = 7 * STEP - (STEP - CELL) + LABEL_H;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-baseline gap-2">
<span className="font-semibold text-2xl tabular-nums">{cells.reduce((n, c) => n + c.attempts, 0)}</span>
<span className="font-normal text-muted-foreground text-sm">attempts in the past {MONTHS} months</span>
</CardTitle>
<CardDescription className="flex gap-4">
<span>
Active days:{" "}
<span className="text-foreground tabular-nums">{cells.filter((c) => c.attempts > 0).length}</span>
</span>
<span>
Max streak: <span className="text-foreground tabular-nums">{streak}</span>
</span>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-1 items-center">
<div ref={ref} className="relative w-full" onPointerLeave={hide}>
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-auto w-full"
role="img"
aria-label={`Attempts per day over the past ${MONTHS} months`}
>
{blocks.map((block) => (
<g key={`${block.label}-${block.x}`}>
{block.cells.map((cell) => (
<rect
key={cell.x * 1000 + cell.y}
x={cell.x}
y={cell.y}
width={CELL}
height={CELL}
rx={2}
fill={HEAT[Math.min(cell.attempts, 4)]}
onPointerMove={(event) =>
show(
event,
<>
<div className="font-medium">{DAY.format(cell.date)}</div>
<TipRow color={HEAT[Math.min(cell.attempts, 4)]!} label="Attempts" value={cell.attempts} />
<TipRow color="var(--chart-4)" label="Passed" value={cell.passes} />
</>,
)
}
/>
))}
<text
x={block.x + block.width / 2}
y={height - 3}
textAnchor="middle"
className="fill-muted-foreground text-[9px]"
>
{block.label}
</text>
</g>
))}
</svg>
{node}
</div>
</CardContent>
</Card>
);
}
-139
View File
@@ -1,139 +0,0 @@
/**
* The campaign's topics as packed bubbles — LeetCode's "All Topics" view,
* over our curriculum instead of LeetCode's tags. A bubble's area is the
* topic's problem count; it fills from the bottom like a glass as problems
* are solved, and turns solid once every one is. A topic the schedule has not
* reached yet is dimmed. Packing is d3-hierarchy's front-chain (packSiblings,
* largest first, so the layout is deterministic), drawn as SVG in a viewBox:
* Recharts has no packed layout.
*/
import { packEnclose, packSiblings } from "d3-hierarchy";
import { useId } from "react";
import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { type Stats, useStats } from "../lib/stats";
import { cn } from "../lib/utils";
export const client = "load";
const PAD = 2; // gap between bubbles, in layout units
const UNIT = 12; // radius of a one-problem bubble
type Topic = Stats["topics"][number];
type Bubble = Topic & { x: number; y: number; r: number };
/** Split a name across lines of at most ~10 characters, never breaking a word. */
function lines(name: string): string[] {
const out: string[] = [];
for (const word of name.split(" ")) {
const last = out.at(-1);
if (last !== undefined && `${last} ${word}`.length <= 10) out[out.length - 1] = `${last} ${word}`;
else out.push(word);
}
return out;
}
export default function TopicBubbles() {
const load = useStats();
const { ref, show, hide, node } = useHoverTip<HTMLDivElement>();
const clip = useId().replace(/:/g, "");
if (load.phase !== "ready") {
return (
<Card>
<CardHeader>
<CardTitle>Topics</CardTitle>
</CardHeader>
<CardContent>
<StatsPending load={load} className="aspect-square w-full" />
</CardContent>
</Card>
);
}
const topics = load.stats.topics.filter((t) => t.total > 0);
const circles: Bubble[] = topics
.slice()
.sort((a, b) => b.total - a.total || a.issue - b.issue)
.map((t) => ({ ...t, x: 0, y: 0, r: UNIT * Math.sqrt(t.total) + PAD }));
packSiblings(circles);
const bound = packEnclose(circles) ?? { x: 0, y: 0, r: 1 };
const solved = topics.reduce((n, t) => n + t.done, 0);
const total = topics.reduce((n, t) => n + t.total, 0);
return (
<Card>
<CardHeader>
<CardTitle>Topics</CardTitle>
<CardDescription>
{solved} of {total} problems across {topics.length} topics · size is problem count, fill is solved
</CardDescription>
</CardHeader>
<CardContent>
<div ref={ref} className="relative mx-auto w-full max-w-xl" onPointerLeave={hide}>
<svg
viewBox={`${bound.x - bound.r} ${bound.y - bound.r} ${bound.r * 2} ${bound.r * 2}`}
className="h-auto w-full"
role="img"
aria-label="Campaign topics sized by problem count and filled by problems solved"
>
{circles.map((c) => {
const r = c.r - PAD;
const ratio = c.total > 0 ? c.done / c.total : 0;
const complete = c.done >= c.total;
const words = lines(c.name);
const size = Math.min(r / 3.2, (1.7 * r) / Math.max(...words.map((w) => w.length)));
const id = `${clip}-${c.issue}`;
return (
<g
key={c.issue}
className={cn("transition-opacity hover:opacity-90", !c.taught && "opacity-50")}
onPointerMove={(event) =>
show(
event,
<>
<div className="font-medium">{c.name}</div>
<TipRow color="var(--chart-4)" label="Solved" value={`${c.done}/${c.total}`} />
{!c.taught && <div className="text-muted-foreground">Not reached yet</div>}
</>,
)
}
>
<clipPath id={id}>
<circle cx={c.x} cy={c.y} r={r} />
</clipPath>
<circle cx={c.x} cy={c.y} r={r} className="fill-muted stroke-border" strokeWidth={0.5} />
<rect
x={c.x - r}
y={c.y + r - 2 * r * ratio}
width={2 * r}
height={2 * r * ratio}
clipPath={`url(#${id})`}
fill="var(--chart-4)"
fillOpacity={complete ? 1 : 0.35}
/>
<text
x={c.x}
y={c.y - ((words.length - 1) * size * 1.15) / 2}
textAnchor="middle"
dominantBaseline="central"
clipPath={`url(#${id})`}
className={cn("pointer-events-none select-none", complete ? "fill-background" : "fill-foreground")}
style={{ fontSize: size }}
>
{words.map((w, i) => (
<tspan key={`${i}-${w}`} x={c.x} dy={i === 0 ? 0 : size * 1.15}>
{w}
</tspan>
))}
</text>
</g>
);
})}
</svg>
{node}
</div>
</CardContent>
</Card>
);
}
-14
View File
@@ -1,14 +0,0 @@
// Islands and the Blume modules they import (blume/components/islands/
// base-path.ts) are bundled by Vite, which provides import.meta.env. Vite is
// Blume's dependency, not this workspace's, so its client types are not
// resolvable from here; this mirrors the slice of them (ImportMetaEnv) tsc needs.
interface ImportMeta {
readonly env: {
readonly [key: string]: unknown;
readonly BASE_URL: string;
readonly MODE: string;
readonly DEV: boolean;
readonly PROD: boolean;
readonly SSR: boolean;
};
}
-91
View File
@@ -1,91 +0,0 @@
/**
* The `/changelog` page's data: every timever release apps/cli/release-topics.ts
* published — one per finished topic and per finished `Review <N>` issue —
* read from the GitHub API at build time (pages/changelog.astro awaits it).
*
* One page, no per-release subpages: each release becomes one timeline card,
* so the fields come from the release itself rather than a staged content
* entry. The date is the timever tag (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — the
* instant the last required problem closed), never `published_at`, which the
* API cannot backdate and which reads the backfill day for every old release.
* The problems are the rows of the body's `### Closed …` tables, the shape
* release-topics.ts's render() writes: `| #issue | LC n · Name | set | diff |
* closed | [py](…) · [docs](…) |`. Only `### Closed …` sections are read, so
* a release published before open problems were dropped from the body still
* shows only what finished. The row's own `[docs]` link is not used: it is
* frozen at release time (a problem solved before the docs existed has
* none), while the page links each problem by its LC number to its page now.
*
* Auth: GITHUB_TOKEN when set (deploy.yml passes it), else unauthenticated —
* one request per 100 releases fits the anonymous limit. A failed request
* fails the build: a changelog silently missing its releases is worse.
*/
const REPO = "prdlk/leetcode";
const MARKER = /<!-- release-topics: #(\d+) -->/u;
const TIMEVER = /^(\d{4})(\d{2})(\d{2})\.(\d{2})(\d{2})(\d{2})\.\d{9}$/u;
const KIND = /^(Topic|Review) \d+ — /u;
export interface ReleaseProblem {
issue: number;
/** The LeetCode number, the key of the docs `/<num>` short links. */
lc: number;
/** `LC 57 · Insert Interval`. */
title: string;
set: string;
/** `easy` / `medium` / `hard`, the problem's `diff:*` label. */
difficulty: string;
}
export interface Release {
/** Topic or Review issue the release records. */
issue: number;
name: string;
kind: "Topic" | "Review";
/** UTC instant the last required problem closed, from the timever tag. */
finishedAt: Date;
url: string;
problems: ReleaseProblem[];
}
/** Rows of every `### Closed …` table in a release body. */
function closedProblems(body: string): ReleaseProblem[] {
const problems: ReleaseProblem[] = [];
let closed = false;
for (const line of body.split("\n")) {
if (line.startsWith("### ")) closed = line.startsWith("### Closed");
if (!closed || !line.startsWith("| #")) continue;
const [issue, title, set, difficulty] = line.slice(1, -1).split("|").map((cell) => cell.trim());
const lc = title?.match(/^LC (\d+) · /u)?.[1];
if (!issue || !title || !lc || !set || !difficulty) continue;
problems.push({ issue: Number(issue.slice(1)), lc: Number(lc), title, set, difficulty });
}
return problems;
}
export async function readReleases(): Promise<Release[]> {
const headers: Record<string, string> = { accept: "application/vnd.github+json" };
if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
const releases: Release[] = [];
for (let page = 1; ; page++) {
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=100&page=${page}`, { headers });
if (!res.ok) throw new Error(`GET ${REPO} releases -> ${res.status} ${await res.text()}`);
const batch = (await res.json()) as { tag_name: string; name: string | null; body: string | null; html_url: string }[];
for (const r of batch) {
const issue = r.body?.match(MARKER)?.[1];
const t = r.tag_name.match(TIMEVER);
const kind = r.name?.match(KIND)?.[1];
if (!issue || !t || !kind || !r.name || !r.body) continue;
releases.push({
issue: Number(issue),
name: r.name,
kind: kind as Release["kind"],
finishedAt: new Date(`${t[1]}-${t[2]}-${t[3]}T${t[4]}:${t[5]}:${t[6]}Z`),
url: r.html_url,
problems: closedProblems(r.body),
});
}
if (batch.length < 100) break;
}
return releases.sort((a, b) => b.finishedAt.getTime() - a.finishedAt.getTime());
}
-90
View File
@@ -1,90 +0,0 @@
/**
* The progress page's data: GET /api/stats from the SRS Worker, fetched in the
* browser so `blume build` never depends on the API being up. API_BASE is the
* deployed Worker — do not inline it anywhere else.
*
* Four islands read this (SolvedRing, SubmissionHeatmap, RecentSolves,
* TopicBubbles). Each hydrates on its own, but they share this module, so the
* one in-flight request is shared too: the page asks the Worker once.
*
* Invariants: a malformed body is an error state, never a throw; the first
* frame is always the loading state, so server and client render identically.
*/
import { useEffect, useState } from "react";
const API_BASE = "https://srs-api.prdlk.workers.dev";
// ── /api/stats response shape (the fields these islands read) ────
export const DIFFICULTIES = ["easy", "medium", "hard"] as const;
export type Difficulty = (typeof DIFFICULTIES)[number];
export interface Stats {
generated: string;
/** Per difficulty, catalog size and how many entered the ladder. */
difficulty: { difficulty: Difficulty; total: number; done: number }[];
/** Problems whose most recent attempt failed — the sweep day's miss log. */
attempting: number;
topics: { issue: number; name: string; total: number; done: number; taught: boolean }[];
/** The ten most recently solved, newest first, dated by first pass (ET). */
recent: { lc: number; title: string; difficulty: Difficulty; date: string }[];
/**
* The 365 days ending today (ET), zeros included — always the current
* calendar year to date. Per difficulty: every logged attempt, and every
* first solve (the first-pass date `recent` uses).
*/
year: Day[];
}
export interface Day {
date: string;
passes: number;
attempts: Record<Difficulty, number>;
solved: Record<Difficulty, number>;
}
function isStats(value: unknown): value is Stats {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return (
Array.isArray(v.difficulty) &&
typeof v.attempting === "number" &&
Array.isArray(v.topics) &&
Array.isArray(v.recent) &&
Array.isArray(v.year)
);
}
// ── the shared request ───────────────────────────────────────────
export type Load = { phase: "loading" } | { phase: "error" } | { phase: "ready"; stats: Stats };
let request: Promise<Stats | null> | undefined;
/** The page's stats, fetched once however many islands ask. */
export function useStats(): Load {
const [load, setLoad] = useState<Load>({ phase: "loading" });
useEffect(() => {
if (!request) {
request = fetch(`${API_BASE}/api/stats`)
.then((res) => (res.ok ? res.json() : null))
.then((body: unknown) => (isStats(body) ? body : null))
.catch(() => null);
}
let live = true;
request.then((stats) => {
if (live) setLoad(stats ? { phase: "ready", stats } : { phase: "error" });
});
return () => {
live = false;
};
}, []);
return load;
}
/** "YYYY-MM-DD" → a UTC-midnight Date, so calendar math never drifts with the reader's timezone. */
export function utcDate(iso: string): Date {
const [y, m, d] = iso.split("-").map(Number);
return new Date(Date.UTC(y!, m! - 1, d!));
}
-129
View File
@@ -1,129 +0,0 @@
/**
* One colour per topic tag, unique across the site: the problem-page badge
* row (`components/ProblemBadges.astro`) paints each tag with it, so a tag
* reads the same on every page and no two tags share a colour.
*
* Fifty-odd hues cannot all be told apart, so uniqueness alone is not the
* goal: tags that share a problem page must be told apart, since that is
* where they are seen together. The palette is a fixed grid in OKLCH, a few
* lightness rows by `HUE_STEP` hues, and tags are coloured greedily, most-used
* first. Each tag takes the free palette colour farthest (OKLab distance)
* from every colour its co-tags already hold, ties broken by distance from
* every colour taken at all. The answer is a pure function of the pages'
* tags, so it is identical on every page of one build; a newly solved problem
* can shift colours in the next.
*
* Blume's Badge takes a custom colour only as hex and uses that one value in
* both themes, as text over a 12% tint of itself, so the rows sit in the
* narrow lightness band where that text holds ≥ 3:1 contrast on the light and
* the dark background alike; wider rows spread tags further but fail one theme.
*/
const LIGHTNESS = [0.55, 0.6];
const CHROMA = 0.15;
const HUE_STEP = 5;
interface Swatch {
hex: string;
lab: [number, number, number];
}
/**
* slug → `#rrggbb`, pairwise distinct, for every slug in `slugs`; `pages` is
* each problem page's tag slugs, the co-occurrence the spread is judged by.
*/
export function tagColors(slugs: Iterable<string>, pages: readonly (readonly string[])[]): Map<string, string> {
const universe = new Set(slugs);
const uses = new Map<string, number>();
const cotags = new Map<string, Set<string>>();
for (const page of pages) {
const on = page.filter((slug) => universe.has(slug));
for (const slug of on) {
uses.set(slug, (uses.get(slug) ?? 0) + 1);
const set = cotags.get(slug) ?? new Set<string>();
for (const other of on) if (other !== slug) set.add(other);
cotags.set(slug, set);
}
}
const order = [...universe].sort((a, b) => (uses.get(b) ?? 0) - (uses.get(a) ?? 0) || a.localeCompare(b));
const palette = swatches(order.length);
const free = new Set(palette.keys());
const chosen = new Map<string, Swatch>();
for (const slug of order) {
const near = [...(cotags.get(slug) ?? [])].flatMap((other) => chosen.get(other) ?? []);
const all = [...chosen.values()];
let best = -1;
let bestScore: [number, number] = [-1, -1];
for (const i of free) {
const score: [number, number] = [nearest(palette[i]!, near), nearest(palette[i]!, all)];
if (score[0] > bestScore[0] || (score[0] === bestScore[0] && score[1] > bestScore[1])) {
best = i;
bestScore = score;
}
}
free.delete(best);
chosen.set(slug, palette[best]!);
}
return new Map([...chosen].map(([slug, swatch]) => [slug, swatch.hex]));
}
/** Distance from `swatch` to the closest of `others`; Infinity when there are none. */
function nearest(swatch: Swatch, others: readonly Swatch[]): number {
let min = Number.POSITIVE_INFINITY;
for (const other of others) {
const d = Math.hypot(swatch.lab[0] - other.lab[0], swatch.lab[1] - other.lab[1], swatch.lab[2] - other.lab[2]);
if (d < min) min = d;
}
return min;
}
/**
* The palette: every `HUE_STEP` degrees in each `LIGHTNESS` row, the step
* shrunk when there are more tags than swatches. Duplicate hexes (possible
* only at a tiny step) are dropped, which keeps every assignment unique.
*/
function swatches(count: number): Swatch[] {
const step = Math.min(HUE_STEP, Math.max(1, Math.floor((360 * LIGHTNESS.length) / Math.max(count, 1))));
const seen = new Set<string>();
const out: Swatch[] = [];
for (const lightness of LIGHTNESS) {
for (let hue = 0; hue < 360; hue += step) {
const swatch = oklch(lightness, hue);
if (seen.has(swatch.hex)) continue;
seen.add(swatch.hex);
out.push(swatch);
}
}
return out;
}
/**
* `oklch(lightness CHROMA hue)` as sRGB hex plus its OKLab coordinates. Hues
* sRGB cannot reach at full chroma (cyans) give up chroma until they fit,
* rather than being clipped per channel, which would shift the hue.
*/
function oklch(lightness: number, hue: number): Swatch {
const rad = (hue * Math.PI) / 180;
for (let chroma = CHROMA; ; chroma -= 0.005) {
const a = chroma * Math.cos(rad);
const b = chroma * Math.sin(rad);
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
const rgb = [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614681 * s,
];
if (chroma > 0 && rgb.some((c) => c < 0 || c > 1)) continue;
const hex = rgb
.map((c) => {
const v = Math.min(1, Math.max(0, c));
const srgb = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
return Math.round(srgb * 255).toString(16).padStart(2, "0");
})
.join("");
return { hex: `#${hex}`, lab: [lightness, a, b] };
}
}
-7
View File
@@ -1,7 +0,0 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
/** shadcn's class combiner: conditional classes, later Tailwind utilities win. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
-28
View File
@@ -1,28 +0,0 @@
{
"name": "docs",
"private": true,
"type": "module",
"scripts": {
"dev": "blume dev",
"build": "blume build",
"doctor": "blume doctor"
},
"dependencies": {
"blume": "^2.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-hierarchy": "^3.1.2",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "3.8.0",
"tailwind-merge": "^3.7.0"
},
"devDependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@types/d3-hierarchy": "^3.1.7",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0"
}
}
-293
View File
@@ -1,293 +0,0 @@
---
/**
* `/appendix`: the glossary of LeetCode topic tags the campaign meets without
* teaching them as a topic (`appendix.ts`), one card per term, filed A–Z under
* a letter index. A card is the target of that tag's badge on every problem
* page (`/appendix#<slug>`, from `tagTarget()`), so it carries what the badge
* shows (the term's icon in the tag's colour from `lib/tag-colors.ts`, over
* the same tag universe `ProblemBadges.astro` colours) and lists the problem
* pages whose badge links here: the backlinks a per-term page used to have.
* The filter is a custom element over static markup, the way the Carousel
* is built; with no JS the page is the full glossary.
*/
import { getEntry } from "astro:content";
import data from "blume:data";
import Icon from "blume/components/Icon.astro";
import { withBase } from "blume/components/islands/base-path.ts";
import PageLayout from "blume/components/layout/PageLayout.astro";
import { APPENDIX } from "../appendix.ts";
import { tagColors } from "../lib/tag-colors.ts";
import { TOPIC_LANDINGS, tagTarget, type TopicTag } from "../topic-landings.ts";
const { config } = data;
const served = new Set<string>(data.routes.map((r: { path: string }) => r.path));
// Problem pages (`<num>. <name>` titles, the shape sync writes) with their tags.
const problems = await Promise.all(
data.routes
.filter((r: { title: string }) => /^\d+\.\s/u.test(r.title))
.map(async (r: { path: string; title: string; collection: string; entryId: string }) => {
const entry = await getEntry(r.collection, r.entryId);
const [, num, name] = r.title.match(/^(\d+)\.\s+(.+)$/u)!;
return {
route: r.path,
num: Number(num),
name: name!,
difficulty: entry?.data.sidebar?.badge as string | undefined,
tags: (entry?.data.topics ?? []) as TopicTag[],
};
}),
);
const colors = tagColors(
[...Object.keys(TOPIC_LANDINGS), ...Object.keys(APPENDIX)],
problems.map((p) => p.tags.map((t) => t.slug)),
);
const terms = Object.entries(APPENDIX)
.map(([slug, term]) => ({
slug,
...term,
color: colors.get(slug),
problems: problems
.filter((p) => p.tags.some((tag) => tagTarget(tag, p, served).href === `/appendix#${slug}`))
.sort((a, b) => a.num - b.num),
}))
.sort((a, b) => a.name.localeCompare(b.name));
const sections = new Map<string, typeof terms>();
for (const term of terms) {
const letter = term.name[0]!.toUpperCase();
sections.set(letter, [...(sections.get(letter) ?? []), term]);
}
const LETTERS = [..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"];
// A card's Solved list shows this many rows at a time; a longer one (Array has
// dozens) pages through them with numbered buttons under the list.
const PAGE_SIZE = 5;
// The /progress cards' difficulty palette (--chart-1..3 in theme.css), as on
// the changelog, so a Hard reads the same colour everywhere.
const DIFFICULTY: Record<string, { label: string; class: string }> = {
Easy: { label: "Easy", class: "border-chart-1/40 bg-chart-1/10 text-chart-1" },
Medium: { label: "Med.", class: "border-chart-2/40 bg-chart-2/10 text-chart-2" },
Hard: { label: "Hard", class: "border-chart-3/40 bg-chart-3/10 text-chart-3" },
};
const description =
"The LeetCode topics the campaign meets without teaching them as a topic: what each one means, and the solved problems that use it.";
---
<PageLayout
site={{ title: config.title, description: config.description }}
logo={config.logo}
favicon={config.favicon}
banner={config.banner}
analytics={config.analytics}
navigation={data.navigation}
fontCssVars={data.fontCssVars}
themeMode={config.theme.mode}
searchEnabled={config.search.enabled}
siteUrl={config.site}
ogEnabled={config.og.enabled}
structuredDataEnabled={config.structuredData}
page={{ title: `Appendix - ${config.title}`, description, route: "/appendix" }}
>
<section class="mx-auto w-full max-w-5xl px-6 py-16">
<h1 class="m-0 text-4xl font-semibold tracking-tight text-foreground">Appendix</h1>
<p class="mt-3 max-w-3xl text-lg text-muted-foreground">{description}</p>
<appendix-filter class="mt-10 block">
<div class="flex flex-col gap-4 border-border border-b pb-6 sm:flex-row sm:items-center sm:justify-between">
<nav aria-label="Letters" class="flex flex-wrap gap-1 font-mono text-sm">
{
LETTERS.map((letter) =>
sections.has(letter) ? (
<a
href={`#letter-${letter}`}
data-letter-link={letter}
class="inline-flex size-7 items-center justify-center rounded-md text-foreground no-underline transition-colors hover:bg-muted"
>
{letter}
</a>
) : (
<span class="inline-flex size-7 items-center justify-center text-muted-foreground/40" aria-hidden="true">
{letter}
</span>
),
)
}
</nav>
<label class="relative block sm:w-64">
<span class="sr-only">Filter terms</span>
<Icon
class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-muted-foreground"
name="search"
size={14}
/>
<input
type="search"
placeholder={`Filter ${terms.length} terms`}
class="w-full rounded-md border border-border bg-background py-1.5 pr-3 pl-8 text-foreground text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-foreground/40"
/>
</label>
</div>
{
[...sections].map(([letter, group]) => (
<section id={`letter-${letter}`} data-letter={letter} class="scroll-mt-24 pt-10">
<h2 class="m-0 font-mono text-2xl font-semibold text-foreground">{letter}</h2>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
{group.map((term) => (
<article
id={term.slug}
data-term={`${term.name} ${term.category} ${term.definition}`.toLowerCase()}
class="flex scroll-mt-24 flex-col rounded-lg border border-border bg-muted/30 p-5 transition-colors target:border-foreground/40 target:bg-muted/60"
>
<header class="flex items-start gap-3">
<span
class="flex size-9 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground"
style={term.color && `color: ${term.color}; border-color: ${term.color}66; background: ${term.color}1f`}
>
<Icon name={term.icon} size={18} />
</span>
<div class="min-w-0 flex-1">
<h3 class="m-0 text-base font-semibold leading-snug">
<a class="text-foreground no-underline hover:underline" href={`#${term.slug}`}>
{term.name}
</a>
</h3>
<p class="m-0 mt-0.5 font-mono text-[11px] uppercase tracking-wide text-muted-foreground">
{term.category}
</p>
</div>
<a
class="shrink-0 font-mono text-xs text-muted-foreground no-underline hover:underline"
href={`https://leetcode.com/problem-list/${term.slug}/`}
target="_blank"
rel="noopener"
>
LeetCode ↗
</a>
</header>
<p class="mt-3 mb-0 text-sm leading-relaxed text-muted-foreground">{term.definition}</p>
{term.problems.length > 0 && (
<details class="group mt-4 border-border border-t pt-3" open>
<summary class="mb-1 flex cursor-pointer list-none items-center justify-between gap-2 text-muted-foreground transition-colors hover:text-foreground [&::-webkit-details-marker]:hidden">
<span class="flex items-baseline gap-2 font-medium text-xs uppercase tracking-wide">
Solved
<span class="tabular-nums">{term.problems.length}</span>
</span>
<Icon class="shrink-0 transition-transform group-open:rotate-180" name="chevron-down" size={14} />
</summary>
<solved-pages class="block">
<ul class="m-0 list-none p-0 text-sm">
{term.problems.map((p, i) => (
<li data-page={Math.floor(i / PAGE_SIZE)}>
<a
class="-mx-2 flex items-baseline gap-3 rounded-md px-2 py-1 text-foreground no-underline transition-colors hover:bg-muted"
href={withBase(p.route)}
>
<span class="w-10 shrink-0 text-right font-mono text-muted-foreground text-xs tabular-nums">
{p.num}
</span>
<span class="min-w-0 flex-1 truncate">{p.name}</span>
{p.difficulty && DIFFICULTY[p.difficulty] && (
<span
class:list={[
"shrink-0 rounded-full border px-2 py-px font-mono text-[11px] leading-4",
DIFFICULTY[p.difficulty]!.class,
]}
>
{DIFFICULTY[p.difficulty]!.label}
</span>
)}
</a>
</li>
))}
</ul>
{term.problems.length > PAGE_SIZE && (
<nav
aria-label={`${term.name} solved pages`}
class="mt-2 flex flex-wrap justify-center gap-1 font-mono text-xs"
hidden
>
{Array.from({ length: Math.ceil(term.problems.length / PAGE_SIZE) }, (_, page) => (
<button
type="button"
data-goto={page}
aria-label={`Page ${page + 1}`}
class="inline-flex size-7 cursor-pointer items-center justify-center rounded-md border border-transparent text-muted-foreground tabular-nums transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:border-border aria-[current=page]:bg-muted aria-[current=page]:text-foreground"
>
{page + 1}
</button>
))}
</nav>
)}
</solved-pages>
</details>
)}
</article>
))}
</div>
</section>
))
}
<p data-empty hidden class="mt-10 text-muted-foreground">No term matches that filter.</p>
</appendix-filter>
</section>
</PageLayout>
<script>
/** Hides the cards (and letters) the filter text does not match. */
class AppendixFilter extends HTMLElement {
connectedCallback() {
const input = this.querySelector("input");
const empty = this.querySelector<HTMLElement>("[data-empty]");
if (!input || !empty) return;
input.addEventListener("input", () => {
const query = input.value.trim().toLowerCase();
let shown = 0;
for (const section of this.querySelectorAll<HTMLElement>("[data-letter]")) {
let visible = 0;
for (const card of section.querySelectorAll<HTMLElement>("[data-term]")) {
card.hidden = !card.dataset.term!.includes(query);
if (!card.hidden) visible++;
}
section.hidden = visible === 0;
const link = this.querySelector<HTMLElement>(`[data-letter-link="${section.dataset.letter}"]`);
link?.classList.toggle("opacity-30", visible === 0);
shown += visible;
}
empty.hidden = shown > 0;
});
}
}
customElements.define("appendix-filter", AppendixFilter);
/**
* Pages a card's Solved list: shows the rows of one page and marks its
* number button current. Server markup lists every row with the buttons
* hidden, so without JS the card shows the whole list.
*/
class SolvedPages extends HTMLElement {
connectedCallback() {
const nav = this.querySelector<HTMLElement>("nav");
if (!nav) return;
const rows = this.querySelectorAll<HTMLElement>("li[data-page]");
const buttons = nav.querySelectorAll<HTMLButtonElement>("button[data-goto]");
const show = (page: string) => {
for (const row of rows) row.hidden = row.dataset.page !== page;
for (const button of buttons) {
if (button.dataset.goto === page) button.setAttribute("aria-current", "page");
else button.removeAttribute("aria-current");
}
};
nav.addEventListener("click", (event) => {
const button = (event.target as Element).closest<HTMLButtonElement>("button[data-goto]");
if (button) show(button.dataset.goto!);
});
show("0");
nav.hidden = false;
}
}
customElements.define("solved-pages", SolvedPages);
</script>
-120
View File
@@ -1,120 +0,0 @@
---
/**
* `/changelog`: every topic and review release on one page, newest first — a
* vertical timeline, the date on the left and one card per release on the
* right, with no per-release subpages. Replaces Blume's generated changelog
* (a custom page wins its route); the header links here from
* `navigation.actions` in blume.config.ts. Data: lib/releases.ts.
*/
import PageLayout from "blume/components/layout/PageLayout.astro";
import data from "blume:data";
import { withBase } from "blume/components/islands/base-path.ts";
import redirects from "../redirects";
import { readReleases } from "../lib/releases.ts";
const { config } = data;
const releases = await readReleases();
const DATE = new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
month: "short",
day: "numeric",
year: "numeric",
});
const issueUrl = (n: number) => `https://github.com/prdlk/leetcode/issues/${n}`;
// LC number -> its docs page route, off sync's `/<num>` short links — the
// page itself, not the redirect, so a click is one hop. A problem with no page
// (none yet) links to its issue instead.
const pages = new Map(redirects.map((r) => [Number(r.from.slice(1)), r.to]));
const problemHref = (lc: number, issue: number) => {
const route = pages.get(lc);
return route ? withBase(route) : issueUrl(issue);
};
// The /progress cards' difficulty palette (--chart-1..3 in theme.css), so a
// Hard reads the same colour here as in the SolvedRing gauge.
const DIFFICULTY: Record<string, { label: string; class: string }> = {
easy: { label: "Easy", class: "border-chart-1/40 bg-chart-1/10 text-chart-1" },
medium: { label: "Med.", class: "border-chart-2/40 bg-chart-2/10 text-chart-2" },
hard: { label: "Hard", class: "border-chart-3/40 bg-chart-3/10 text-chart-3" },
};
const description = "Every topic and review the campaign finished, newest first.";
---
<PageLayout
site={{ title: config.title, description: config.description }}
logo={config.logo}
favicon={config.favicon}
banner={config.banner}
analytics={config.analytics}
navigation={data.navigation}
fontCssVars={data.fontCssVars}
themeMode={config.theme.mode}
searchEnabled={config.search.enabled}
siteUrl={config.site}
ogEnabled={config.og.enabled}
structuredDataEnabled={config.structuredData}
page={{ title: `Changelog - ${config.title}`, description, route: "/changelog" }}
>
<section class="mx-auto w-full max-w-4xl px-6 py-16">
<h1 class="m-0 text-4xl font-semibold tracking-tight text-foreground">Changelog</h1>
<p class="mt-3 text-lg text-muted-foreground">{description}</p>
<ol class="relative mt-12 list-none p-0">
{
releases.map((r, i) => (
<li class="relative grid gap-4 pb-14 pl-8 sm:grid-cols-[11rem_1fr] sm:gap-8">
{/* The rail: a line down the left edge, a ring at each release. */}
{i < releases.length - 1 && (
<span class="absolute top-3 bottom-0 left-[5px] w-px bg-border" aria-hidden="true" />
)}
<span
class="absolute top-1.5 left-0 size-[11px] rounded-full border border-emerald-500 bg-background"
aria-hidden="true"
/>
<div class="font-mono text-sm">
<time class="block uppercase tracking-wide text-foreground" datetime={r.finishedAt.toISOString()}>
{DATE.format(r.finishedAt)}
</time>
<a class="mt-1 block text-xs text-muted-foreground no-underline hover:underline" href={r.url}>
{r.kind} release ↗
</a>
</div>
<article id={`issue-${r.issue}`} class="rounded-lg border border-border bg-muted/30 p-6">
<header class="flex items-baseline justify-between gap-4">
<h2 class="m-0 text-lg font-semibold text-foreground">{r.name}</h2>
<a
class="shrink-0 font-mono text-sm text-muted-foreground no-underline hover:underline"
href={issueUrl(r.issue)}
>
#{r.issue}
</a>
</header>
<ul class="mt-4 mb-0 list-none space-y-2 p-0">
{r.problems.map((p) => (
<li class="flex items-baseline gap-3 text-base leading-relaxed text-muted-foreground">
<span class="select-none text-border" aria-hidden="true">—</span>
<span class="min-w-0">
<span class="mr-2 font-mono text-xs uppercase tracking-wide">{p.set}</span>
<a class="text-foreground no-underline hover:underline" href={problemHref(p.lc, p.issue)}>
{p.title}
</a>
{DIFFICULTY[p.difficulty] && (
<span
class:list={[
"ml-2 inline-block rounded-full border px-2 py-px align-middle font-mono text-[11px] leading-4",
DIFFICULTY[p.difficulty]!.class,
]}
>
{DIFFICULTY[p.difficulty]!.label}
</span>
)}
</span>
</li>
))}
</ul>
</article>
</li>
))
}
</ol>
</section>
</PageLayout>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

@@ -1,48 +0,0 @@
<svg viewBox="0 0 650 280" xmlns="http://www.w3.org/2000/svg">
<rect width="650" height="280" fill="#f8f9fa" rx="10"/>
<!-- Title -->
<text x="325" y="30" font-family="Arial, sans-serif" font-size="18" font-weight="bold" text-anchor="middle" fill="#2c3e50">Hash Table: Three Core Operations (All O(1))</text>
<!-- Insert -->
<rect x="50" y="60" width="180" height="180" fill="#ffffff" stroke="#27ae60" stroke-width="3" rx="8"/>
<text x="140" y="85" font-family="Arial, sans-serif" font-size="15" font-weight="bold" text-anchor="middle" fill="#27ae60">INSERT</text>
<text x="140" y="115" font-family="monospace" font-size="13" text-anchor="middle" fill="#2c3e50">map["Alice"] = </text>
<text x="140" y="135" font-family="monospace" font-size="13" text-anchor="middle" fill="#2c3e50">"555-1234"</text>
<text x="140" y="170" font-family="Arial, sans-serif" font-size="11" text-anchor="middle" fill="#7f8c8d">1. Hash "Alice"</text>
<text x="140" y="188" font-family="Arial, sans-serif" font-size="11" text-anchor="middle" fill="#7f8c8d">2. Compute index</text>
<text x="140" y="206" font-family="Arial, sans-serif" font-size="11" text-anchor="middle" fill="#7f8c8d">3. Store at index</text>
<text x="140" y="230" font-family="Arial, sans-serif" font-size="13" font-weight="bold" text-anchor="middle" fill="#27ae60">O(1)</text>
<!-- Lookup -->
<rect x="240" y="60" width="180" height="180" fill="#ffffff" stroke="#3498db" stroke-width="3" rx="8"/>
<text x="330" y="85" font-family="Arial, sans-serif" font-size="15" font-weight="bold" text-anchor="middle" fill="#3498db">LOOKUP</text>
<text x="330" y="115" font-family="monospace" font-size="13" text-anchor="middle" fill="#2c3e50">value = </text>
<text x="330" y="135" font-family="monospace" font-size="13" text-anchor="middle" fill="#2c3e50">map["Alice"]</text>
<text x="330" y="170" font-family="Arial, sans-serif" font-size="11" text-anchor="middle" fill="#7f8c8d">1. Hash "Alice"</text>
<text x="330" y="188" font-family="Arial, sans-serif" font-size="11" text-anchor="middle" fill="#7f8c8d">2. Compute index</text>
<text x="330" y="206" font-family="Arial, sans-serif" font-size="11" text-anchor="middle" fill="#7f8c8d">3. Read from index</text>
<text x="330" y="230" font-family="Arial, sans-serif" font-size="13" font-weight="bold" text-anchor="middle" fill="#3498db">O(1)</text>
<!-- Delete -->
<rect x="430" y="60" width="180" height="180" fill="#ffffff" stroke="#e74c3c" stroke-width="3" rx="8"/>
<text x="520" y="85" font-family="Arial, sans-serif" font-size="15" font-weight="bold" text-anchor="middle" fill="#e74c3c">DELETE</text>
<text x="520" y="115" font-family="monospace" font-size="13" text-anchor="middle" fill="#2c3e50">delete</text>
<text x="520" y="135" font-family="monospace" font-size="13" text-anchor="middle" fill="#2c3e50">map["Alice"]</text>
<text x="520" y="170" font-family="Arial, sans-serif" font-size="11" text-anchor="middle" fill="#7f8c8d">1. Hash "Alice"</text>
<text x="520" y="188" font-family="Arial, sans-serif" font-size="11" text-anchor="middle" fill="#7f8c8d">2. Compute index</text>
<text x="520" y="206" font-family="Arial, sans-serif" font-size="11" text-anchor="middle" fill="#7f8c8d">3. Remove from index</text>
<text x="520" y="230" font-family="Arial, sans-serif" font-size="13" font-weight="bold" text-anchor="middle" fill="#e74c3c">O(1)</text>
<!-- Bottom note -->
<text x="325" y="265" font-family="Arial, sans-serif" font-size="13" text-anchor="middle" fill="#7f8c8d">All operations complete in constant time (on average)</text>
</svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

@@ -1,29 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1166 466" width="1166" height="466" font-family="Inter, Helvetica, Arial, sans-serif" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTplNTQ2ZmQ3OS1hNTQwLTQwYzctYjMxZC1mOWYyODMxZWUwN2YAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaPzccOzcNqqGwCVEue2cwv0AAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDo3OGU1MmE0MS1iMWZjLTQ5MTItOGFkNC1kMjQzODM2MjAyZTRscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNocPSh2E7eebWNUztuxZwhMAAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggfICJhx/gC3AGlwvd6lmmDILnZfr/OwUArljCD0UDNCWkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaBCXwkaI77miLLMhWyIze/IAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCB1LL7eMTTGwGK0wDUm4AhFQ7VuJ1M+odScr8pTYladZWRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBjKZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmU1NDZmZDc5LWE1NDAtNDBjNy1iMzFkLWY5ZjI4MzFlZTA3Zi9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOmVjYTBhMGI3LTM2YmEtNGU2NC05ZjY5LWVhYTY1YmI4MzEyOHJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCB8gImHH+ALcAaXC93qWaYMgudl+v87BQCuWMIPRQM0JaJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggSiOomuuA+mAkOE2eUUrEUWQVHEzCQnln5mDcE/fV6vSiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggzqJMOqEsC3jDPpPwzhLmPHdw+3qStk06lig2fLA7eKB0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALine truncated
<rect width="1166" height="466" fill="#f4f5f7"/>
<!-- Title -->
<text x="265" y="50" font-size="28" font-weight="700" fill="#2d3a4a">Merge = [min(starts), max(ends)]</text>
<!-- Guide lines at the outer edges (merge bounds) -->
<line x1="242" y1="115" x2="242" y2="290" stroke="#7f8790" stroke-width="2" stroke-dasharray="6 5"/>
<line x1="971" y1="115" x2="971" y2="290" stroke="#7f8790" stroke-width="2" stroke-dasharray="6 5"/>
<!-- x = [2, 8] -->
<text x="238" y="108" font-size="20" fill="#4a5563" font-family="JetBrains Mono, Menlo, Consolas, monospace" text-anchor="middle">2</text>
<text x="728" y="108" font-size="20" fill="#4a5563" font-family="JetBrains Mono, Menlo, Consolas, monospace" text-anchor="middle">8</text>
<text x="200" y="152" font-size="22" font-weight="600" fill="#3598db" text-anchor="middle">x</text>
<rect x="242" y="121" width="486" height="44" rx="6" fill="#3598db"/>
<!-- y = [5, 11] -->
<text x="200" y="218" font-size="22" font-weight="600" fill="#f39c12" text-anchor="middle">y</text>
<rect x="485" y="187" width="486" height="44" rx="6" fill="#f39c12"/>
<text x="485" y="260" font-size="20" fill="#4a5563" font-family="JetBrains Mono, Menlo, Consolas, monospace" text-anchor="middle">5</text>
<text x="966" y="260" font-size="20" fill="#4a5563" font-family="JetBrains Mono, Menlo, Consolas, monospace" text-anchor="middle">11</text>
<!-- Merged result -->
<rect x="242" y="297" width="729" height="22" rx="5" fill="#d5f5e3" stroke="#27ae60" stroke-width="2.5"/>
<text x="606" y="358" font-size="22" fill="#27ae60" font-family="JetBrains Mono, Menlo, Consolas, monospace" text-anchor="middle">merge = [2, 11]</text>
<!-- Worked formula -->
<text x="265" y="430" font-size="22" fill="#2d3a4a" font-family="JetBrains Mono, Menlo, Consolas, monospace">min(2, 5) = 2 and max(8, 11) = 11</text>
</svg>

Before

Width:  |  Height:  |  Size: 9.6 KiB

@@ -1,28 +0,0 @@
<svg viewBox="0 0 820 380" xmlns="http://www.w3.org/2000/svg">
<!-- Background (REQUIRED) - MUST BE FIRST ELEMENT -->
<rect width="820" height="380" fill="#f8f9fa" rx="10"/>
<!-- Title (left-anchored, manually centered) -->
<text x="239" y="44" font-family="Arial, sans-serif" font-size="22" font-weight="bold" fill="#2c3e50">When do intervals overlap?</text>
<!-- Panel 1: no overlap. Labels sit OUTSIDE the bars. -->
<text x="60" y="94" font-family="Arial, sans-serif" font-size="16" fill="#2c3e50">No overlap: a ends before b starts</text>
<text x="90" y="132" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="#3498db">a</text>
<rect x="110" y="110" width="180" height="32" fill="#3498db" rx="4"/>
<text x="330" y="132" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="#f39c12">b</text>
<rect x="350" y="110" width="180" height="32" fill="#f39c12" rx="4"/>
<text x="560" y="132" font-family="monospace" font-size="18" fill="#2c3e50">a.end &lt; b.start</text>
<!-- Panel 2: overlap. Bars on separate rows so they never visually collide. -->
<text x="60" y="210" font-family="Arial, sans-serif" font-size="16" fill="#2c3e50">Overlap: their times cross</text>
<text x="90" y="250" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="#3498db">a</text>
<rect x="110" y="228" width="240" height="32" fill="#3498db" rx="4"/>
<text x="480" y="300" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="#f39c12">b</text>
<rect x="230" y="278" width="240" height="32" fill="#f39c12" rx="4"/>
<!-- guide lines marking the shared x-range (paths, no arrowheads) -->
<path d="M 230,224 L 230,314" stroke="#7f8c8d" stroke-width="1.5" stroke-dasharray="4 3" fill="none"/>
<path d="M 350,224 L 350,314" stroke="#7f8c8d" stroke-width="1.5" stroke-dasharray="4 3" fill="none"/>
<rect x="230" y="324" width="120" height="12" fill="#d1fae5" stroke="#10b981" stroke-width="1.5" rx="3"/>
<text x="244" y="362" font-family="Arial, sans-serif" font-size="15" fill="#27ae60">shared = [max start, min end]</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,28 +0,0 @@
<svg viewBox="0 0 820 340" xmlns="http://www.w3.org/2000/svg">
<!-- Background (REQUIRED) - MUST BE FIRST ELEMENT -->
<rect width="820" height="340" fill="#f8f9fa" rx="10"/>
<!-- Title (left-anchored, manually centered) -->
<text x="206" y="42" font-family="Arial, sans-serif" font-size="20" font-weight="bold" fill="#2c3e50">Overlap = [max(starts), min(ends)]</text>
<!-- interval x = [2, 8]; endpoints labeled ABOVE the bar -->
<text x="184" y="82" font-family="monospace" font-size="15" fill="#2c3e50">2</text>
<text x="512" y="82" font-family="monospace" font-size="15" fill="#2c3e50">8</text>
<text x="158" y="112" font-family="monospace" font-size="16" font-weight="bold" fill="#3498db">x</text>
<rect x="190" y="90" width="330" height="30" fill="#3498db" rx="4"/>
<!-- interval y = [5, 11]; endpoints labeled BELOW the bar -->
<text x="158" y="157" font-family="monospace" font-size="16" font-weight="bold" fill="#f39c12">y</text>
<rect x="355" y="135" width="330" height="30" fill="#f39c12" rx="4"/>
<text x="349" y="185" font-family="monospace" font-size="15" fill="#2c3e50">5</text>
<text x="672" y="185" font-family="monospace" font-size="15" fill="#2c3e50">11</text>
<!-- shared range [5, 8] -->
<path d="M 355,86 L 355,205" stroke="#7f8c8d" stroke-width="1.5" stroke-dasharray="4 3" fill="none"/>
<path d="M 520,86 L 520,205" stroke="#7f8c8d" stroke-width="1.5" stroke-dasharray="4 3" fill="none"/>
<rect x="355" y="210" width="165" height="14" fill="#d1fae5" stroke="#10b981" stroke-width="2" rx="3"/>
<text x="405" y="252" font-family="monospace" font-size="16" font-weight="bold" fill="#27ae60">overlap = [5, 8]</text>
<!-- computation -->
<text x="206" y="300" font-family="monospace" font-size="16" fill="#2c3e50">max(2, 5) = 5 and min(8, 11) = 8</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

-17
View File
@@ -1,17 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 94 110.92">
<defs/>
<g id="LeetCode’s-new-logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-812.000000, -3714.000000)" id="logo_english_dark">
<g transform="translate(812.000000, 3714.000000)">
<g id="Logo_English">
<g id="Group-10">
<path d="M67.5068339,83.0664138 C70.0005384,80.5763786 74.0371402,80.5828822 76.5228362,83.0809398 C79.0085322,85.5789975 79.00204,89.6226456 76.5083355,92.1126808 L65.4351451,103.169577 C55.2192332,113.370744 38.5604663,113.518673 28.1722578,103.513204 C28.112217,103.455678 23.486583,98.9201326 8.22702585,83.9570195 C-1.92478479,74.0028895 -2.93614945,58.0748736 6.61697549,47.8463644 L24.4286944,28.7745461 C33.9100043,18.6218594 51.3874487,17.5122246 62.2279907,26.2789232 L78.4052912,39.3620235 C81.1448956,41.5776292 81.5728103,45.5984975 79.3610655,48.3428842 C77.1493207,51.0872709 73.1354592,51.5159327 70.3958548,49.300327 L54.2186634,36.2173149 C48.5492813,31.6325105 38.631911,32.2621597 33.7398535,37.5006265 L15.9279056,56.5726899 C11.2772073,61.552182 11.7865613,69.5740156 17.1461283,74.8292186 C28.3515339,85.8169393 36.9874071,94.2846214 36.9973988,94.294225 C42.3981571,99.4959838 51.130862,99.418438 56.43358,94.1233737 L67.5068339,83.0664138 Z" id="Combined-Shape" fill="#FFA116" fill-rule="nonzero"/>
<path d="M40.6069914,72.0014117 C37.086019,72.0014117 34.2317068,69.142117 34.2317068,65.6149982 C34.2317068,62.0878794 37.086019,59.2285847 40.6069914,59.2285847 L87.6247154,59.2285847 C91.1456879,59.2285847 94,62.0878794 94,65.6149982 C94,69.142117 91.1456879,72.0014117 87.6247154,72.0014117 L40.6069914,72.0014117 Z" id="Path-2" fill="#B3B3B3"/>
<path d="M49.4124315,2.02335002 C51.8178981,-0.552320454 55.852269,-0.686893945 58.4234511,1.72277172 C60.9946333,4.13243738 61.1289722,8.17385083 58.7235056,10.7495213 L15.9282277,56.5728697 C11.2773659,61.551984 11.7867168,69.5737689 17.1459309,74.8291832 L36.9094236,94.2091099 C39.4255514,96.6764051 39.4686234,100.719828 37.0056277,103.240348 C34.5426319,105.760868 30.5062548,105.804016 27.990127,103.33672 L8.22654289,83.9567041 C-1.92467414,74.0021005 -2.93603527,58.0741402 6.61751533,47.846311 L49.4124315,2.02335002 Z" id="Stroke-3" fill="#fff"/>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

@@ -1,66 +0,0 @@
<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg">
<!-- Background (REQUIRED) -->
<rect width="800" height="450" fill="#f8f9fa" rx="10"/>
<!-- Title and Subtitle -->
<text x="400" y="45" font-family="Arial, sans-serif" font-size="26" font-weight="bold" fill="#2c3e50" text-anchor="middle">Sliding Window: Fixed Size</text>
<text x="400" y="75" font-family="Arial, sans-serif" font-size="16" fill="#7f8c8d" text-anchor="middle">Window of size K=3 moving through the array.</text>
<!-- Step 1: Initial window -->
<g transform="translate(150, 130)">
<text x="0" y="-20" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">1. Current window (Sum = 10)</text>
<!-- Array -->
<g transform="translate(0, 20)">
<rect x="0" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
<text x="30" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">2</text>
<rect x="70" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
<text x="100" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">3</text>
<rect x="140" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
<text x="170" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">5</text>
<rect x="210" y="0" width="60" height="50" fill="#ffffff" stroke="#dee2e6" stroke-width="2" rx="3"/>
<text x="240" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">1</text>
<rect x="280" y="0" width="60" height="50" fill="#ffffff" stroke="#dee2e6" stroke-width="2" rx="3"/>
<text x="310" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">8</text>
<!-- Bracket -->
<path d="M 0,60 L 0,70 L 200,70 L 200,60" fill="none" stroke="#10b981" stroke-width="2"/>
</g>
</g>
<!-- Transition -->
<g transform="translate(400, 260)">
<path d="M 0,0 L 0,30" stroke="#dee2e6" stroke-width="2" fill="none"/>
<text x="10" y="20" font-family="Arial, sans-serif" font-size="14" font-style="italic" fill="#7f8c8d">Slide window: -2, +1</text>
</g>
<!-- Step 2: Slid window -->
<g transform="translate(150, 340)">
<text x="0" y="-20" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">2. Next window (Sum = 9)</text>
<!-- Array -->
<g transform="translate(0, 20)">
<rect x="0" y="0" width="60" height="50" fill="#e5e7eb" stroke="#94a3b8" stroke-width="2" rx="3"/>
<text x="30" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#94a3b8">2</text>
<rect x="70" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
<text x="100" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">3</text>
<rect x="140" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
<text x="170" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">5</text>
<rect x="210" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
<text x="240" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">1</text>
<rect x="280" y="0" width="60" height="50" fill="#ffffff" stroke="#dee2e6" stroke-width="2" rx="3"/>
<text x="310" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">8</text>
<!-- Bracket -->
<path d="M 70,60 L 70,70 L 270,70 L 270,60" fill="none" stroke="#10b981" stroke-width="2"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

@@ -1,66 +0,0 @@
<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg">
<!-- Background (REQUIRED) -->
<rect width="800" height="450" fill="#f8f9fa" rx="10"/>
<!-- Title and Subtitle -->
<text x="400" y="45" font-family="Arial, sans-serif" font-size="26" font-weight="bold" fill="#2c3e50" text-anchor="middle">Sliding Window: Flexible Size</text>
<text x="400" y="75" font-family="Arial, sans-serif" font-size="16" fill="#7f8c8d" text-anchor="middle">Window expands until invalid, then shrinks until valid again.</text>
<!-- Step 1: Invalid state -->
<g transform="translate(150, 130)">
<text x="0" y="-20" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">1. Expansion breaks invariant</text>
<!-- Array -->
<g transform="translate(0, 20)">
<rect x="0" y="0" width="60" height="50" fill="#fee2e2" stroke="#ef4444" stroke-width="3" rx="3"/>
<text x="30" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">a</text>
<polygon points="30,60 25,70 35,70" fill="#27ae60"/>
<text x="30" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#27ae60" text-anchor="middle">L</text>
<rect x="70" y="0" width="60" height="50" fill="#fee2e2" stroke="#ef4444" stroke-width="3" rx="3"/>
<text x="100" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">b</text>
<rect x="140" y="0" width="60" height="50" fill="#fee2e2" stroke="#ef4444" stroke-width="3" rx="3"/>
<text x="170" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">c</text>
<rect x="210" y="0" width="60" height="50" fill="#fee2e2" stroke="#ef4444" stroke-width="4" rx="3"/>
<text x="240" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">a</text>
<polygon points="240,60 235,70 245,70" fill="#3498db"/>
<text x="240" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#3498db" text-anchor="middle">R</text>
<text x="300" y="32" font-family="Arial, sans-serif" font-size="14" fill="#ef4444" font-weight="bold">Duplicate 'a'!</text>
</g>
</g>
<!-- Transition -->
<g transform="translate(400, 260)">
<path d="M 0,0 L 0,30" stroke="#dee2e6" stroke-width="2" fill="none"/>
<text x="10" y="20" font-family="Arial, sans-serif" font-size="14" font-style="italic" fill="#7f8c8d">Shrink from left</text>
</g>
<!-- Step 2: Valid again -->
<g transform="translate(150, 340)">
<text x="0" y="-20" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">2. Invariant restored</text>
<!-- Array -->
<g transform="translate(0, 20)">
<rect x="0" y="0" width="60" height="50" fill="#e5e7eb" stroke="#94a3b8" stroke-width="2" rx="3"/>
<text x="30" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#94a3b8">a</text>
<rect x="70" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
<text x="100" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">b</text>
<polygon points="100,60 95,70 105,70" fill="#27ae60"/>
<text x="100" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#27ae60" text-anchor="middle">L</text>
<rect x="140" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
<text x="170" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">c</text>
<rect x="210" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
<text x="240" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">a</text>
<polygon points="240,60 235,70 245,70" fill="#3498db"/>
<text x="240" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#3498db" text-anchor="middle">R</text>
<text x="300" y="32" font-family="Arial, sans-serif" font-size="14" fill="#10b981" font-weight="bold">Valid again!</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

@@ -1,54 +0,0 @@
<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg">
<!-- Background (REQUIRED) -->
<rect width="800" height="450" fill="#f8f9fa" rx="10"/>
<!-- Title and Subtitle -->
<text x="400" y="45" font-family="Arial, sans-serif" font-size="26" font-weight="bold" fill="#2c3e50" text-anchor="middle">Sliding Window: Frequency Tracking</text>
<text x="400" y="75" font-family="Arial, sans-serif" font-size="16" fill="#7f8c8d" text-anchor="middle">Maintaining a character frequency map for the current window.</text>
<!-- Left: Window -->
<g transform="translate(100, 150)">
<text x="0" y="-30" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">Current Window</text>
<!-- Array -->
<g transform="translate(0, 20)">
<rect x="0" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
<text x="30" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">a</text>
<polygon points="30,60 25,70 35,70" fill="#27ae60"/>
<text x="30" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#27ae60" text-anchor="middle">L</text>
<rect x="70" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
<text x="100" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">b</text>
<rect x="140" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
<text x="170" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">a</text>
<polygon points="170,60 165,70 175,70" fill="#3498db"/>
<text x="170" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#3498db" text-anchor="middle">R</text>
<rect x="210" y="0" width="60" height="50" fill="#ffffff" stroke="#dee2e6" stroke-width="2" rx="3"/>
<text x="240" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">c</text>
</g>
</g>
<!-- Right: Frequency Map -->
<g transform="translate(500, 150)">
<text x="0" y="-30" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">Frequency Map</text>
<!-- Map Container -->
<rect x="0" y="0" width="150" height="120" fill="#ffffff" stroke="#dee2e6" stroke-width="2" rx="8"/>
<!-- Entries -->
<g transform="translate(25, 35)">
<text x="0" y="0" font-family="monospace" font-size="18" fill="#2c3e50">'a' : 2</text>
<text x="0" y="30" font-family="monospace" font-size="18" fill="#2c3e50">'b' : 1</text>
<text x="0" y="60" font-family="monospace" font-size="18" fill="#7f8c8d">'c' : 0</text>
</g>
<!-- Invariant label -->
<rect x="-20" y="140" width="190" height="40" fill="#e3f2fd" stroke="#3498db" stroke-width="1" rx="5"/>
<text x="75" y="165" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="#1565c0" text-anchor="middle">INVARIANT: SYNCED STATE</text>
</g>
<!-- Connector -->
<path d="M 380,195 L 480,195" stroke="#dee2e6" stroke-width="2" stroke-dasharray="5,5" fill="none"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

-95
View File
@@ -1,95 +0,0 @@
// Generated by `bun run sync` (apps/cli/sync.ts) — do not edit.
// `/leetcode/<num>` → that problem's page; exact paths, base added by Blume.
export default [
{ from: "/1", to: "/hash-table/two-sum" },
{ from: "/2", to: "/hybrid-structures/add-two-numbers" },
{ from: "/3", to: "/sliding-window/longest-substring-without-repeating-characters" },
{ from: "/11", to: "/two-pointers/container-with-most-water" },
{ from: "/15", to: "/two-pointers/3sum" },
{ from: "/20", to: "/stack/valid-parentheses" },
{ from: "/21", to: "/linked-list/merge-two-sorted-lists" },
{ from: "/23", to: "/heap-priority-queue/merge-k-sorted-lists" },
{ from: "/33", to: "/binary-search/search-in-rotated-sorted-array" },
{ from: "/34", to: "/binary-search/find-first-and-last-position-of-element-in-sorted-array" },
{ from: "/35", to: "/binary-search/search-insert-position" },
{ from: "/39", to: "/backtracking/combination-sum" },
{ from: "/42", to: "/two-pointers/trapping-rain-water" },
{ from: "/46", to: "/backtracking/permutations" },
{ from: "/48", to: "/matrix-index-math/rotate-image" },
{ from: "/49", to: "/hash-table/group-anagrams" },
{ from: "/53", to: "/greedy/maximum-subarray" },
{ from: "/54", to: "/matrix-index-math/spiral-matrix" },
{ from: "/55", to: "/greedy/jump-game" },
{ from: "/56", to: "/intervals/merge-intervals" },
{ from: "/57", to: "/intervals/insert-interval" },
{ from: "/62", to: "/multi-d-grid-dp/unique-paths" },
{ from: "/64", to: "/multi-d-grid-dp/minimum-path-sum" },
{ from: "/70", to: "/1d-dynamic-programming/climbing-stairs" },
{ from: "/73", to: "/matrix-index-math/set-matrix-zeroes" },
{ from: "/74", to: "/binary-search/search-a-2d-matrix" },
{ from: "/78", to: "/backtracking/subsets" },
{ from: "/98", to: "/binary-search-tree/validate-binary-search-tree" },
{ from: "/102", to: "/tree-bfs/binary-tree-level-order-traversal" },
{ from: "/104", to: "/tree-dfs/maximum-depth-of-binary-tree" },
{ from: "/121", to: "/sliding-window/best-time-to-buy-and-sell-stock" },
{ from: "/125", to: "/two-pointers/valid-palindrome" },
{ from: "/127", to: "/graph-bfs/word-ladder" },
{ from: "/128", to: "/hash-table/longest-consecutive-sequence" },
{ from: "/133", to: "/graph-dfs/clone-graph" },
{ from: "/134", to: "/greedy/gas-station" },
{ from: "/141", to: "/linked-list/linked-list-cycle" },
{ from: "/146", to: "/hybrid-structures/lru-cache" },
{ from: "/150", to: "/stack/evaluate-reverse-polish-notation" },
{ from: "/153", to: "/binary-search/find-minimum-in-rotated-sorted-array" },
{ from: "/155", to: "/stack/min-stack" },
{ from: "/162", to: "/binary-search/find-peak-element" },
{ from: "/167", to: "/two-pointers/two-sum-ii-input-array-is-sorted" },
{ from: "/189", to: "/two-pointers/rotate-array" },
{ from: "/198", to: "/1d-dynamic-programming/house-robber" },
{ from: "/199", to: "/tree-bfs/binary-tree-right-side-view" },
{ from: "/200", to: "/graph-dfs/number-of-islands" },
{ from: "/205", to: "/hash-table/isomorphic-strings" },
{ from: "/206", to: "/linked-list/reverse-linked-list" },
{ from: "/207", to: "/topological-sort/course-schedule" },
{ from: "/208", to: "/trie/implement-trie-prefix-tree" },
{ from: "/209", to: "/sliding-window/minimum-size-subarray-sum" },
{ from: "/210", to: "/topological-sort/course-schedule-ii" },
{ from: "/211", to: "/trie/design-add-and-search-words-data-structure" },
{ from: "/215", to: "/heap-priority-queue/kth-largest-element-in-an-array" },
{ from: "/217", to: "/hash-table/contains-duplicate" },
{ from: "/226", to: "/tree-dfs/invert-binary-tree" },
{ from: "/230", to: "/binary-search-tree/kth-smallest-element-in-a-bst" },
{ from: "/235", to: "/binary-search-tree/lowest-common-ancestor-of-a-binary-search-tree" },
{ from: "/236", to: "/tree-dfs/lowest-common-ancestor-of-a-binary-tree" },
{ from: "/238", to: "/prefix-sum/product-of-array-except-self" },
{ from: "/242", to: "/hash-table/valid-anagram" },
{ from: "/290", to: "/hash-table/word-pattern" },
{ from: "/303", to: "/prefix-sum/range-sum-query-immutable" },
{ from: "/322", to: "/1d-dynamic-programming/coin-change" },
{ from: "/323", to: "/union-find/number-of-connected-components-in-an-undirected-graph" },
{ from: "/344", to: "/two-pointers/reverse-string" },
{ from: "/347", to: "/hash-table/top-k-frequent-elements" },
{ from: "/387", to: "/hash-table/first-unique-character-in-a-string" },
{ from: "/392", to: "/two-pointers/is-subsequence" },
{ from: "/417", to: "/graph-dfs/pacific-atlantic-water-flow" },
{ from: "/424", to: "/sliding-window/longest-repeating-character-replacement" },
{ from: "/435", to: "/intervals/non-overlapping-intervals" },
{ from: "/451", to: "/hash-table/sort-characters-by-frequency" },
{ from: "/525", to: "/prefix-sum/contiguous-array" },
{ from: "/560", to: "/prefix-sum/subarray-sum-equals-k" },
{ from: "/567", to: "/sliding-window/permutation-in-string" },
{ from: "/643", to: "/sliding-window/maximum-average-subarray-i" },
{ from: "/684", to: "/union-find/redundant-connection" },
{ from: "/704", to: "/binary-search/binary-search" },
{ from: "/739", to: "/monotonic-stack/daily-temperatures" },
{ from: "/853", to: "/monotonic-stack/car-fleet" },
{ from: "/875", to: "/binary-search/koko-eating-bananas" },
{ from: "/973", to: "/heap-priority-queue/k-closest-points-to-origin" },
{ from: "/974", to: "/prefix-sum/subarray-sums-divisible-by-k" },
{ from: "/977", to: "/two-pointers/squares-of-a-sorted-array" },
{ from: "/994", to: "/graph-bfs/rotting-oranges" },
{ from: "/1004", to: "/sliding-window/max-consecutive-ones-iii" },
{ from: "/1143", to: "/multi-d-grid-dp/longest-common-subsequence" },
{ from: "/1448", to: "/tree-bfs/count-good-nodes-in-binary-tree" },
{ from: "/1832", to: "/hash-table/check-if-the-sentence-is-pangram" },
];
-71
View File
@@ -1,71 +0,0 @@
/*
* shadcn/ui tokens on top of Blume's theme. Blume (Tailwind v4) already maps
* background/foreground/muted/border onto its --blume-* tokens; this adds the
* names shadcn components expect, pointed at the same tokens so the progress
* cards follow the site in both colour schemes. Blume inlines this file into
* its Tailwind entry and switches dark mode with [data-theme="dark"], not
* shadcn's .dark class.
*
* Deliberately NOT remapped: `accent`. In Blume it is the brand colour
* (bg-accent), in shadcn a subtle hover surface; the vendored components here
* never use it.
*/
:root {
/* Difficulty hues, LeetCode's: easy teal, medium amber, hard red. */
--chart-1: oklch(0.72 0.12 195);
--chart-2: oklch(0.8 0.16 80);
--chart-3: oklch(0.63 0.22 25);
/* Solved fill for the topic bubbles. */
--chart-4: oklch(0.72 0.17 150);
--chart-5: oklch(0.6 0.12 250);
/* Heatmap ramp — the GREENS of apps/api/src/charts.ts (README + email), with
level 0 on the theme's muted surface so an empty day reads in light mode. */
--heat-0: var(--blume-muted);
--heat-1: #9be9a8;
--heat-2: #40c463;
--heat-3: #30a14e;
--heat-4: #216e39;
}
:root[data-theme="dark"] {
--chart-1: oklch(0.75 0.12 195);
--chart-2: oklch(0.83 0.16 80);
--chart-3: oklch(0.68 0.21 25);
--chart-4: oklch(0.76 0.17 150);
--chart-5: oklch(0.7 0.12 250);
--heat-0: var(--blume-muted);
--heat-1: #0e4429;
--heat-2: #006d32;
--heat-3: #26a641;
--heat-4: #39d353;
}
/* shadcn components draw `border` with no colour and rely on a global
`* { border-color: var(--border) }` base rule. Blume has none (its own
components always name border-border), so give shadcn's parts — every one
carries data-slot — that default, scoped, without touching Blume's. */
@layer base {
[data-slot],
[data-slot] * {
border-color: var(--blume-border);
}
}
@theme inline {
--color-card: var(--blume-background);
--color-card-foreground: var(--blume-foreground);
--color-popover: var(--blume-background);
--color-popover-foreground: var(--blume-foreground);
--color-primary: var(--blume-foreground);
--color-primary-foreground: var(--blume-background);
--color-secondary: var(--blume-muted);
--color-secondary-foreground: var(--blume-foreground);
--color-input: var(--blume-border);
--color-ring: var(--blume-muted-foreground);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
}
-69
View File
@@ -1,69 +0,0 @@
/**
* LeetCode topic-tag slug → the campaign landing routes that teach it, in
* default preference order. The one answer to "does the campaign cover this
* tag", read by the sides that must agree:
*
* - `components/ProblemBadges.astro` links a covered tag's badge to its
* landing page, and every other tag's badge to its Appendix card,
* `/appendix#<slug>` (`appendix.ts`);
* - `components/BacklinksList.astro` lists, under a landing page, every
* problem whose badge links there — both via `tagTarget()`;
* - `apps/cli/sync.ts` warns about a tag a problem page carries that is
* neither a key here nor an Appendix term.
*
* Add a key when a campaign topic starts teaching a tag; its Appendix term is
* then dead weight, delete it. A tag naming two topics (DFS/BFS: tree and
* graph; DP: 1-D and grid) lists both; the badge picks per page.
*/
import { APPENDIX } from "./appendix.ts";
export const TOPIC_LANDINGS: Record<string, readonly string[]> = {
"hash-table": ["/hash-table"],
"prefix-sum": ["/prefix-sum"],
"two-pointers": ["/two-pointers"],
"sliding-window": ["/sliding-window"],
"binary-search": ["/binary-search"],
stack: ["/stack"],
"monotonic-stack": ["/monotonic-stack"],
"linked-list": ["/linked-list"],
matrix: ["/matrix-index-math"],
"depth-first-search": ["/tree-dfs", "/graph-dfs"],
"breadth-first-search": ["/tree-bfs", "/graph-bfs"],
"binary-search-tree": ["/binary-search-tree"],
"heap-priority-queue": ["/heap-priority-queue"],
trie: ["/trie"],
"topological-sort": ["/topological-sort"],
"union-find": ["/union-find"],
backtracking: ["/backtracking"],
"dynamic-programming": ["/1d-dynamic-programming", "/multi-d-grid-dp"],
greedy: ["/greedy"],
};
/** One of LeetCode's topic tags, as a problem page's `topics:` frontmatter carries it. */
export interface TopicTag {
name: string;
slug: string;
}
/**
* Where a problem page's badge for `tag` points: a root-relative landing
* route or Appendix anchor (the caller adds the base), or LeetCode's problem
* list when the site has neither, so a badge never links to a 404.
*
* A tag naming two landings links to the page's own topic — its route's first
* segment — when that is one of them; otherwise DFS/BFS prefer the tree topic
* for a problem LeetCode also tags `Tree` and the graph topic for the rest.
*/
export function tagTarget(
tag: TopicTag,
page: { route: string; tags: readonly TopicTag[] },
served: ReadonlySet<string>,
): { href: string; kind: "landing" | "appendix" | "external" } {
const landings = (TOPIC_LANDINGS[tag.slug] ?? []).filter((path) => served.has(path));
const own = `/${page.route.split("/")[1]}`;
const prefer = page.tags.some((t) => t.slug === "tree") ? "/tree-" : "/graph-";
const landing = landings.includes(own) ? own : (landings.find((p) => p.startsWith(prefer)) ?? landings[0]);
if (landing) return { href: landing, kind: "landing" };
if (APPENDIX[tag.slug]) return { href: `/appendix#${tag.slug}`, kind: "appendix" };
return { href: `https://leetcode.com/problem-list/${tag.slug}/`, kind: "external" };
}
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"paths": {
"@/components": ["./components"],
"@/components/*": ["./components/*"],
"@/lib": ["./lib"],
"@/lib/*": ["./lib/*"],
"@/hooks": ["./hooks"],
"@/hooks/*": ["./hooks/*"]
}
},
"include": ["islands", "components/ui", "lib", "topic-landings.ts", "redirects.ts"]
}
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff. Show more