mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-03 05:27:36 +00:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1c8f1a0f1 | ||
|
|
57a5917e09 | ||
|
|
f8e90d7f55 | ||
|
|
a39336f7ed | ||
|
|
6396f1fbec | ||
|
|
82fe05b225 | ||
|
|
d7fbe88eb8 | ||
|
|
856ab0667a | ||
|
|
2c574018fb | ||
|
|
1f6413f3d8 | ||
|
|
3b9079f87f | ||
|
|
d5496581df | ||
|
|
e931391200 | ||
|
|
790e256127 | ||
|
|
a8bd4fbcd7 | ||
|
|
a7737502e1 | ||
|
|
2ac54e9930 | ||
|
|
ef91c0d1fe | ||
|
|
910e83fa64 | ||
|
|
3c9feb33fe | ||
|
|
b76b199b17 | ||
|
|
cd9aef1229 | ||
|
|
34b944d32d | ||
|
|
b710c777c3 | ||
|
|
19dedc52ce | ||
|
|
d9dc90e433 | ||
|
|
9c2aaf5969 | ||
|
|
a30e593202 | ||
|
|
d194cedf7a | ||
|
|
5d2d1dff3c | ||
|
|
dde06daea3 | ||
|
|
6e9c407698 | ||
|
|
63f0a81af3 | ||
|
|
5ed33057ed | ||
|
|
f4fc0fd0e7 | ||
|
|
f2a2d2d7de | ||
|
|
fa74adeda7 | ||
|
|
e11c9723f8 | ||
|
|
d2c645bef8 | ||
|
|
2f94b02e15 | ||
|
|
6f1cb93395 | ||
|
|
b310f410a2 | ||
|
|
436ff3080e | ||
|
|
d3f7b1c6f9 | ||
|
|
8a331f4b54 | ||
|
|
bc6eb3e66b | ||
|
|
dd929cca6c | ||
|
|
5987fdce7d | ||
|
|
6879afa61c | ||
|
|
40e965aa06 | ||
|
|
eea8d242e0 | ||
|
|
50b42108eb | ||
|
|
6f375e6c14 | ||
|
|
667bc06965 | ||
|
|
e63d517c9a | ||
|
|
4c297b4524 | ||
|
|
32dadf5928 |
No files matched your search
@@ -1,36 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Pre-commit: the docs pages under apps/docs/content (and the `/<num>` short
|
|
||||||
# links in apps/docs/redirects.ts) are generated from work/1 by `bun run sync`,
|
|
||||||
# and deploy.yml publishes whatever main holds. Regenerate them now and stage
|
|
||||||
# what sync changed, so a solution, its page and its redirect land in the same
|
|
||||||
# commit and a stale `## Solution` never reaches main.
|
|
||||||
#
|
|
||||||
# Sync reads the working tree, not the index, so the pages it writes only
|
|
||||||
# match this commit when work/1 has nothing unstaged: an untracked or
|
|
||||||
# half-staged solution would ship a page whose source is not in the commit.
|
|
||||||
# In that case refuse and say what to stage.
|
|
||||||
#
|
|
||||||
# Only paths that became dirty during sync are staged. Anything already dirty
|
|
||||||
# beforehand (hand-written `## Approach` prose in progress, a new index.mdx)
|
|
||||||
# stays where the author left it.
|
|
||||||
#
|
|
||||||
# Bypass for a deliberate partial commit: git commit --no-verify
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
cd "$(git rev-parse --show-toplevel)"
|
|
||||||
|
|
||||||
unstaged=$(git status --porcelain -- work/1 | grep -v '^[MADR] ' || true)
|
|
||||||
if [ -n "$unstaged" ]; then
|
|
||||||
printf 'pre-commit: work/1 has unstaged changes; stage or stash them so the generated pages match this commit:\n%s\n' "$unstaged" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
before=$(mktemp)
|
|
||||||
trap 'rm -f "$before"' EXIT
|
|
||||||
git status --porcelain -- apps/docs/content apps/docs/redirects.ts | sort > "$before"
|
|
||||||
report=$(bun run --silent sync)
|
|
||||||
generated=$(git status --porcelain -- apps/docs/content apps/docs/redirects.ts | sort | comm -13 "$before" - | cut -c4-)
|
|
||||||
if [ -n "$generated" ]; then
|
|
||||||
printf '%s\n' "$report" | grep -v 'skipped (in sync)' >&2
|
|
||||||
printf '%s\n' "$generated" | xargs git add --
|
|
||||||
fi
|
|
||||||
@@ -54,8 +54,9 @@ jobs:
|
|||||||
|
|
||||||
# Deploys on every main push (not path-filtered): apps/api/data/schedule.json
|
# Deploys on every main push (not path-filtered): apps/api/data/schedule.json
|
||||||
# is bundled at deploy time, and re-deploying an unchanged Worker is a no-op.
|
# is bundled at deploy time, and re-deploying an unchanged Worker is a no-op.
|
||||||
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit) and
|
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit),
|
||||||
# CLOUDFLARE_ACCOUNT_ID.
|
# CLOUDFLARE_ACCOUNT_ID, and OPENROUTER_API_KEY (the docs assistant's
|
||||||
|
# POST /ask; the Actions secret is the one copy, mirrored into the Worker).
|
||||||
deploy-api:
|
deploy-api:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -68,6 +69,13 @@ jobs:
|
|||||||
# DST cron guards must hold before the Worker ships.
|
# DST cron guards must hold before the Worker ships.
|
||||||
- run: bun run api:test
|
- run: bun run api:test
|
||||||
|
|
||||||
|
- run: printf %s "$OPENROUTER_API_KEY" | bunx wrangler secret put OPENROUTER_API_KEY
|
||||||
|
working-directory: apps/api
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||||
|
|
||||||
- run: bun run api:deploy
|
- run: bun run api:deploy
|
||||||
env:
|
env:
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
node_modules
|
node_modules
|
||||||
|
.openlogs
|
||||||
tmp/
|
tmp/
|
||||||
*.log
|
*.log
|
||||||
.crush
|
.crush
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ 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/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/<num>-<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync: 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 (`/tree-dfs/invert-binary-tree`), the `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order with each topic a collapsible group. A problem page's badge row (above the title) is rendered, not written: the `PageHeader` slot (`components/ProblemBadges.astro`, wired in `components.ts`) 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 (accent), any other tag to its guide page `/guides/<slug>` (neutral; LeetCode's problem list if the guide is gone), and the video as a red `Explanation` badge. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), read by both that component and sync. `content/guides/<slug>.mdx` (the `Guides` header tab) is one page per tag outside that map: sync creates a placeholder when the file is missing or empty and never touches it again (hand-owned; a guide whose tag no page carries any more is left alone), with frontmatter `list:` — the tag's LeetCode problem list, rendered by the same slot as an orange `LeetCode list` badge; sync also writes `guides/meta.ts`. 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; `ProgressDashboard`, `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 a custom element for behaviour, the way Blume's own `Frame` is built (`Carousel`/`CarouselImage`: `src` takes a URL, `/file.png`, or the page-relative `../../public/file.png`) |
|
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/<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 (`/tree-dfs/invert-binary-tree`), the `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order with each topic a collapsible group. 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 guide page `/guides/<slug>` (outlined; LeetCode's problem list if the guide is gone, 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 in the sidebar (read off `data.navigation`: the topic `meta.ts` icon, the guide's `icon:`) and the tag's own colour from `lib/tag-colors.ts` — unique per tag site-wide, assigned greedily over every tag with a page 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 and guide 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 (so badges and backlinks cannot disagree) and the map by sync. `content/guides/` (the `Guides` header tab) is one page per tag outside that map, filed by hand into `(group)` categories — `(1-data-structures)/`, `(2-algorithms)/`, `(3-techniques)/` (hand-written `meta.ts` each; no route segment, so every guide stays `/guides/<slug>`). Sync finds a guide by slug anywhere under `guides/` and creates a placeholder only when none exists or it is empty — at the `guides/` root, for you to file into a category and give a Lucide `icon:` — the one every badge linking to it shows — that no topic and no other guide uses (sync warns while it is missing, a `TOPIC_ICONS` value, or another guide's) — and after that owns exactly one key of it: `sidebar.badge`, the sidebar count, rewritten every run to the guide's backlink count (problem pages carrying the tag — equal to the `BacklinksList` rows, since a guide's tag has no landing; dropped at zero). Everything else is hand-owned (a guide whose tag no page carries any more is left alone, badge dropped), with frontmatter `list:` — the tag's LeetCode problem list, rendered by the same wrapper as an orange `LeetCode list` badge; sync also writes `guides/meta.ts`. 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 a custom element for behaviour, the way Blume's own `Frame` is built (`Carousel`/`CarouselImage`: `src` takes a URL, `/file.png`, or the page-relative `../../public/file.png`) |
|
||||||
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | 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 | 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, 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 (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) |
|
||||||
| `.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/` | 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 |
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
@@ -107,20 +107,21 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
|||||||
## Important Files
|
## Important Files
|
||||||
|
|
||||||
- `apps/api/src/srs.ts` — SRS domain: the ladder (`new → +3 → +7 → retired`; fail resets to `+3`; first-ever log enters at `+3`), `WINDOWS`/`STAGES`/`TEMPERATURES`/`DIFFICULTIES`/`SETS` (the one vocabulary shared by the charts, the `work/<n>` buckets, the review issues and the stats dashboard), ET date math, `workingDay()` (no scheduled date is ever a Sunday), `temperatureOf()` (days since a concept was last exercised → `hot`/`fresh`/`fading`/`cold`, bands derived from `WINDOWS` so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
|
- `apps/api/src/srs.ts` — SRS domain: the ladder (`new → +3 → +7 → retired`; fail resets to `+3`; first-ever log enters at `+3`), `WINDOWS`/`STAGES`/`TEMPERATURES`/`DIFFICULTIES`/`SETS` (the one vocabulary shared by the charts, the `work/<n>` buckets, the review issues and the stats dashboard), ET date math, `workingDay()` (no scheduled date is ever a Sunday), `temperatureOf()` (days since a concept was last exercised → `hot`/`fresh`/`fading`/`cold`, bands derived from `WINDOWS` so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
|
||||||
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`.
|
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`, and `OPENROUTER_API_KEY`, whose one copy is the repo's Actions secret (deploy.yml puts it before every deploy — rotate it there).
|
||||||
|
- `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/56) LeetCode Daily Digest`.
|
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/56) LeetCode Daily Digest`.
|
||||||
- `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) is filled once — on create, or on any run that finds it missing — never refreshed (delete the key to refetch; offline, the page is written without it and the next sync retries). `## 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>/`, both `meta.ts` script-owned and rewritten every run (phase: `Phase N: Name`, `order: N`; topic: label, curriculum order, `TOPIC_ICONS` icon, `display: "group"`). 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 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) is filled once — on create, or on any run that finds it missing — never refreshed (delete the key to refetch; offline, the page is written without it and the next sync retries). `## 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>/`. The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "group"`); 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 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/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` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, per-topic temperature, gates, the 14-day due queue, and `heat` (every campaign day, zeros and future days included — the docs page windows the tail off it rather than asking a second question). Two readers consume it, the docs island and `bun run stats`, so its shape is both their contract: change all three together. `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` / `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.
|
||||||
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the docs `/progress` page fetches `/api/stats` client-side (`apps/docs/islands/ProgressDashboard.tsx`).
|
- `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 — Blume's Vite has no `@/` alias; `components.json` + `tsconfig.json` exist for the shadcn CLI and `tsc`, so after `shadcn add` rewrite `@/…` imports to relative ones — and check `package.json`: the CLI has twice installed an unrelated `cn` package for `lib/utils` and skipped real deps (`class-variance-authority`). Blume's `.prose :where(a, p, ol)` rules ignore `not-prose`, so island markup overrides them with `!` utilities, as Blume's own components do.
|
||||||
- `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`).
|
- `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`. Node 22 appears only in `deploy.yml` because Blume requires it.
|
||||||
- **Near-zero runtime npm dependencies** — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. The **one** exception is the digest email: `apps/api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. Do not extend this exception to any other module.
|
- **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.
|
||||||
- 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
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
GH_PAT=github_pat_or_gho_token_with_issues_and_projects_rw
|
GH_PAT=github_pat_or_gho_token_with_issues_and_projects_rw
|
||||||
WEBHOOK_SECRET=random_hex_matching_the_repo_webhook
|
WEBHOOK_SECRET=random_hex_matching_the_repo_webhook
|
||||||
LINK_KEY=random_hex_signing_one_tap_links
|
LINK_KEY=random_hex_signing_one_tap_links
|
||||||
|
OPENROUTER_API_KEY=sk-or-v1_key_for_the_docs_assistant
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* POST /ask — the docs assistant's backend.
|
||||||
|
*
|
||||||
|
* The docs are a static GitHub Pages site, and Blume's own assistant route is
|
||||||
|
* server-rendered (it reads the provider key per request), so the site points
|
||||||
|
* `ai.assistant.endpoint` here instead. This speaks the Blume island's wire
|
||||||
|
* contract (`blume/components/islands/hooks.ts`):
|
||||||
|
*
|
||||||
|
* - request: `{ messages: [{ role, content }], page: { path } }` as JSON,
|
||||||
|
* `path` the base-stripped route the reader is on;
|
||||||
|
* - response: the answer as a plain UTF-8 text stream; any non-2xx shows the
|
||||||
|
* island's error notice, and a 503 whose body starts "The assistant is not
|
||||||
|
* configured" shows that body verbatim.
|
||||||
|
*
|
||||||
|
* Validation matches Blume's generated route: 1–40 user/assistant messages of
|
||||||
|
* string content, ≤ 24 000 chars serialized, a 64 KB body, and only role and
|
||||||
|
* content ever reach the model, so the endpoint cannot be repurposed as an
|
||||||
|
* open LLM proxy with a caller's own system prompt.
|
||||||
|
*
|
||||||
|
* Grounding reads what the site already publishes: `llms-full.txt`, every
|
||||||
|
* page's Markdown under `# Title` / `Source: <url>`. The page the reader is on
|
||||||
|
* goes in first, then the best keyword matches, within Blume's default budget
|
||||||
|
* (6 pages, 2 000 chars each, 10 000 total), under Blume's grounded system
|
||||||
|
* prompt — answer only from the excerpts, cite pages as `[Title](/route)`.
|
||||||
|
*
|
||||||
|
* The model call is OpenRouter's OpenAI-compatible chat endpoint over plain
|
||||||
|
* `fetch` (no SDK — see the repo's near-zero-dependency rule), streamed, with
|
||||||
|
* `reasoning.effort: "none"`, for the model the `ASK_MODEL` var names (a
|
||||||
|
* wrangler.jsonc change swaps it); its SSE deltas are re-emitted as bare text. The
|
||||||
|
* reader closing the panel aborts the request, which aborts the model call.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BODY_LIMIT = 64 * 1024;
|
||||||
|
const MAX_MESSAGES = 40;
|
||||||
|
const MAX_CHARS = 24_000;
|
||||||
|
|
||||||
|
const MAX_RESULTS = 6;
|
||||||
|
const EXCERPT_CHARS = 2000;
|
||||||
|
const CONTEXT_BUDGET = 10_000;
|
||||||
|
/** Lead-in kept before the matched region of a long page. */
|
||||||
|
const EXCERPT_LEAD = 160;
|
||||||
|
/** How long one isolate reuses the parsed corpus (the edge caches the fetch as long). */
|
||||||
|
const CORPUS_TTL_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
"You are a helpful documentation assistant for this project, a LeetCode interview-prep campaign: solved problems grouped by topic, with guides to the techniques behind them. Answer the user's question using ONLY the documentation excerpts below. Each excerpt is headed by its page as `## Page Title (/route)`. If the answer is not covered by the excerpts, say you don't know and suggest where in the docs to look — do not invent details. Always cite the pages you drew from, and write every citation as a Markdown link to that page using its route, e.g. [Page Title](/route).";
|
||||||
|
|
||||||
|
/** Words that match everything and rank nothing. */
|
||||||
|
const STOPWORDS = new Set(
|
||||||
|
"a an and are as at be but by can do does for from how i if in is it its me my of on or so that the this to was what when where which who why with you your".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface AskMessage {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Section {
|
||||||
|
title: string;
|
||||||
|
route: string;
|
||||||
|
body: string;
|
||||||
|
/** Lower-cased title + body, searched per query term. */
|
||||||
|
text: string;
|
||||||
|
titleText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── corpus ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let corpus: { at: number; sections: Section[] } | undefined;
|
||||||
|
|
||||||
|
/** `llms-full.txt` split into pages, cached per isolate for `CORPUS_TTL_MS`. */
|
||||||
|
async function sections(docsUrl: string): Promise<Section[]> {
|
||||||
|
if (corpus && Date.now() - corpus.at < CORPUS_TTL_MS) return corpus.sections;
|
||||||
|
const res = await fetch(`${docsUrl}/llms-full.txt`, {
|
||||||
|
cf: { cacheTtl: CORPUS_TTL_MS / 1000, cacheEverything: true },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`GET llms-full.txt -> ${res.status}`);
|
||||||
|
const full = await res.text();
|
||||||
|
const heads = [...full.matchAll(/^# (.+)\nSource: (\S+)$/gm)];
|
||||||
|
const out = heads.map((head, i): Section => {
|
||||||
|
const start = head.index + head[0].length;
|
||||||
|
const end = heads[i + 1]?.index ?? full.length;
|
||||||
|
const body = full.slice(start, end).replace(/\n-{3,}\s*$/, "").trim();
|
||||||
|
const title = head[1]!.trim();
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
route: new URL(head[2]!).pathname.replace(/(.)\/+$/, "$1"),
|
||||||
|
body,
|
||||||
|
text: `${title}\n${body}`.toLowerCase(),
|
||||||
|
titleText: title.toLowerCase(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
corpus = { at: Date.now(), sections: out };
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── retrieval ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function terms(text: string): string[] {
|
||||||
|
return [...new Set(text.toLowerCase().match(/[a-z0-9][a-z0-9+#-]*/g) ?? [])].filter(
|
||||||
|
(t) => !STOPWORDS.has(t) && (t.length > 1 || /\d/.test(t)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function occurrences(haystack: string, needle: string): number {
|
||||||
|
let count = 0;
|
||||||
|
for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + needle.length)) count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pages to inject, best first: term frequency damped by log and weighted
|
||||||
|
* by rarity across pages (idf), a title hit counting triple — so "217" or
|
||||||
|
* "sliding window" lands on the page named that rather than every page that
|
||||||
|
* mentions it in passing.
|
||||||
|
*/
|
||||||
|
function rank(all: Section[], query: string[]): Section[] {
|
||||||
|
const idf = new Map(
|
||||||
|
query.map((t) => [t, Math.log(1 + all.length / (1 + all.filter((s) => s.text.includes(t)).length))]),
|
||||||
|
);
|
||||||
|
return all
|
||||||
|
.map((section) => {
|
||||||
|
let score = 0;
|
||||||
|
for (const t of query) {
|
||||||
|
const tf = occurrences(section.text, t);
|
||||||
|
if (tf) score += idf.get(t)! * (Math.log(1 + tf) + (section.titleText.includes(t) ? 3 : 0));
|
||||||
|
}
|
||||||
|
return { section, score };
|
||||||
|
})
|
||||||
|
.filter((r) => r.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, MAX_RESULTS)
|
||||||
|
.map((r) => r.section);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A page whole when it fits, else `chars` from just before its first query hit. */
|
||||||
|
function excerpt(section: Section, query: string[], chars: number): string {
|
||||||
|
if (section.body.length <= chars) return section.body;
|
||||||
|
const lower = section.body.toLowerCase();
|
||||||
|
const hits = query.map((t) => lower.indexOf(t)).filter((i) => i !== -1);
|
||||||
|
const start = hits.length ? Math.max(0, Math.min(...hits) - EXCERPT_LEAD) : 0;
|
||||||
|
return `${start > 0 ? "…" : ""}${section.body.slice(start, start + chars)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The system prompt: the current page, then the ranked pages, within the budget. */
|
||||||
|
async function instructions(env: Env, messages: AskMessage[], path: string | undefined): Promise<string> {
|
||||||
|
const all = await sections(env.DOCS_URL);
|
||||||
|
// A follow-up ("and in Python?") leans on the question before it.
|
||||||
|
const asked = messages.filter((m) => m.role === "user").slice(-2).map((m) => m.content);
|
||||||
|
const query = terms(asked.join(" "));
|
||||||
|
const current = path ? all.find((s) => s.route === path) : undefined;
|
||||||
|
const picked = [...(current ? [current] : []), ...rank(all, query).filter((s) => s !== current)];
|
||||||
|
const parts: string[] = [];
|
||||||
|
let budget = CONTEXT_BUDGET;
|
||||||
|
for (const section of picked) {
|
||||||
|
if (budget < 200) break;
|
||||||
|
const text = excerpt(section, query, Math.min(EXCERPT_CHARS, budget));
|
||||||
|
parts.push(`## ${section.title} (${section.route})\n\n${text}`);
|
||||||
|
budget -= text.length;
|
||||||
|
}
|
||||||
|
return parts.length ? `${SYSTEM_PROMPT}\n\n# Documentation excerpts\n\n${parts.join("\n\n")}` : SYSTEM_PROMPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── request handling ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** The validated conversation, or a reason it was turned away. */
|
||||||
|
function readMessages(body: unknown): AskMessage[] | undefined {
|
||||||
|
if (!body || typeof body !== "object" || !("messages" in body) || !Array.isArray(body.messages)) return;
|
||||||
|
const raw: unknown[] = body.messages;
|
||||||
|
if (raw.length === 0 || raw.length > MAX_MESSAGES || JSON.stringify(raw).length > MAX_CHARS) return;
|
||||||
|
const messages: AskMessage[] = [];
|
||||||
|
for (const m of raw) {
|
||||||
|
if (!m || typeof m !== "object" || !("role" in m) || !("content" in m)) return;
|
||||||
|
if ((m.role !== "user" && m.role !== "assistant") || typeof m.content !== "string") return;
|
||||||
|
messages.push({ role: m.role, content: m.content });
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPath(body: unknown): string | undefined {
|
||||||
|
if (!body || typeof body !== "object" || !("page" in body)) return;
|
||||||
|
const page = body.page;
|
||||||
|
if (!page || typeof page !== "object" || !("path" in page) || typeof page.path !== "string") return;
|
||||||
|
return page.path.replace(/(.)\/+$/, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OpenRouter's SSE (`data: {…choices[0].delta.content…}`) → the answer's bare text. */
|
||||||
|
function textStream(sse: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let buffer = "";
|
||||||
|
return sse.pipeThrough(
|
||||||
|
new TransformStream<Uint8Array, Uint8Array>({
|
||||||
|
transform(chunk, controller) {
|
||||||
|
buffer += decoder.decode(chunk, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith("data: ")) continue; // `: OPENROUTER PROCESSING` keep-alives
|
||||||
|
const data = line.slice(6).trim();
|
||||||
|
if (data === "[DONE]") continue;
|
||||||
|
let event: unknown;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event && typeof event === "object" && "error" in event) {
|
||||||
|
// After the 200: the stream just ends, and the island shows its
|
||||||
|
// error notice for an empty answer.
|
||||||
|
console.error(`ask: provider error mid-stream: ${JSON.stringify(event.error)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!event || typeof event !== "object" || !("choices" in event) || !Array.isArray(event.choices)) continue;
|
||||||
|
const choice: unknown = event.choices[0];
|
||||||
|
if (!choice || typeof choice !== "object" || !("delta" in choice)) continue;
|
||||||
|
const delta = choice.delta;
|
||||||
|
if (!delta || typeof delta !== "object" || !("content" in delta)) continue;
|
||||||
|
const content = delta.content;
|
||||||
|
if (typeof content === "string" && content) controller.enqueue(encoder.encode(content));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAsk(request: Request, env: Env): Promise<Response> {
|
||||||
|
// The docs serve from the custom domain and the Pages origin; reflect the
|
||||||
|
// requesting origin only when it is on the allowlist (as /api/stats does).
|
||||||
|
const allowed = env.DOCS_ORIGIN.split(",").map((o) => o.trim());
|
||||||
|
const origin = request.headers.get("origin");
|
||||||
|
const cors = {
|
||||||
|
"access-control-allow-origin": origin && allowed.includes(origin) ? origin : allowed[0]!,
|
||||||
|
"access-control-allow-methods": "POST",
|
||||||
|
"access-control-allow-headers": "content-type",
|
||||||
|
"access-control-max-age": "86400",
|
||||||
|
vary: "Origin",
|
||||||
|
};
|
||||||
|
const text = (body: string, status: number) =>
|
||||||
|
new Response(body, { status, headers: { ...cors, "content-type": "text/plain; charset=utf-8" } });
|
||||||
|
|
||||||
|
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
|
||||||
|
if (request.method !== "POST") return text("POST only.", 405);
|
||||||
|
|
||||||
|
const raw = await request.arrayBuffer();
|
||||||
|
if (raw.byteLength > BODY_LIMIT) return text("Request too large: the body must be at most 64 KB.", 413);
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(new TextDecoder().decode(raw));
|
||||||
|
} catch {
|
||||||
|
body = undefined;
|
||||||
|
}
|
||||||
|
const messages = readMessages(body);
|
||||||
|
if (!messages) return text("Invalid request: send 1-40 user/assistant messages with string content.", 400);
|
||||||
|
if (!env.OPENROUTER_API_KEY) return text("The assistant is not configured: set OPENROUTER_API_KEY.", 503);
|
||||||
|
|
||||||
|
let system: string;
|
||||||
|
try {
|
||||||
|
system = await instructions(env, messages, readPath(body));
|
||||||
|
} catch (err) {
|
||||||
|
// Ungrounded beats unanswered: the prompt still forbids inventing details.
|
||||||
|
console.warn(`ask: grounding unavailable: ${err}`);
|
||||||
|
system = SYSTEM_PROMPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${env.OPENROUTER_API_KEY}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: env.ASK_MODEL,
|
||||||
|
stream: true,
|
||||||
|
reasoning: { effort: "none" },
|
||||||
|
messages: [{ role: "system", content: system }, ...messages],
|
||||||
|
}),
|
||||||
|
signal: request.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
console.error(`ask: POST /chat/completions -> ${res.status} ${await res.text().catch(() => "")}`);
|
||||||
|
return text("Failed to generate a response.", res.status === 429 ? 429 : 502);
|
||||||
|
}
|
||||||
|
return new Response(textStream(res.body), {
|
||||||
|
headers: { ...cors, "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
CAMPAIGN_DAYS,
|
CAMPAIGN_DAYS,
|
||||||
|
MISS_WHERE,
|
||||||
type ProblemRow,
|
type ProblemRow,
|
||||||
RESERVED,
|
RESERVED,
|
||||||
REVIEW_CAP,
|
REVIEW_CAP,
|
||||||
@@ -84,8 +85,7 @@ async function openMisses(db: D1Database): Promise<ProblemRow[]> {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT p.* FROM problems p
|
`SELECT p.* FROM problems p
|
||||||
JOIN attempts a ON a.lc_number = p.lc_number
|
JOIN attempts a ON a.lc_number = p.lc_number
|
||||||
WHERE a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number)
|
WHERE ${MISS_WHERE}
|
||||||
AND a.result = 'fail' AND p.stage != 'retired'
|
|
||||||
ORDER BY a.date, p.lc_number`,
|
ORDER BY a.date, p.lc_number`,
|
||||||
)
|
)
|
||||||
.all<ProblemRow>();
|
.all<ProblemRow>();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* Writes are owner-only (webhook author check, HMAC one-tap links, bearer
|
* Writes are owner-only (webhook author check, HMAC one-tap links, bearer
|
||||||
* admin routes); everything else is read-only public.
|
* admin routes); everything else is read-only public.
|
||||||
*/
|
*/
|
||||||
|
import { handleAsk } from "./ask.ts";
|
||||||
import { reconcileCatalog } from "./catalog.ts";
|
import { reconcileCatalog } from "./catalog.ts";
|
||||||
import { gateBadge, heatmapChart, heatmapPng, ladderChart, progressChart } from "./charts.ts";
|
import { gateBadge, heatmapChart, heatmapPng, ladderChart, progressChart } from "./charts.ts";
|
||||||
import { sendDigest } from "./digest.ts";
|
import { sendDigest } from "./digest.ts";
|
||||||
@@ -322,6 +323,8 @@ export default {
|
|||||||
if (path.startsWith("/admin/") && request.method === "POST") {
|
if (path.startsWith("/admin/") && request.method === "POST") {
|
||||||
return await handleAdmin(request, env, path);
|
return await handleAdmin(request, env, path);
|
||||||
}
|
}
|
||||||
|
// The docs assistant (the static Pages site's `ai.assistant.endpoint`).
|
||||||
|
if (path === "/ask") return await handleAsk(request, env);
|
||||||
|
|
||||||
if (path === "/chart/progress.svg") {
|
if (path === "/chart/progress.svg") {
|
||||||
return new Response(await progressChart(env.DB), { headers: SVG_HEADERS });
|
return new Response(await progressChart(env.DB), { headers: SVG_HEADERS });
|
||||||
|
|||||||
@@ -310,6 +310,15 @@ export async function lookupProblems(
|
|||||||
export const DUE_WHERE = `stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
export const DUE_WHERE = `stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||||
AND (defer_until IS NULL OR defer_until <= ?1)`;
|
AND (defer_until IS NULL OR defer_until <= ?1)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an open miss means, in one place: a problem `p` whose most recent
|
||||||
|
* attempt (highest id) failed and that has not retired. The sweep day works
|
||||||
|
* these and the stats' "attempting" count is how many there are, so the two
|
||||||
|
* cannot disagree. Expects `problems p` joined to `attempts a` on lc_number.
|
||||||
|
*/
|
||||||
|
export const MISS_WHERE = `a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number)
|
||||||
|
AND a.result = 'fail' AND p.stage != 'retired'`;
|
||||||
|
|
||||||
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
||||||
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
|
|||||||
+90
-3
@@ -8,12 +8,17 @@
|
|||||||
* srs.ts turns a topic's days-since-last-attempt into a band, so "which
|
* srs.ts turns a topic's days-since-last-attempt into a band, so "which
|
||||||
* concepts have gone cold" has exactly one definition and the dashboard is
|
* concepts have gone cold" has exactly one definition and the dashboard is
|
||||||
* free to colour it however it likes.
|
* free to colour it however it likes.
|
||||||
|
*
|
||||||
|
* `recent`, `attempting` and `year` feed the docs page's LeetCode-style cards
|
||||||
|
* (recently solved, the solved ring, the heatmap and the submissions chart).
|
||||||
|
* They are additive: the terminal dashboard ignores them.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
CAMPAIGN_DAYS,
|
CAMPAIGN_DAYS,
|
||||||
CAMPAIGN_START,
|
CAMPAIGN_START,
|
||||||
DIFFICULTIES,
|
DIFFICULTIES,
|
||||||
DUE_WHERE,
|
DUE_WHERE,
|
||||||
|
MISS_WHERE,
|
||||||
addDays,
|
addDays,
|
||||||
campaignDay,
|
campaignDay,
|
||||||
campaignWeek,
|
campaignWeek,
|
||||||
@@ -54,6 +59,12 @@ interface TopicStat {
|
|||||||
temperature: Temperature;
|
temperature: Temperature;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Days in `year`: the 365 ending today, which always covers the current calendar year to date. */
|
||||||
|
const YEAR_DAYS = 365;
|
||||||
|
|
||||||
|
/** How many problems `recent` lists. */
|
||||||
|
const RECENT_LIMIT = 10;
|
||||||
|
|
||||||
async function buildTopics(db: D1Database, today: string): Promise<TopicStat[]> {
|
async function buildTopics(db: D1Database, today: string): Promise<TopicStat[]> {
|
||||||
// Three shapes, three statements: batch() carries one row type, and casting
|
// Three shapes, three statements: batch() carries one row type, and casting
|
||||||
// its results apart would cost more than the round trips it saves.
|
// its results apart would cost more than the round trips it saves.
|
||||||
@@ -195,9 +206,9 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
|||||||
);
|
);
|
||||||
const queue = dates.map((date, i) => ({ date, due: queueRows[i]?.results[0]?.n ?? 0 }));
|
const queue = dates.map((date, i) => ({ date, due: queueRows[i]?.results[0]?.n ?? 0 }));
|
||||||
|
|
||||||
// Attempts per campaign day, every day present. This is the heatmap's data
|
// Attempts per campaign day, every day present. This is the README heatmap's
|
||||||
// model — the docs page reads the tail of it for "last 7 days" rather than
|
// data model — the terminal dashboard lays it out by campaign week and reads
|
||||||
// asking a second question that could answer differently.
|
// its tail for "last 14 days" rather than asking a second question.
|
||||||
const end = addDays(CAMPAIGN_START, CAMPAIGN_DAYS - 1);
|
const end = addDays(CAMPAIGN_START, CAMPAIGN_DAYS - 1);
|
||||||
const { results: heatRows } = await db
|
const { results: heatRows } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -215,16 +226,92 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
|||||||
return { date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 };
|
return { date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The ten problems most recently solved, dated by each one's FIRST passing
|
||||||
|
// attempt — when it was completed, not when it was last re-solved (LeetCode's
|
||||||
|
// "Recent AC"). Same-day ties fall back to log order.
|
||||||
|
const { results: recentRows } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT p.lc_number AS lc, p.title AS title, p.difficulty AS difficulty,
|
||||||
|
MIN(a.date) AS date, MIN(a.id) AS seq
|
||||||
|
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
|
||||||
|
WHERE a.result = 'pass'
|
||||||
|
GROUP BY p.lc_number
|
||||||
|
ORDER BY date DESC, seq DESC
|
||||||
|
LIMIT ?1`,
|
||||||
|
)
|
||||||
|
.bind(RECENT_LIMIT)
|
||||||
|
.all<{ lc: number; title: string; difficulty: string; date: string; seq: number }>();
|
||||||
|
const recent = recentRows.map(({ lc, title, difficulty, date }) => ({ lc, title, difficulty, date }));
|
||||||
|
|
||||||
|
// Problems still standing on a failure — the sweep day's miss log, counted.
|
||||||
|
const missRow = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) AS n FROM problems p
|
||||||
|
JOIN attempts a ON a.lc_number = p.lc_number
|
||||||
|
WHERE ${MISS_WHERE}`,
|
||||||
|
)
|
||||||
|
.first<{ n: number }>();
|
||||||
|
|
||||||
|
// The past year, one entry per day (zeros included), split by difficulty two
|
||||||
|
// ways: every logged attempt, and every first solve — the same first-pass
|
||||||
|
// date `recent` uses. `heat` stays campaign-scoped (the terminal dashboard
|
||||||
|
// lays it out by campaign week); this is the calendar-scoped view the docs
|
||||||
|
// heatmap and submissions chart window and bucket for themselves.
|
||||||
|
const yearStart = addDays(today, -(YEAR_DAYS - 1));
|
||||||
|
const { results: attemptRows } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.date AS date, p.difficulty AS difficulty,
|
||||||
|
COUNT(*) AS n,
|
||||||
|
SUM(CASE WHEN a.result = 'pass' THEN 1 ELSE 0 END) AS passes
|
||||||
|
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
|
||||||
|
WHERE a.date BETWEEN ?1 AND ?2
|
||||||
|
GROUP BY a.date, p.difficulty`,
|
||||||
|
)
|
||||||
|
.bind(yearStart, today)
|
||||||
|
.all<{ date: string; difficulty: string; n: number; passes: number | null }>();
|
||||||
|
const { results: solvedRows } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT date, difficulty, COUNT(*) AS n
|
||||||
|
FROM (SELECT MIN(a.date) AS date, p.difficulty AS difficulty
|
||||||
|
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
|
||||||
|
WHERE a.result = 'pass'
|
||||||
|
GROUP BY p.lc_number)
|
||||||
|
WHERE date BETWEEN ?1 AND ?2
|
||||||
|
GROUP BY date, difficulty`,
|
||||||
|
)
|
||||||
|
.bind(yearStart, today)
|
||||||
|
.all<{ date: string; difficulty: string; n: number }>();
|
||||||
|
const year = Array.from({ length: YEAR_DAYS }, (_, i) => ({
|
||||||
|
date: addDays(yearStart, i),
|
||||||
|
passes: 0,
|
||||||
|
attempts: Object.fromEntries(DIFFICULTIES.map((d) => [d, 0])),
|
||||||
|
solved: Object.fromEntries(DIFFICULTIES.map((d) => [d, 0])),
|
||||||
|
}));
|
||||||
|
const yearIndex = new Map(year.map((d, i) => [d.date, i]));
|
||||||
|
for (const row of attemptRows) {
|
||||||
|
const day = year[yearIndex.get(row.date) ?? -1];
|
||||||
|
if (!day || !(row.difficulty in day.attempts)) continue;
|
||||||
|
day.attempts[row.difficulty] = row.n;
|
||||||
|
day.passes += row.passes ?? 0;
|
||||||
|
}
|
||||||
|
for (const row of solvedRows) {
|
||||||
|
const day = year[yearIndex.get(row.date) ?? -1];
|
||||||
|
if (day && row.difficulty in day.solved) day.solved[row.difficulty] = row.n;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generated: new Date().toISOString(),
|
generated: new Date().toISOString(),
|
||||||
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
|
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
|
||||||
phases: [...phases.values()],
|
phases: [...phases.values()],
|
||||||
ladder,
|
ladder,
|
||||||
difficulty,
|
difficulty,
|
||||||
|
attempting: missRow?.n ?? 0,
|
||||||
topics: await buildTopics(db, today),
|
topics: await buildTopics(db, today),
|
||||||
gates,
|
gates,
|
||||||
streak: await streak(db, today),
|
streak: await streak(db, today),
|
||||||
queue,
|
queue,
|
||||||
heat,
|
heat,
|
||||||
|
recent,
|
||||||
|
year,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -34,8 +34,13 @@
|
|||||||
"TO_EMAIL": "prnk28@gmail.com",
|
"TO_EMAIL": "prnk28@gmail.com",
|
||||||
"DOCS_URL": "https://lc.prd.lk",
|
"DOCS_URL": "https://lc.prd.lk",
|
||||||
"DOCS_ORIGIN": "https://lc.prd.lk,https://prdlk.github.io",
|
"DOCS_ORIGIN": "https://lc.prd.lk,https://prdlk.github.io",
|
||||||
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev"
|
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev",
|
||||||
|
// The docs assistant's OpenRouter model (POST /ask). `:free` variants share
|
||||||
|
// an upstream pool and answer 429 when it is saturated.
|
||||||
|
"ASK_MODEL": "qwen/qwen3.8-27b:free"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secrets (wrangler secret put): GH_PAT, WEBHOOK_SECRET, LINK_KEY.
|
// Secrets (wrangler secret put): GH_PAT, WEBHOOK_SECRET, LINK_KEY.
|
||||||
|
// OPENROUTER_API_KEY (the docs assistant, POST /ask) is the repo's Actions
|
||||||
|
// secret of that name, put by deploy.yml before every deploy.
|
||||||
}
|
}
|
||||||
+103
-22
@@ -15,14 +15,21 @@
|
|||||||
* `topicTags` once — when the page is created, or on any later run that
|
* `topicTags` once — when the page is created, or on any later run that
|
||||||
* finds the key missing — and never refreshed after, so a sync with nothing
|
* finds the key missing — and never refreshed after, so a sync with nothing
|
||||||
* new makes no request. Delete the key to refetch it. The page itself
|
* new makes no request. Delete the key to refetch it. The page itself
|
||||||
* renders no badges: the docs `PageHeader` slot
|
* renders no badges: the docs MDX `wrapper`
|
||||||
* (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the
|
* (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the
|
||||||
* `video:` key `videos.ts` writes into the badge row above the title.
|
* `video:` key `videos.ts` writes into the badge row under the description.
|
||||||
* - Every tag a page carries that no campaign topic teaches (not a key of
|
* - Every tag a page carries that no campaign topic teaches (not a key of
|
||||||
* `apps/docs/topic-landings.ts`) gets a placeholder guide page,
|
* `apps/docs/topic-landings.ts`) gets a placeholder guide page,
|
||||||
* `guides/<slug>.mdx`, with `list:` pointing at LeetCode's problem list for
|
* `guides/<slug>.mdx`, with `list:` pointing at LeetCode's problem list for
|
||||||
* the tag. Created once (a missing or empty file), then hand-owned; the tag's
|
* the tag. Created once (a missing or empty file), then hand-owned except
|
||||||
* badge on a problem page links to it.
|
* for `sidebar.badge`, which sync keeps at the guide's backlink count (the
|
||||||
|
* problem pages carrying the tag; dropped at zero); the tag's badge on a
|
||||||
|
* problem page links to it. A guide may be filed into a category `(group)`
|
||||||
|
* folder (e.g. `guides/(2-algorithms)/`) — no route segment, so
|
||||||
|
* `/guides/<slug>` holds — and is found there by slug; a new placeholder
|
||||||
|
* lands at the `guides/` root for a human to file (and give an `icon:`,
|
||||||
|
* which every badge linking to the guide shows; sync warns while one is
|
||||||
|
* missing or shared).
|
||||||
*
|
*
|
||||||
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`,
|
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`,
|
||||||
* ordered by topic number via a script-owned `meta.ts`). A problem outside the
|
* ordered by topic number via a script-owned `meta.ts`). A problem outside the
|
||||||
@@ -30,13 +37,15 @@
|
|||||||
* its file sits under in `work/1/<Difficulty>/<Category>/` instead.
|
* its file sits under in `work/1/<Difficulty>/<Category>/` instead.
|
||||||
*
|
*
|
||||||
* Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`.
|
* Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`.
|
||||||
* The phase folder is a Blume `(group)` — a sidebar section with its own
|
* The phase folder is a Blume `(group)` — a sidebar section adding no route
|
||||||
* script-owned `meta.ts`, adding no route segment — while the topic folder is
|
* segment — while the topic folder is a real segment
|
||||||
* a real segment (`/tree-dfs/invert-binary-tree`), so a hand-written
|
* (`/tree-dfs/invert-binary-tree`), so a hand-written `index.mdx` in it is the
|
||||||
* `index.mdx` in it is the topic's landing page at `/tree-dfs` and Blume links
|
* topic's landing page at `/tree-dfs` and Blume links the sidebar group row to
|
||||||
* the sidebar group row to it. Sync never writes or moves an `index.mdx`, and
|
* it. Sync never writes or moves an `index.mdx`, and never removes a folder
|
||||||
* never removes a folder that still holds one. An unplanned category group has
|
* that still holds one. A phase folder's `meta.ts` is written once, when
|
||||||
* no phase and sits at the content root.
|
* missing, then hand-owned (its title is yours to rename); a topic's is
|
||||||
|
* script-owned. An unplanned category group has no phase and sits at the
|
||||||
|
* content root.
|
||||||
*
|
*
|
||||||
* Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves
|
* Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves
|
||||||
* of problems whose page already exists (see work.ts), and a fresh scaffold
|
* of problems whose page already exists (see work.ts), and a fresh scaffold
|
||||||
@@ -309,7 +318,13 @@ function phaseDir(phase: Phase): string {
|
|||||||
return `(${String(phase.order).padStart(2, "0")}-${slugify(phase.name)})`;
|
return `(${String(phase.order).padStart(2, "0")}-${slugify(phase.name)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Blume `(group)` meta for a phase: the overview page's heading, in phase order. */
|
/**
|
||||||
|
* Blume `(group)` meta for a phase: the overview page's heading, in phase
|
||||||
|
* order. Written only when the folder has none — the title is hand-owned after
|
||||||
|
* that. Nothing is lost by never rewriting it: the order and name it is built
|
||||||
|
* from are also the folder name (`phaseDir`), so a README change to either
|
||||||
|
* lands in a new folder that gets a fresh meta.
|
||||||
|
*/
|
||||||
function phaseMeta(phase: Phase): string {
|
function phaseMeta(phase: Phase): string {
|
||||||
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: ${JSON.stringify(`Phase ${phase.order}: ${phase.name}`)}, order: ${phase.order} });\n`;
|
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: ${JSON.stringify(`Phase ${phase.order}: ${phase.name}`)}, order: ${phase.order} });\n`;
|
||||||
}
|
}
|
||||||
@@ -387,7 +402,7 @@ function frontmatterTopics(page: string): TopicTag[] {
|
|||||||
/**
|
/**
|
||||||
* Placeholder for a tag no campaign topic teaches: the `/guides/<slug>` page a
|
* Placeholder for a tag no campaign topic teaches: the `/guides/<slug>` page a
|
||||||
* problem's topic badge links to. `list:` is the tag's LeetCode problem list,
|
* problem's topic badge links to. `list:` is the tag's LeetCode problem list,
|
||||||
* rendered as a badge by the docs `PageHeader` slot. Written once — the page
|
* rendered as a badge by the docs MDX `wrapper`. Written once — the page
|
||||||
* is hand-owned from then on.
|
* is hand-owned from then on.
|
||||||
*/
|
*/
|
||||||
function guidePage(tag: TopicTag): string {
|
function guidePage(tag: TopicTag): string {
|
||||||
@@ -407,6 +422,27 @@ function guidePage(tag: TopicTag): string {
|
|||||||
/** Folder meta for `guides/`: its own header tab (`navigation.tabs` in blume.config.ts), so only the label. */
|
/** Folder meta for `guides/`: its own header tab (`navigation.tabs` in blume.config.ts), so only the label. */
|
||||||
const GUIDES_META = `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: "Guides" });\n`;
|
const GUIDES_META = `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: "Guides" });\n`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A guide's frontmatter with `sidebar.badge` set to its backlink count — the
|
||||||
|
* one key sync owns on a hand-owned guide — or dropped (with a `sidebar:` left
|
||||||
|
* empty) at zero, so the sidebar shows a count only where there is something
|
||||||
|
* to count.
|
||||||
|
*/
|
||||||
|
function withBacklinkBadge(page: string, count: number): string {
|
||||||
|
const fm = page.match(/^---\n[\s\S]*?\n---/);
|
||||||
|
if (!fm) return page;
|
||||||
|
let head = fm[0].replace(/^ {2}badge: .*\n/m, "");
|
||||||
|
if (count > 0) {
|
||||||
|
const line = ` badge: '${count}'`;
|
||||||
|
head = /^sidebar:$/m.test(head)
|
||||||
|
? head.replace(/^sidebar:$/m, `sidebar:\n${line}`)
|
||||||
|
: head.replace(/\n---$/, `\nsidebar:\n${line}\n---`);
|
||||||
|
} else {
|
||||||
|
head = head.replace(/^sidebar:\n(?! {2})/m, "");
|
||||||
|
}
|
||||||
|
return head + page.slice(fm[0].length);
|
||||||
|
}
|
||||||
|
|
||||||
// ── inline-code formatting heuristics ────────────────────────────
|
// ── inline-code formatting heuristics ────────────────────────────
|
||||||
|
|
||||||
/** Identifiers worth backticking in prose, harvested from the code. */
|
/** Identifiers worth backticking in prose, harvested from the code. */
|
||||||
@@ -651,6 +687,7 @@ const phases = new Map<string, Phase>(); // phase folder -> phase, for meta.ts
|
|||||||
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
|
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
|
||||||
const redirects: string[] = []; // `{ from: "/<num>", to: "/<topic>/<slug>" }` rows, numeric order
|
const redirects: string[] = []; // `{ from: "/<num>", to: "/<topic>/<slug>" }` rows, numeric order
|
||||||
const tags = new Map<string, TopicTag>(); // every tag slug any problem page carries -> tag
|
const tags = new Map<string, TopicTag>(); // every tag slug any problem page carries -> tag
|
||||||
|
const tagged = new Map<string, number>(); // tag slug -> how many problem pages carry it
|
||||||
|
|
||||||
for (const sol of ordered) {
|
for (const sol of ordered) {
|
||||||
const langs = Object.keys(sol.code).sort().join("+");
|
const langs = Object.keys(sol.code).sort().join("+");
|
||||||
@@ -666,6 +703,7 @@ for (const sol of ordered) {
|
|||||||
const existing = pages.get(sol.num);
|
const existing = pages.get(sol.num);
|
||||||
await mkdir(join(DOCS, dir), { recursive: true });
|
await mkdir(join(DOCS, dir), { recursive: true });
|
||||||
|
|
||||||
|
let written: string; // the page as it stands after this run, for its topics
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const page = await Bun.file(join(DOCS, existing)).text();
|
const page = await Bun.file(join(DOCS, existing)).text();
|
||||||
const at = page.indexOf("## Solution");
|
const at = page.indexOf("## Solution");
|
||||||
@@ -687,12 +725,16 @@ for (const sol of ordered) {
|
|||||||
actions.push("updated");
|
actions.push("updated");
|
||||||
}
|
}
|
||||||
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
|
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
|
||||||
for (const tag of frontmatterTopics(next)) tags.set(tag.slug, tag);
|
written = next;
|
||||||
} else {
|
} else {
|
||||||
const page = renderPage(sol, await fetchTopicTags(sol));
|
const page = renderPage(sol, await fetchTopicTags(sol));
|
||||||
await Bun.write(join(DOCS, name), page);
|
await Bun.write(join(DOCS, name), page);
|
||||||
report.push([workRef, name, "created"]);
|
report.push([workRef, name, "created"]);
|
||||||
for (const tag of frontmatterTopics(page)) tags.set(tag.slug, tag);
|
written = page;
|
||||||
|
}
|
||||||
|
for (const tag of frontmatterTopics(written)) {
|
||||||
|
tags.set(tag.slug, tag);
|
||||||
|
tagged.set(tag.slug, (tagged.get(tag.slug) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
// The route drops the phase `(group)` and the page's number prefix.
|
// The route drops the phase `(group)` and the page's number prefix.
|
||||||
redirects.push(` { from: "/${sol.num}", to: ${JSON.stringify(`/${slugify(topic.name)}/${sol.slug}`)} },`);
|
redirects.push(` { from: "/${sol.num}", to: ${JSON.stringify(`/${slugify(topic.name)}/${sol.slug}`)} },`);
|
||||||
@@ -715,22 +757,61 @@ for (const sol of ordered) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// One guide page per tag that no campaign topic teaches, created when missing
|
// One guide page per tag that no campaign topic teaches, created when missing
|
||||||
// (an empty file counts as missing) and never rewritten. A guide whose tag no
|
// (an empty file counts as missing) and never rewritten. A guide is found by
|
||||||
// page carries any more is left alone: it is hand-owned.
|
// slug anywhere under guides/ — a human files it into a category `(group)`,
|
||||||
|
// which leaves its route alone — and a new one lands at the guides/ root. A
|
||||||
|
// guide whose tag no page carries any more is left alone: it is hand-owned.
|
||||||
const guideTags = [...tags.values()]
|
const guideTags = [...tags.values()]
|
||||||
.filter((tag) => !TOPIC_LANDINGS[tag.slug])
|
.filter((tag) => !TOPIC_LANDINGS[tag.slug])
|
||||||
.sort((a, b) => a.slug.localeCompare(b.slug));
|
.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||||
|
const guideFiles = new Map<string, string>(); // slug -> path relative to DOCS
|
||||||
|
for await (const rel of new Bun.Glob("guides/**/*.mdx").scan(DOCS)) guideFiles.set(basename(rel, ".mdx"), rel);
|
||||||
for (const tag of guideTags) {
|
for (const tag of guideTags) {
|
||||||
const file = Bun.file(join(DOCS, "guides", `${tag.slug}.mdx`));
|
const rel = guideFiles.get(tag.slug) ?? `guides/${tag.slug}.mdx`;
|
||||||
|
const file = Bun.file(join(DOCS, rel));
|
||||||
if ((await file.exists()) && file.size > 0) continue;
|
if ((await file.exists()) && file.size > 0) continue;
|
||||||
await Bun.write(file, guidePage(tag));
|
await Bun.write(file, guidePage(tag));
|
||||||
report.push([`tag ${tag.slug}`, `guides/${tag.slug}.mdx`, "created"]);
|
report.push([`tag ${tag.slug}`, rel, "created"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Script-owned folder metas — one per live phase and topic folder, plus
|
// Every guide's sidebar badge is its backlink count: the problem pages whose
|
||||||
// guides/ — rewritten only when they differ.
|
// tag badge links to it, which is every page carrying the tag, since a guide's
|
||||||
|
// tag has no landing (`tagTarget()`). A guide whose tag a landing has since
|
||||||
|
// claimed has no backlinks left. Counts move with every solved problem, so the
|
||||||
|
// badge is rewritten whenever it differs; the rest of the guide is untouched.
|
||||||
|
// The same pass checks each guide's hand-picked `icon:`, which is also the icon
|
||||||
|
// on every problem-page badge linking to the guide: it must exist, must not be
|
||||||
|
// a topic group's (`TOPIC_ICONS`), so a guide badge never reads as a topic's,
|
||||||
|
// and must not be another guide's.
|
||||||
|
const topicIcons = new Set(Object.values(TOPIC_ICONS));
|
||||||
|
const guideIcons = new Map<string, string>(); // icon -> the guide that took it first
|
||||||
|
for await (const rel of new Bun.Glob("guides/**/*.mdx").scan(DOCS)) {
|
||||||
|
const slug = basename(rel, ".mdx");
|
||||||
|
const count = TOPIC_LANDINGS[slug] ? 0 : (tagged.get(slug) ?? 0);
|
||||||
|
const page = await Bun.file(join(DOCS, rel)).text();
|
||||||
|
const icon = page.match(/^---\n[\s\S]*?\n---/)?.[0].match(/^icon: (\S+)$/m)?.[1];
|
||||||
|
if (!icon) console.warn(`${rel}: no icon; its badges render without one — file it and add \`icon:\``);
|
||||||
|
else if (topicIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is a topic's (TOPIC_ICONS); pick another`);
|
||||||
|
else if (guideIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is ${guideIcons.get(icon)}'s too; pick another`);
|
||||||
|
else guideIcons.set(icon, rel);
|
||||||
|
const next = withBacklinkBadge(page, count);
|
||||||
|
if (next === page) continue;
|
||||||
|
await Bun.write(join(DOCS, rel), next);
|
||||||
|
report.push([`${count} backlinks`, rel, "badged"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase metas: created when missing (an empty file counts as missing), never
|
||||||
|
// rewritten, so a renamed phase title survives every sync.
|
||||||
|
for (const [dir, phase] of phases) {
|
||||||
|
const file = Bun.file(join(DOCS, dir, "meta.ts"));
|
||||||
|
if ((await file.exists()) && file.size > 0) continue;
|
||||||
|
await Bun.write(file, phaseMeta(phase));
|
||||||
|
report.push([`phase ${phase.order}`, `${dir}/meta.ts`, "created"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script-owned folder metas — one per live topic folder, plus guides/ —
|
||||||
|
// rewritten only when they differ.
|
||||||
const metas: [label: string, dir: string, meta: string][] = [
|
const metas: [label: string, dir: string, meta: string][] = [
|
||||||
...[...phases].map(([dir, phase]): [string, string, string] => [`phase ${phase.order}`, dir, phaseMeta(phase)]),
|
|
||||||
...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]),
|
...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]),
|
||||||
...(guideTags.length ? [["guides", "guides", GUIDES_META] as [string, string, string]] : []),
|
...(guideTags.length ? [["guides", "guides", GUIDES_META] as [string, string, string]] : []),
|
||||||
];
|
];
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
* Find the NeetCode explanation video for every docs page that has none.
|
* Find the NeetCode explanation video for every docs page that has none.
|
||||||
*
|
*
|
||||||
* A finished solution page carries frontmatter `video: <YouTube id>` —
|
* A finished solution page carries frontmatter `video: <YouTube id>` —
|
||||||
* NeetCode's walkthrough of the problem — which the docs `PageHeader` slot
|
* NeetCode's walkthrough of the problem — which the docs MDX `wrapper`
|
||||||
* (`apps/docs/components/ProblemBadges.astro`) renders as the red
|
* (`apps/docs/components/ProblemBadges.astro`) renders as the red
|
||||||
* "Explanation" badge beside the topic badges. `sync.ts` never writes that key
|
* "Explanation" badge beside the topic badges. `sync.ts` never writes that key
|
||||||
* (it owns `icon:`, `topics:` and `## Solution`), so pages accumulate without
|
* (it owns `icon:`, `topics:` and `## Solution`), so pages accumulate without
|
||||||
|
|||||||
@@ -125,6 +125,28 @@ export default defineConfig({
|
|||||||
frontmatter: {
|
frontmatter: {
|
||||||
extend: { topics: topicsSchema, video: videoSchema, list: listSchema },
|
extend: { topics: topicsSchema, video: videoSchema, list: listSchema },
|
||||||
},
|
},
|
||||||
|
seo: {
|
||||||
|
og: { enabled: true },
|
||||||
|
rss: { enabled: true, types: ["changelog"] },
|
||||||
|
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 },
|
||||||
|
},
|
||||||
content: {
|
content: {
|
||||||
sources: [
|
sources: [
|
||||||
filesystem({ root: "content" }),
|
filesystem({ root: "content" }),
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,12 @@ export default defineComponents({
|
|||||||
mdx: {
|
mdx: {
|
||||||
Carousel: "./components/Carousel.astro",
|
Carousel: "./components/Carousel.astro",
|
||||||
CarouselImage: "./components/CarouselImage.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: {
|
layout: {
|
||||||
// Problem pages' topic + explanation badges, from `topics:` / `video:` frontmatter.
|
// Topic landing + guide pages' "what links here": every problem pointing at the page.
|
||||||
PageHeader: "./components/ProblemBadges.astro",
|
PageFooter: "./components/BacklinksList.astro",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* The `PageFooter` slot: an Obsidian-style backlinks list under a topic landing
|
||||||
|
* or guide page — every problem page that points at it, as `<num> <name>`
|
||||||
|
* rows in LeetCode-number order. Pages nothing points at (problem pages, the
|
||||||
|
* overview, the changelog) render nothing.
|
||||||
|
*
|
||||||
|
* 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,60 +1,99 @@
|
|||||||
---
|
---
|
||||||
/**
|
/**
|
||||||
* The `PageHeader` slot: the badge row above a page's title. Blume hands the
|
* The MDX `wrapper`: the badge row between a page's description and its body.
|
||||||
* slot only the route, so the page's frontmatter is looked up through the
|
* Blume's page template renders the title and description itself and then
|
||||||
* route manifest — the same `getEntry` call Blume's own page template makes.
|
* the MDX content, with no slot in between, so wrapping the content is the
|
||||||
* Pages with none of the keys below (landing pages, the changelog) render
|
* only way to land right after the description. MDX hands a wrapper no
|
||||||
* nothing.
|
* 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
|
* - `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 tags. A tag the campaign teaches (`topic-landings.ts`) links to that
|
||||||
* topic's landing page and reads accent; any other tag links to its guide
|
* topic's landing page and is filled; any other tag links to its guide
|
||||||
* page, `/guides/<slug>` (sync creates one per such tag), and reads neutral.
|
* page, `/guides/<slug>` (sync creates one per such tag), and is outlined.
|
||||||
|
* `tagTarget()` picks the route (and between two landings, e.g. DFS); the
|
||||||
|
* `PageFooter` backlinks list reads the same answer. Each tag badge is a
|
||||||
|
* pill carrying the icon its target shows in the sidebar (the topic's
|
||||||
|
* `meta.ts` icon, the guide's `icon:`) and the tag's own colour
|
||||||
|
* (`lib/tag-colors.ts`), assigned over every tag the site has a page for —
|
||||||
|
* no two tags share one, and tags that share problem pages are spread
|
||||||
|
* furthest apart. A tag with no page (the LeetCode-list fallback) stays
|
||||||
|
* gray and iconless.
|
||||||
* - `video:` (problem pages, written by `apps/cli/videos.ts`) is the NeetCode
|
* - `video:` (problem pages, written by `apps/cli/videos.ts`) is the NeetCode
|
||||||
* walkthrough's YouTube id, the red "Explanation" badge at the end of the row.
|
* walkthrough's YouTube id, the red "Explanation" badge at the end of the row.
|
||||||
* - `list:` (guide pages, written by sync on create) is the tag's LeetCode
|
* - `list:` (guide pages, written by sync on create) is the tag's LeetCode
|
||||||
* problem list, an orange "LeetCode list" badge.
|
* problem list, an orange "LeetCode list" badge.
|
||||||
*
|
|
||||||
* A tag naming two campaign topics (`Depth-First Search` is both Tree DFS and
|
|
||||||
* Graph DFS) 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. A route the site
|
|
||||||
* does not serve (a landing or guide page removed) falls back to LeetCode's
|
|
||||||
* problem list for the tag, so a badge never links to a 404.
|
|
||||||
*/
|
*/
|
||||||
import { getEntry } from "astro:content";
|
import { getEntry } from "astro:content";
|
||||||
import data from "blume:data";
|
import data from "blume:data";
|
||||||
import Badge from "blume/components/content/Badge.astro";
|
import Badge from "blume/components/content/Badge.astro";
|
||||||
import { withBase } from "blume/components/islands/base-path.ts";
|
import { stripBase, withBase } from "blume/components/islands/base-path.ts";
|
||||||
import { TOPIC_LANDINGS } from "../topic-landings.ts";
|
import { tagColors } from "../lib/tag-colors.ts";
|
||||||
|
import { TOPIC_LANDINGS, tagTarget, type TopicTag } from "../topic-landings.ts";
|
||||||
|
|
||||||
interface Props {
|
// `/sub/tree-dfs/invert-binary-tree/` (or `.../index.html` in a static build)
|
||||||
route: string;
|
// -> `/tree-dfs/invert-binary-tree`, the base-less shape of `data.routes`.
|
||||||
}
|
const route =
|
||||||
|
stripBase(import.meta.env.BASE_URL ?? "/", Astro.url.pathname)
|
||||||
interface TopicTag {
|
.replace(/(\/index)?\.html$/u, "")
|
||||||
name: string;
|
.replace(/(.)\/+$/u, "$1");
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { route } = Astro.props;
|
|
||||||
const at = data.routes.find((r: { path: string }) => r.path === route);
|
const at = data.routes.find((r: { path: string }) => r.path === route);
|
||||||
const entry = at ? await getEntry(at.collection, at.entryId) : undefined;
|
const entry = at ? await getEntry(at.collection, at.entryId) : undefined;
|
||||||
const tags: TopicTag[] = entry?.data.topics ?? [];
|
const tags: TopicTag[] = entry?.data.topics ?? [];
|
||||||
const video: string | undefined = entry?.data.video;
|
const video: string | undefined = entry?.data.video;
|
||||||
const list: string | undefined = entry?.data.list;
|
const list: string | undefined = entry?.data.list;
|
||||||
|
|
||||||
const served = new Set(data.routes.map((r: { path: string }) => r.path));
|
const served = new Set<string>(data.routes.map((r: { path: string }) => r.path));
|
||||||
const own = `/${route.split("/")[1]}`;
|
|
||||||
const prefer = tags.some((t) => t.slug === "tree") ? "/tree-" : "/graph-";
|
// 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 page — the ones a landing teaches, plus every served guide —
|
||||||
|
// 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),
|
||||||
|
...[...served].flatMap((path) => path.match(/^\/guides\/([^/]+)$/u)?.slice(1) ?? []),
|
||||||
|
],
|
||||||
|
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 badges = tags.map((tag) => {
|
||||||
const landings = (TOPIC_LANDINGS[tag.slug] ?? []).filter((path) => served.has(path));
|
const target = tagTarget(tag, { route, tags }, served);
|
||||||
const landing = landings.includes(own) ? own : (landings.find((p) => p.startsWith(prefer)) ?? landings[0]);
|
const external = target.kind === "external";
|
||||||
if (landing) return { name: tag.name, href: withBase(landing), accent: true, external: false };
|
return {
|
||||||
const guide = `/guides/${tag.slug}`;
|
name: tag.name,
|
||||||
if (served.has(guide)) return { name: tag.name, href: withBase(guide), accent: false, external: false };
|
href: external ? target.href : withBase(target.href),
|
||||||
return { name: tag.name, href: `https://leetcode.com/problem-list/${tag.slug}/`, accent: false, external: true };
|
external,
|
||||||
|
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";
|
const linkClass = "no-underline transition-opacity hover:opacity-80";
|
||||||
@@ -62,7 +101,7 @@ const linkClass = "no-underline transition-opacity hover:opacity-80";
|
|||||||
|
|
||||||
{
|
{
|
||||||
(badges.length > 0 || video || list) && (
|
(badges.length > 0 || video || list) && (
|
||||||
<div class="not-prose mx-auto mb-4 flex max-w-content flex-wrap gap-1.5">
|
<div class="not-prose mb-6 flex flex-wrap gap-1.5">
|
||||||
{badges.map((b) => (
|
{badges.map((b) => (
|
||||||
<a
|
<a
|
||||||
href={b.href}
|
href={b.href}
|
||||||
@@ -70,19 +109,21 @@ const linkClass = "no-underline transition-opacity hover:opacity-80";
|
|||||||
rel={b.external ? "noopener" : undefined}
|
rel={b.external ? "noopener" : undefined}
|
||||||
class={linkClass}
|
class={linkClass}
|
||||||
>
|
>
|
||||||
<Badge variant={b.accent ? "accent" : "default"}>{b.name}</Badge>
|
<Badge color={b.color} icon={b.icon} shape="pill" stroke={b.stroke}>
|
||||||
|
{b.name}
|
||||||
|
</Badge>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
{video && (
|
{video && (
|
||||||
<a href={`https://www.youtube.com/watch?v=${video}`} target="_blank" rel="noopener" class={linkClass}>
|
<a href={`https://www.youtube.com/watch?v=${video}`} target="_blank" rel="noopener" class={linkClass}>
|
||||||
<Badge variant="danger" icon="youtube">
|
<Badge variant="danger" icon="youtube" shape="pill">
|
||||||
Explanation
|
Explanation
|
||||||
</Badge>
|
</Badge>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{list && (
|
{list && (
|
||||||
<a href={list} target="_blank" rel="noopener" class={linkClass}>
|
<a href={list} target="_blank" rel="noopener" class={linkClass}>
|
||||||
<Badge variant="warning" icon="list">
|
<Badge variant="warning" icon="list" shape="pill">
|
||||||
LeetCode list
|
LeetCode list
|
||||||
</Badge>
|
</Badge>
|
||||||
</a>
|
</a>
|
||||||
@@ -90,3 +131,5 @@ const linkClass = "no-underline transition-opacity hover:opacity-80";
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<slot />
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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 }
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"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 }
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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 +1,8 @@
|
|||||||
import { defineMeta } from "blume";
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
export default defineMeta({ title: "Binary Search", order: 5, icon: "search", display: "group" });
|
export default defineMeta({
|
||||||
|
title: "Binary Search",
|
||||||
|
order: 5,
|
||||||
|
icon: "search",
|
||||||
|
display: "group",
|
||||||
|
});
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
import { defineMeta } from "blume";
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
export default defineMeta({ title: "Hash Table", order: 1, icon: "hash", display: "group" });
|
export default defineMeta({
|
||||||
|
title: "Hash Table",
|
||||||
|
order: 1,
|
||||||
|
icon: "hash",
|
||||||
|
display: "group",
|
||||||
|
});
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import { defineMeta } from "blume";
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
export default defineMeta({ title: "Phase 1: Linear Structures", order: 1 });
|
export default defineMeta({ title: "1. Linear Structures", order: 1 });
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import { defineMeta } from "blume";
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
export default defineMeta({ title: "Phase 2: Nodal & Grid", order: 2 });
|
export default defineMeta({ title: "2. Nodal & Grid", order: 2 });
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import { defineMeta } from "blume";
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
export default defineMeta({ title: "Phase 3: Hierarchical", order: 3 });
|
export default defineMeta({ title: "3. Hierarchical", order: 3 });
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import { defineMeta } from "blume";
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
export default defineMeta({ title: "Phase 4: Relational", order: 4 });
|
export default defineMeta({ title: "4. Relational", order: 4 });
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
title: '134. Gas Station'
|
||||||
|
description: There are n gas stations along a circular route, where the amount of gas at the i^th station is gas[i]
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Array", slug: "array" }
|
||||||
|
- { name: "Greedy", slug: "greedy" }
|
||||||
|
sidebar:
|
||||||
|
label: 'Gas Station'
|
||||||
|
badge: 'Medium'
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `gas = [1,2,3,4,5], cost = [3,4,5,1,2]`
|
||||||
|
- Output: `3`
|
||||||
|
- Explanation: Start at station `3` (index `3`) and fill up with `4` unit of `gas`. Your tank = `0 + 4 = 4` Travel to station `4`. Your tank = `4 - 1 + 5 = 8` Travel to station `0`. Your tank = `8 - 2 + 1 = 7` Travel to station `1`. Your tank = `7 - 3 + 2 = 6` Travel to station `2`. Your tank = `6 - 4 + 3 = 5` Travel to station `3`. The `cost` is `5`. Your `gas` is just enough to travel back to station `3`. Therefore, return `3` as the starting index.
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `gas = [2,3,4], cost = [3,4,3]`
|
||||||
|
- Output: `-1`
|
||||||
|
- Explanation: You can't start at station `0` or `1`, as there is not enough `gas` to travel to the next station. Let's start at station `2` and fill up with `4` unit of `gas`. Your tank = `0 + 4 = 4` Travel to station `0`. Your tank = `4 - 3 + 2 = 3` Travel to station `1`. Your tank = `3 - 3 + 3 = 3` You cannot travel back to station `2`, as it requires `4` unit of `gas` but you only have `3`. Therefore, you can't travel around the circuit once no matter where you start.
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `n == gas.length == cost.length`
|
||||||
|
- `1 <= n <= 10^5`
|
||||||
|
- `0 <= gas[i], cost[i] <= 10^4`
|
||||||
|
- The input is generated such that the answer is unique.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def canCompleteCircuit(self, gas: list[int], cost: list[int]) -> int:
|
||||||
|
start, tank, total = 0, 0, 0
|
||||||
|
|
||||||
|
for i in range(len(gas)):
|
||||||
|
drive = gas[i] - cost[i]
|
||||||
|
tank = tank + drive
|
||||||
|
|
||||||
|
if tank < 0:
|
||||||
|
tank = 0
|
||||||
|
start = i + 1
|
||||||
|
total = total + drive
|
||||||
|
|
||||||
|
return start if total >= 0 else -1
|
||||||
|
```
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
title: '53. Maximum Subarray'
|
||||||
|
description: Given an integer array nums, find the subarray with the largest sum, and return its sum
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Array", slug: "array" }
|
||||||
|
- { name: "Divide and Conquer", slug: "divide-and-conquer" }
|
||||||
|
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
||||||
|
sidebar:
|
||||||
|
label: 'Maximum Subarray'
|
||||||
|
badge: 'Medium'
|
||||||
|
---
|
||||||
|
|
||||||
|
::::warning
|
||||||
|
If you have figured out the O(n) solution, try coding another solution using the divide and conquer approach, which is more subtle.
|
||||||
|
::::
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `nums = [-2,1,-3,4,-1,2,1,-5,4]`
|
||||||
|
- Output: `6`
|
||||||
|
- Explanation: The subarray `[4,-1,2,1]` has the largest sum `6`.
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `nums = [1]`
|
||||||
|
- Output: `1`
|
||||||
|
- Explanation: The subarray `[1]` has the largest sum `1`.
|
||||||
|
|
||||||
|
### Example 3:
|
||||||
|
- Input: `nums = [5,4,-1,7,8]`
|
||||||
|
- Output: `23`
|
||||||
|
- Explanation: The subarray `[5,4,-1,7,8]` has the largest sum `23`.
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `1 <= nums.length <= 10^5`
|
||||||
|
- `-10^4 <= nums[i] <= 10^4`
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def maxSubArray(self, nums: list[int]) -> int:
|
||||||
|
best, total = nums[0], nums[0]
|
||||||
|
|
||||||
|
for i in range(1, len(nums)):
|
||||||
|
if total < 0:
|
||||||
|
total = nums[i]
|
||||||
|
else:
|
||||||
|
total = total + nums[i]
|
||||||
|
best = max(best, total)
|
||||||
|
|
||||||
|
return best
|
||||||
|
```
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
title: '55. Jump Game'
|
||||||
|
description: You are given an integer array nums. You are initially positioned at the array's first index, and each element in the array represents your maximum jump length at that position
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Array", slug: "array" }
|
||||||
|
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
||||||
|
- { name: "Greedy", slug: "greedy" }
|
||||||
|
sidebar:
|
||||||
|
label: 'Jump Game'
|
||||||
|
badge: 'Medium'
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `nums = [2,3,1,1,4]`
|
||||||
|
- Output: `true`
|
||||||
|
- Explanation: Jump `1` step from index `0` to `1`, then `3` steps to the last index.
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `nums = [3,2,1,0,4]`
|
||||||
|
- Output: `false`
|
||||||
|
- Explanation: You will always arrive at index `3` no matter what. Its maximum jump length is `0`, which makes it impossible to reach the last index.
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `1 <= nums.length <= 10^4`
|
||||||
|
- `0 <= nums[i] <= 10^5`
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def canJump(self, nums: list[int]) -> bool:
|
||||||
|
best, last = nums[0], len(nums) - 1
|
||||||
|
|
||||||
|
for i in range(last):
|
||||||
|
if i > best:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
pos = nums[i] + i
|
||||||
|
best = max(best, pos)
|
||||||
|
|
||||||
|
return best >= last
|
||||||
|
```
|
||||||
@@ -3,3 +3,91 @@ title: Greedy
|
|||||||
description: 'The best local choice is made at each step and is not changed later.'
|
description: 'The best local choice is made at each step and is not changed later.'
|
||||||
hidden: true
|
hidden: true
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## When to use greedy algorithms?
|
||||||
|
|
||||||
|
- Solving optimization problems.
|
||||||
|
- Ask to optimize some value and calculate it.
|
||||||
|
- keywords (min/max, longest/shortest, largest/smallest, etc.)
|
||||||
|
|
||||||
|
## Optimization problems
|
||||||
|
|
||||||
|
1. Return the minimum number of swaps required to make s1 equal to s2.
|
||||||
|
2. Return the minimum number of steps to make s and t anagrams of each other.
|
||||||
|
3. Given three integers a, b, and c, return the longest possible happy string, if there are multiple such strings return any of them. if there is no such string, return empty string.
|
||||||
|
|
||||||
|
## What are greedy algorithms?
|
||||||
|
|
||||||
|
- Greedy algorithms follow this format:
|
||||||
|
- identify what choices you have at every step
|
||||||
|
- find the best choice
|
||||||
|
- just do what looks best until done
|
||||||
|
- assume that the best choice is the one that will lead to the best outcome
|
||||||
|
|
||||||
|
## How do we know greedy works?
|
||||||
|
- Write out a formal proof
|
||||||
|
- too complicated to write out
|
||||||
|
- too much time
|
||||||
|
- better to use intuition
|
||||||
|
- try a bunch of ideas
|
||||||
|
- find counterexamples
|
||||||
|
|
||||||
|
## Steps to use greedy algorithms
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step title="Identify the different steps and choices">
|
||||||
|
- Choices have to be comparable
|
||||||
|
- must determine if a choice is better than another
|
||||||
|
</Step>
|
||||||
|
<Step title="Check if greedy works">
|
||||||
|
- try finding small cases where greedy is not optimal
|
||||||
|
- if cases pass, try to deduce why greedy is optimal
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Why use greedy algorithms?
|
||||||
|
|
||||||
|
- simple to code
|
||||||
|
- doesn't use complex algorithms (BFS, DFS, etc.)
|
||||||
|
- runtime is fast, usually 0(n) or O(nlogn)
|
||||||
|
- however, greedy algorithms are not always optimal
|
||||||
|
|
||||||
|
## The Greedy Skeleton
|
||||||
|
|
||||||
|
### Type 1: Walk and Carry
|
||||||
|
|
||||||
|
:::info
|
||||||
|
No sort. Walk the list once. Carry one number. At each step, decide: keep it, or drop it.
|
||||||
|
:::
|
||||||
|
|
||||||
|
```python
|
||||||
|
def walk_and_carry(nums):
|
||||||
|
carry = start_value
|
||||||
|
best = start_value
|
||||||
|
for n in nums:
|
||||||
|
if carry_is_useless(carry):
|
||||||
|
carry = fresh_start
|
||||||
|
carry = update(carry, n)
|
||||||
|
best = max(best, carry)
|
||||||
|
return best
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type 2: Sort and Pick
|
||||||
|
|
||||||
|
:::info
|
||||||
|
Used when order doesn't matter, so you pick the order. Meeting rooms, intervals, "most tasks by a deadline."
|
||||||
|
:::
|
||||||
|
|
||||||
|
```python
|
||||||
|
def greedy(items):
|
||||||
|
# 1. Sort by the greedy key (deadline, end time, size, ratio, ...)
|
||||||
|
items.sort(key=greedy_key)
|
||||||
|
result = 0
|
||||||
|
state = initial_state()
|
||||||
|
for item in items:
|
||||||
|
# 2. Take the item only if the locally best choice is valid
|
||||||
|
if is_feasible(item, state):
|
||||||
|
result += take(item, state)
|
||||||
|
# 3. Correctness relies on an exchange argument, not just passing tests
|
||||||
|
return result
|
||||||
|
```
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({ title: "Greedy", order: 23, icon: "coins", display: "group" });
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
title: '56. Merge Intervals'
|
||||||
|
description: Given an array of intervals where intervals[i] = [starti, endi], merge all overlapping intervals, and return an array of the non-overlapping intervals that cover all the intervals in the input
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Array", slug: "array" }
|
||||||
|
- { name: "Sorting", slug: "sorting" }
|
||||||
|
- { name: "Quicksort", slug: "quicksort" }
|
||||||
|
sidebar:
|
||||||
|
label: 'Merge Intervals'
|
||||||
|
badge: 'Medium'
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `intervals = [[1,3],[2,6],[8,10],[15,18]]`
|
||||||
|
- Output: `[[1,6],[8,10],[15,18]]`
|
||||||
|
- Explanation: Since `intervals [1,3]` and `[2,6]` overlap, merge them into `[1,6]`.
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `intervals = [[1,4],[4,5]]`
|
||||||
|
- Output: `[[1,5]]`
|
||||||
|
- Explanation: Intervals `[1,4]` and `[4,5]` are considered overlapping.
|
||||||
|
|
||||||
|
### Example 3:
|
||||||
|
- Input: `intervals = [[4,7],[1,4]]`
|
||||||
|
- Output: `[[1,7]]`
|
||||||
|
- Explanation: Intervals `[1,4]` and `[4,7]` are considered overlapping.
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `1 <= intervals.length <= 10^4`
|
||||||
|
- `intervals[i].length == 2`
|
||||||
|
- `0 <= starti <= endi <= 10^4`
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def merge(self, intervals: list[list[int]]) -> list[list[int]]:
|
||||||
|
intervals.sort(key=lambda x: x[0])
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for start, end in intervals:
|
||||||
|
if result and start <= result[-1][1]:
|
||||||
|
result[-1][1] = max(result[-1][1], end)
|
||||||
|
else:
|
||||||
|
result.append([start, end])
|
||||||
|
|
||||||
|
return result
|
||||||
|
```
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
title: '57. Insert Interval'
|
||||||
|
description: You are given an array of non-overlapping intervals intervals where intervals[i] = [starti, endi] represent the start and the end of the i^th interval and intervals is sorted in ascending order by starti. You are also given an interval newInterval = [start, end] that represents the start and end of another interval
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Array", slug: "array" }
|
||||||
|
sidebar:
|
||||||
|
label: 'Insert Interval'
|
||||||
|
badge: 'Medium'
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `intervals = [[1,3],[6,9]], newInterval = [2,5]`
|
||||||
|
- Output: `[[1,5],[6,9]]`
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `intervals = [[1,2],[3,5],[6,7],[8,10],[12,16]], newInterval = [4,8]`
|
||||||
|
- Output: `[[1,2],[3,10],[12,16]]`
|
||||||
|
- Explanation: Because the new interval `[4,8]` overlaps with [3,5],[6,7],[8,10].
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `0 <= intervals.length <= 10^4`
|
||||||
|
- `intervals[i].length == 2`
|
||||||
|
- `0 <= starti <= endi <= 10^5`
|
||||||
|
- intervals is sorted by starti in ascending order.
|
||||||
|
- `newInterval.length == 2`
|
||||||
|
- `0 <= start <= end <= 10^5`
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def insert(
|
||||||
|
self, intervals: list[list[int]], newInterval: list[int]
|
||||||
|
) -> list[list[int]]:
|
||||||
|
result = []
|
||||||
|
i, n = 0, len(intervals)
|
||||||
|
start, end = newInterval
|
||||||
|
|
||||||
|
# Everything before
|
||||||
|
while i < n and intervals[i][1] < start:
|
||||||
|
result.append(intervals[i])
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Everything merged
|
||||||
|
while i < n and intervals[i][0] <= end:
|
||||||
|
start = min(start, intervals[i][0])
|
||||||
|
end = max(end, intervals[i][1])
|
||||||
|
i += 1
|
||||||
|
result.append([start, end])
|
||||||
|
|
||||||
|
# Everything after
|
||||||
|
result.extend(intervals[i:])
|
||||||
|
return result
|
||||||
|
```
|
||||||
@@ -3,3 +3,172 @@ title: Intervals
|
|||||||
description: 'Ranges with a start and an end are sorted, and then merged, counted, or scheduled.'
|
description: 'Ranges with a start and an end are sorted, and then merged, counted, or scheduled.'
|
||||||
hidden: true
|
hidden: true
|
||||||
---
|
---
|
||||||
|
|
||||||
|
An interval is a pair `[start, end]` marking a stretch on a line:
|
||||||
|
- the minutes a meeting occupies
|
||||||
|
- the range a task runs
|
||||||
|
- the span a sensor was active
|
||||||
|
|
||||||
|
**What interval problems ask you?**
|
||||||
|
|
||||||
|
- Do any two collide?
|
||||||
|
- What does the timeline look like once you merge everything that touches?
|
||||||
|
- How many rooms do you need so no two meetings share one?
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
Every one of those questions reduces to a single primitive: deciding whether two intervals overlap, and if they do, how to merge them or what part they share. Once that primitive is clear, the rest of the pattern is bookkeeping.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## When two intervals overlap
|
||||||
|
|
||||||
|
It is easier to state when two intervals do not overlap. Interval `a` sits entirely before interval `b` when `a` finishes before `b` begins, that is `a.end < b.start`. By symmetry they also miss each other when `b` ends before `a` starts. If neither of those holds, the intervals overlap.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Negating `a.end < b.start` or `b.end < a.start` gives the positive test directly: two intervals overlap exactly when `a.start <= b.end` and `b.start <= a.end`. That pair of comparisons is the whole overlap check.
|
||||||
|
|
||||||
|
## The Merged Region
|
||||||
|
|
||||||
|
When two intervals overlap, you can merge them into one interval that covers both. Its start is the earlier of the two starts, and its end is the later of the two ends: `[min(a.start, b.start), max(a.end, b.end)]`. The merged interval begins as soon as either interval starts, and it ends only once both have stopped.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## The Shared Region
|
||||||
|
|
||||||
|
When two intervals overlap, the part they share is itself an interval. Its start is the later of the two starts, and its end is the earlier of the two ends: `[max(a.start, b.start), min(a.end, b.end)]`. The overlap begins only once both intervals have started, and it ends the moment either one stops.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
:::tip
|
||||||
|
Merge grows, overlap shrinks. Merge uses `[min, max]` and covers both intervals. Overlap uses `[max, min]` and keeps only the part both share.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Sort by Start first
|
||||||
|
|
||||||
|
Left unsorted, checking a list of intervals means comparing every pair, which is `O(n^2)`. Sorting the intervals by start time removes that cost. Once the list is in start order, any interval can only overlap ones that come after it and begin before it ends, so a single left-to-right pass is enough to merge, count, or detect collisions.
|
||||||
|
|
||||||
|
Sorting by start is the standard first line of almost every interval solution, and the problems below all lean on it.
|
||||||
|
|
||||||
|
That gives the template every interval problem in this section builds on: sort by start, then sweep once, comparing each interval against the last one you kept.
|
||||||
|
|
||||||
|
|
||||||
|
:::note[Input]
|
||||||
|
`[[1,3], [8,10], [2,6], [4,5], [10,12]]`
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::note[After sort]
|
||||||
|
`[[1,3], [2,6], [4,5], [8,10], [10,12]]`
|
||||||
|
:::
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Time: O(n log n)
|
||||||
|
# Space: O(n)
|
||||||
|
def interval_pattern(intervals):
|
||||||
|
# 1. Sort by start time
|
||||||
|
intervals.sort(key=lambda x: x[0])
|
||||||
|
result = []
|
||||||
|
for start, end in intervals:
|
||||||
|
# 2. Overlap with the last kept interval? start <= last end
|
||||||
|
if result and start <= result[-1][1]:
|
||||||
|
# Merge: extend the end of the last interval
|
||||||
|
result[-1][1] = max(result[-1][1], end)
|
||||||
|
else:
|
||||||
|
# 3. No overlap: keep this interval as-is
|
||||||
|
result.append([start, end])
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
<Expandable title="Show trace table">
|
||||||
|
|
||||||
|
| i | `[start, end]` | `result[-1]` | `start <= result[-1][1]`? | Action | `result` after |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 0 | `[1,3]` | — | — (empty) | append | `[[1,3]]` |
|
||||||
|
| 1 | `[2,6]` | `[1,3]` | `2 <= 3` ✅ | end = `max(3,6)` = 6 | `[[1,6]]` |
|
||||||
|
| 2 | `[4,5]` | `[1,6]` | `4 <= 6` ✅ | end = `max(6,5)` = 6 | `[[1,6]]` |
|
||||||
|
| 3 | `[8,10]` | `[1,6]` | `8 <= 6` ❌ | append | `[[1,6], [8,10]]` |
|
||||||
|
| 4 | `[10,12]` | `[8,10]` | `10 <= 10` ✅ | end = `max(10,12)` = 12 | `[[1,6], [8,12]]` |
|
||||||
|
|
||||||
|
|
||||||
|
:::tip[Output]
|
||||||
|
`[[1,6], [8,12]]`
|
||||||
|
- **Row 2:** `[4,5]` sits inside `[1,6]`. Setting `end = 5` without `max` would cut the interval short. `max` keeps 6.
|
||||||
|
- **Row 4:** `[8,10]` and `[10,12]` only touch at 10. The `<=` check merges them. With `<`, you would get two intervals.
|
||||||
|
:::
|
||||||
|
|
||||||
|
</Expandable>
|
||||||
|
|
||||||
|
|
||||||
|
## Insert into a sorted list
|
||||||
|
|
||||||
|
Sometimes the list is already sorted and has no overlaps, and you must add one new interval. You could append it, sort, and run the template. That works, but it pays `O(n log n)` for a sort you do not need.
|
||||||
|
|
||||||
|
A sorted list with no overlaps splits into three parts around the new interval, in this order:
|
||||||
|
|
||||||
|
1. Intervals that end **before** the new one starts. Keep them as they are.
|
||||||
|
2. Intervals that **overlap** the new one. Merge each into it with `[min, max]`.
|
||||||
|
3. Intervals that start **after** the new one ends. Keep them as they are.
|
||||||
|
|
||||||
|
Walk the list once and handle each part in turn. No sort, so the cost drops to `O(n)`.
|
||||||
|
|
||||||
|
:::note[Intervals]
|
||||||
|
`[[1,2], [3,5], [6,7], [8,10], [12,16]]`
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::note[New interval]
|
||||||
|
`[4,8]`
|
||||||
|
:::
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Time: O(n)
|
||||||
|
# Space: O(n)
|
||||||
|
def insert_interval(intervals, new_interval):
|
||||||
|
result = []
|
||||||
|
i, n = 0, len(intervals)
|
||||||
|
start, end = new_interval
|
||||||
|
|
||||||
|
# 1. Ends before new starts: keep
|
||||||
|
while i < n and intervals[i][1] < start:
|
||||||
|
result.append(intervals[i])
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# 2. Overlaps new: grow new to cover it
|
||||||
|
while i < n and intervals[i][0] <= end:
|
||||||
|
start = min(start, intervals[i][0])
|
||||||
|
end = max(end, intervals[i][1])
|
||||||
|
i += 1
|
||||||
|
result.append([start, end])
|
||||||
|
|
||||||
|
# 3. Starts after new ends: keep
|
||||||
|
result.extend(intervals[i:])
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
<Expandable title="Show trace table">
|
||||||
|
|
||||||
|
|
||||||
|
| i | interval | Part | Check | Action | `[start, end]` | `result` after |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 0 | `[1,2]` | 1 | `2 < 4` ✅ | keep | `[4,8]` | `[[1,2]]` |
|
||||||
|
| 1 | `[3,5]` | 1 | `5 < 4` ❌ | go to part 2 | `[4,8]` | `[[1,2]]` |
|
||||||
|
| 1 | `[3,5]` | 2 | `3 <= 8` ✅ | start = `min(4,3)` = 3 | `[3,8]` | `[[1,2]]` |
|
||||||
|
| 2 | `[6,7]` | 2 | `6 <= 8` ✅ | end = `max(8,7)` = 8 | `[3,8]` | `[[1,2]]` |
|
||||||
|
| 3 | `[8,10]` | 2 | `8 <= 8` ✅ | end = `max(8,10)` = 10 | `[3,10]` | `[[1,2]]` |
|
||||||
|
| 4 | `[12,16]` | 2 | `12 <= 10` ❌ | append new | `[3,10]` | `[[1,2], [3,10]]` |
|
||||||
|
| 4 | `[12,16]` | 3 | — | extend rest | — | `[[1,2], [3,10], [12,16]]` |
|
||||||
|
|
||||||
|
:::tip[Output]
|
||||||
|
`[[1,2], [3,10], [12,16]]`
|
||||||
|
- **Row i=1:** The same interval shows up twice. The part 1 check fails, so that loop stops, and part 2 reads the same interval again. `i` does not move.
|
||||||
|
- **Row i=3:** `[8,10]` only touches `[3,8]` at 8. The `<=` merges them, since one shared point counts as an overlap.
|
||||||
|
- **Part 1 uses `<`, part 2 uses `<=`:** An interval that ends exactly at `start` touches the new interval. It must fail part 1 so part 2 can merge it.
|
||||||
|
:::
|
||||||
|
|
||||||
|
</Expandable>
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
If the input is already sorted, do not sort again. Interviewers add "sorted" to the problem on purpose. Name the `O(n log n)` cost of re-sorting before they do, then offer the `O(n)` three-part walk.
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Footnote
|
||||||
|
|
||||||
|
The positive overlap test comes straight from De Morgan's law. Negating `(a.end < b.start)` or `(b.end < a.start)` gives `(a.end >= b.start)` and `(b.end >= a.start)`, which is the `a.start <= b.end` and `b.start <= a.end` condition rewritten.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({ title: "Intervals", order: 24, icon: "calendar-range", display: "group" });
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import { defineMeta } from "blume";
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
export default defineMeta({ title: "Phase 5: Decision Space", order: 5 });
|
export default defineMeta({ title: "5. Decision Space", order: 5 });
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Array"
|
title: "Array"
|
||||||
list: "https://leetcode.com/problem-list/array/"
|
list: "https://leetcode.com/problem-list/array/"
|
||||||
|
icon: list-ordered
|
||||||
|
sidebar:
|
||||||
|
badge: '40'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Binary Tree"
|
title: "Binary Tree"
|
||||||
list: "https://leetcode.com/problem-list/binary-tree/"
|
list: "https://leetcode.com/problem-list/binary-tree/"
|
||||||
|
icon: git-branch
|
||||||
|
sidebar:
|
||||||
|
badge: '9'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Directed Acyclic Graph"
|
title: "Directed Acyclic Graph"
|
||||||
list: "https://leetcode.com/problem-list/directed-acyclic-graph/"
|
list: "https://leetcode.com/problem-list/directed-acyclic-graph/"
|
||||||
|
icon: route
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Doubly-Linked List"
|
title: "Doubly-Linked List"
|
||||||
list: "https://leetcode.com/problem-list/doubly-linked-list/"
|
list: "https://leetcode.com/problem-list/doubly-linked-list/"
|
||||||
|
icon: link-2
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Graph Theory"
|
title: "Graph Theory"
|
||||||
list: "https://leetcode.com/problem-list/graph/"
|
list: "https://leetcode.com/problem-list/graph/"
|
||||||
|
icon: share-2
|
||||||
|
sidebar:
|
||||||
|
badge: '5'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "K-D Tree"
|
title: "K-D Tree"
|
||||||
list: "https://leetcode.com/problem-list/k-d-tree/"
|
list: "https://leetcode.com/problem-list/k-d-tree/"
|
||||||
|
icon: axis-3d
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({ title: "Data Structures", order: 1 });
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Queue"
|
title: "Queue"
|
||||||
list: "https://leetcode.com/problem-list/queue/"
|
list: "https://leetcode.com/problem-list/queue/"
|
||||||
|
icon: list-end
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "String"
|
title: "String"
|
||||||
list: "https://leetcode.com/problem-list/string/"
|
list: "https://leetcode.com/problem-list/string/"
|
||||||
|
icon: quote
|
||||||
|
sidebar:
|
||||||
|
badge: '16'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Tree"
|
title: "Tree"
|
||||||
list: "https://leetcode.com/problem-list/tree/"
|
list: "https://leetcode.com/problem-list/tree/"
|
||||||
|
icon: tree-deciduous
|
||||||
|
sidebar:
|
||||||
|
badge: '9'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Bidirectional Search"
|
title: "Bidirectional Search"
|
||||||
list: "https://leetcode.com/problem-list/bidirectional-search/"
|
list: "https://leetcode.com/problem-list/bidirectional-search/"
|
||||||
|
icon: move-horizontal
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Binary Lifting"
|
title: "Binary Lifting"
|
||||||
list: "https://leetcode.com/problem-list/binary-lifting/"
|
list: "https://leetcode.com/problem-list/binary-lifting/"
|
||||||
|
icon: chevrons-up
|
||||||
|
sidebar:
|
||||||
|
badge: '2'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Bit Manipulation"
|
title: "Bit Manipulation"
|
||||||
list: "https://leetcode.com/problem-list/bit-manipulation/"
|
list: "https://leetcode.com/problem-list/bit-manipulation/"
|
||||||
|
icon: binary
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Bracket Sequences"
|
title: "Bracket Sequences"
|
||||||
list: "https://leetcode.com/problem-list/bracket-sequences/"
|
list: "https://leetcode.com/problem-list/bracket-sequences/"
|
||||||
|
icon: parentheses
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Bucket Sort"
|
title: "Bucket Sort"
|
||||||
list: "https://leetcode.com/problem-list/bucket-sort/"
|
list: "https://leetcode.com/problem-list/bucket-sort/"
|
||||||
|
icon: container
|
||||||
|
sidebar:
|
||||||
|
badge: '2'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Complete Knapsack"
|
title: "Complete Knapsack"
|
||||||
list: "https://leetcode.com/problem-list/complete-knapsack/"
|
list: "https://leetcode.com/problem-list/complete-knapsack/"
|
||||||
|
icon: package-plus
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Divide and Conquer"
|
title: "Divide and Conquer"
|
||||||
list: "https://leetcode.com/problem-list/divide-and-conquer/"
|
list: "https://leetcode.com/problem-list/divide-and-conquer/"
|
||||||
|
icon: split
|
||||||
|
sidebar:
|
||||||
|
badge: '5'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Floyd's Cycle Finding Algorithm"
|
title: "Floyd's Cycle Finding Algorithm"
|
||||||
list: "https://leetcode.com/problem-list/floyds-cycle-finding-algorithm/"
|
list: "https://leetcode.com/problem-list/floyds-cycle-finding-algorithm/"
|
||||||
|
icon: repeat
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Knapsack Problem"
|
title: "Knapsack Problem"
|
||||||
list: "https://leetcode.com/problem-list/knapsack-problem/"
|
list: "https://leetcode.com/problem-list/knapsack-problem/"
|
||||||
|
icon: backpack
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Longest Common Subsequence"
|
title: "Longest Common Subsequence"
|
||||||
list: "https://leetcode.com/problem-list/longest-common-subsequence/"
|
list: "https://leetcode.com/problem-list/longest-common-subsequence/"
|
||||||
|
icon: diff
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Lowest Common Ancestor"
|
title: "Lowest Common Ancestor"
|
||||||
list: "https://leetcode.com/problem-list/lowest-common-ancestor/"
|
list: "https://leetcode.com/problem-list/lowest-common-ancestor/"
|
||||||
|
icon: git-merge
|
||||||
|
sidebar:
|
||||||
|
badge: '2'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Memoization"
|
title: "Memoization"
|
||||||
list: "https://leetcode.com/problem-list/memoization/"
|
list: "https://leetcode.com/problem-list/memoization/"
|
||||||
|
icon: database
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Merge Sort"
|
title: "Merge Sort"
|
||||||
list: "https://leetcode.com/problem-list/merge-sort/"
|
list: "https://leetcode.com/problem-list/merge-sort/"
|
||||||
|
icon: blend
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({ title: "Algorithms", order: 2 });
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Quickselect"
|
title: "Quickselect"
|
||||||
list: "https://leetcode.com/problem-list/quickselect/"
|
list: "https://leetcode.com/problem-list/quickselect/"
|
||||||
|
icon: crosshair
|
||||||
|
sidebar:
|
||||||
|
badge: '3'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Recursion"
|
title: "Recursion"
|
||||||
list: "https://leetcode.com/problem-list/recursion/"
|
list: "https://leetcode.com/problem-list/recursion/"
|
||||||
|
icon: iteration-ccw
|
||||||
|
sidebar:
|
||||||
|
badge: '3'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Simulation"
|
title: "Simulation"
|
||||||
list: "https://leetcode.com/problem-list/simulation/"
|
list: "https://leetcode.com/problem-list/simulation/"
|
||||||
|
icon: monitor-play
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Sorting"
|
title: "Sorting"
|
||||||
list: "https://leetcode.com/problem-list/sorting/"
|
list: "https://leetcode.com/problem-list/sorting/"
|
||||||
|
icon: arrow-down-wide-narrow
|
||||||
|
sidebar:
|
||||||
|
badge: '11'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Tournament Sort"
|
title: "Tournament Sort"
|
||||||
list: "https://leetcode.com/problem-list/tournament-sort/"
|
list: "https://leetcode.com/problem-list/tournament-sort/"
|
||||||
|
icon: trophy
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Combinatorics"
|
title: "Combinatorics"
|
||||||
list: "https://leetcode.com/problem-list/combinatorics/"
|
list: "https://leetcode.com/problem-list/combinatorics/"
|
||||||
|
icon: dices
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Counting"
|
title: "Counting"
|
||||||
list: "https://leetcode.com/problem-list/counting/"
|
list: "https://leetcode.com/problem-list/counting/"
|
||||||
|
icon: tally-5
|
||||||
|
sidebar:
|
||||||
|
badge: '3'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Design"
|
title: "Design"
|
||||||
list: "https://leetcode.com/problem-list/design/"
|
list: "https://leetcode.com/problem-list/design/"
|
||||||
|
icon: pencil-ruler
|
||||||
|
sidebar:
|
||||||
|
badge: '5'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
+3
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Geometry"
|
title: "Geometry"
|
||||||
list: "https://leetcode.com/problem-list/geometry/"
|
list: "https://leetcode.com/problem-list/geometry/"
|
||||||
|
icon: shapes
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: "Math"
|
title: "Math"
|
||||||
list: "https://leetcode.com/problem-list/math/"
|
list: "https://leetcode.com/problem-list/math/"
|
||||||
|
icon: calculator
|
||||||
|
sidebar:
|
||||||
|
badge: '6'
|
||||||
---
|
---
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({ title: "Techniques", order: 3 });
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
title: "Quicksort"
|
||||||
|
list: "https://leetcode.com/problem-list/quicksort/"
|
||||||
|
sidebar:
|
||||||
|
badge: '1'
|
||||||
|
---
|
||||||
|
|
||||||
|
:::note
|
||||||
|
This guide has not been written yet. The LeetCode list above has every problem tagged Quicksort.
|
||||||
|
:::
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
---
|
---
|
||||||
title: Progress
|
title: Progress
|
||||||
description: Live SRS campaign dashboard — phases, ladder, review queue, gates, and recent attempts, fetched from the SRS Worker.
|
description: Live campaign progress — solved by difficulty, recent attempts, the year by difficulty, topic coverage, and the most recently solved problems, fetched from the SRS Worker.
|
||||||
icon: loader
|
icon: loader
|
||||||
---
|
---
|
||||||
|
|
||||||
<ProgressDashboard />
|
<div className="not-prose flex flex-col gap-6">
|
||||||
|
<SolvedRing />
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<SubmissionHeatmap />
|
||||||
|
<SubmissionChart />
|
||||||
|
</div>
|
||||||
|
<TopicBubbles />
|
||||||
|
<RecentSolves />
|
||||||
|
</div>
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
/**
|
|
||||||
* Progress dashboard island — the expanded, always-current version of the
|
|
||||||
* daily email footer. Rendered on /progress; fetches campaign state from the
|
|
||||||
* SRS Worker at page load, entirely client-side, so `blume build` never
|
|
||||||
* depends on the API being up. API_BASE is a placeholder stamped in after the
|
|
||||||
* Worker deploys — do not inline it anywhere else.
|
|
||||||
*
|
|
||||||
* Invariants: the component never throws on malformed API data (a shape guard
|
|
||||||
* downgrades to the error state), and the first frame is always the loading
|
|
||||||
* state so server and client render identically.
|
|
||||||
*/
|
|
||||||
import { useEffect, useState, type CSSProperties } from "react";
|
|
||||||
|
|
||||||
export const client = "load";
|
|
||||||
|
|
||||||
const API_BASE = "https://srs-api.prdlk.workers.dev";
|
|
||||||
|
|
||||||
// ── /api/stats response shape ────────────────────────────────────
|
|
||||||
|
|
||||||
// Mirrors STAGES in apps/api/src/srs.ts — the ladder rungs, in order. The
|
|
||||||
// Worker ships every rung in `ladder`, zeros included.
|
|
||||||
const STAGES = ["new", "+3", "+7", "retired"] as const;
|
|
||||||
|
|
||||||
type Stage = (typeof STAGES)[number];
|
|
||||||
|
|
||||||
interface Stats {
|
|
||||||
generated: string;
|
|
||||||
campaign: { day: number; week: number; start: string; days: number };
|
|
||||||
phases: {
|
|
||||||
milestone: number;
|
|
||||||
name: string;
|
|
||||||
core_done: number;
|
|
||||||
core_total: number;
|
|
||||||
optional_done: number;
|
|
||||||
optional_total: number;
|
|
||||||
deferred_done: number;
|
|
||||||
deferred_total: number;
|
|
||||||
}[];
|
|
||||||
ladder: Record<Stage, number>;
|
|
||||||
/** week is the 1-based campaign week — the Worker converts from ISO. */
|
|
||||||
gates: {
|
|
||||||
week: number;
|
|
||||||
issue: number | null;
|
|
||||||
pass_rate: number | null;
|
|
||||||
closed_on: string | null;
|
|
||||||
}[];
|
|
||||||
streak: number;
|
|
||||||
queue: { date: string; due: number }[];
|
|
||||||
/** Every campaign day, zeros included; this page reads only the tail. */
|
|
||||||
heat: { date: string; attempts: number; passes: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStats(value: unknown): value is Stats {
|
|
||||||
if (typeof value !== "object" || value === null) return false;
|
|
||||||
const v = value as Record<string, unknown>;
|
|
||||||
return (
|
|
||||||
typeof v.campaign === "object" &&
|
|
||||||
v.campaign !== null &&
|
|
||||||
typeof v.ladder === "object" &&
|
|
||||||
v.ladder !== null &&
|
|
||||||
Array.isArray(v.phases) &&
|
|
||||||
Array.isArray(v.gates) &&
|
|
||||||
Array.isArray(v.queue) &&
|
|
||||||
Array.isArray(v.heat) &&
|
|
||||||
typeof v.streak === "number"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── formatting ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Legacy gates stored integer percent; the Worker may emit a 0–1 fraction. */
|
|
||||||
function percent(rate: number): string {
|
|
||||||
return `${Math.round(rate <= 1 ? rate * 100 : rate)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "2026-08-24" → "Mon 24" without timezone drift. */
|
|
||||||
function dayLabel(iso: string): string {
|
|
||||||
const d = new Date(`${iso}T00:00:00`);
|
|
||||||
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
|
|
||||||
return `${weekday} ${iso.slice(8)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── shared styles ────────────────────────────────────────────────
|
|
||||||
// Theme tokens only, with two deliberate exceptions: the phase-bar sets get
|
|
||||||
// fixed hues (accent-blue core, yellow optional, red deferred — they must be
|
|
||||||
// distinguishable, not shades of one colour), and the last-7-days heatmap
|
|
||||||
// reuses the GREENS ramp from apps/api/src/charts.ts so the page matches the
|
|
||||||
// README/email heatmaps (level 0 stays a theme token so light mode works).
|
|
||||||
|
|
||||||
const card: CSSProperties = {
|
|
||||||
border: "1px solid var(--blume-border)",
|
|
||||||
borderRadius: "var(--blume-radius)",
|
|
||||||
padding: "0.75rem 1rem",
|
|
||||||
marginBottom: "1rem",
|
|
||||||
};
|
|
||||||
|
|
||||||
const muted: CSSProperties = {
|
|
||||||
color: "var(--blume-muted-foreground)",
|
|
||||||
fontSize: "0.85em",
|
|
||||||
};
|
|
||||||
|
|
||||||
const heading: CSSProperties = {
|
|
||||||
fontWeight: 600,
|
|
||||||
marginBottom: "0.5rem",
|
|
||||||
};
|
|
||||||
|
|
||||||
function Bar({ done, total }: { done: number; total: number }) {
|
|
||||||
const ratio = total > 0 ? Math.min(done / total, 1) : 0;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
width: "10rem",
|
|
||||||
maxWidth: "40vw",
|
|
||||||
height: "0.5rem",
|
|
||||||
borderRadius: "var(--blume-radius)",
|
|
||||||
background: "var(--blume-muted)",
|
|
||||||
verticalAlign: "middle",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
width: `${ratio * 100}%`,
|
|
||||||
height: "100%",
|
|
||||||
background: "var(--blume-accent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── sections ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function Header({ stats }: { stats: Stats }) {
|
|
||||||
const { campaign, streak } = stats;
|
|
||||||
return (
|
|
||||||
<div style={{ ...card, display: "flex", gap: "1.5rem", flexWrap: "wrap" }}>
|
|
||||||
<span>
|
|
||||||
<strong>
|
|
||||||
Day {campaign.day}/{campaign.days}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
<span>Week {campaign.week}</span>
|
|
||||||
<span>
|
|
||||||
Streak: <strong>{streak}</strong> day{streak === 1 ? "" : "s"}
|
|
||||||
</span>
|
|
||||||
<span style={muted}>started {campaign.start}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// One stacked bar per phase: solved core / optional / deferred segments over
|
|
||||||
// a muted track sized by the phase's total problem count, so both phase size
|
|
||||||
// and progress read at a glance.
|
|
||||||
const SETS = [
|
|
||||||
["core", "var(--blume-accent)"],
|
|
||||||
["optional", "#d29922"],
|
|
||||||
["deferred", "#e5534b"],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function Phases({ stats }: { stats: Stats }) {
|
|
||||||
const maxTotal = Math.max(
|
|
||||||
1,
|
|
||||||
...stats.phases.map((p) => p.core_total + p.optional_total + p.deferred_total),
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div style={card}>
|
|
||||||
<div style={heading}>Phases</div>
|
|
||||||
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap", marginBottom: "0.6rem" }}>
|
|
||||||
{SETS.map(([set, color]) => (
|
|
||||||
<span key={set} style={muted}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
width: "0.65rem",
|
|
||||||
height: "0.65rem",
|
|
||||||
borderRadius: "2px",
|
|
||||||
background: color,
|
|
||||||
marginRight: "0.35rem",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{set}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{stats.phases.map((p) => {
|
|
||||||
const total = p.core_total + p.optional_total + p.deferred_total;
|
|
||||||
const done = p.core_done + p.optional_done + p.deferred_done;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={p.milestone}
|
|
||||||
title={`core ${p.core_done}/${p.core_total} · optional ${p.optional_done}/${p.optional_total} · deferred ${p.deferred_done}/${p.deferred_total}`}
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: "0.75rem", padding: "0.25rem 0" }}
|
|
||||||
>
|
|
||||||
<span style={{ width: "9.5rem", flexShrink: 0 }}>{p.name}</span>
|
|
||||||
<span style={{ flex: 1 }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
width: `${(total / maxTotal) * 100}%`,
|
|
||||||
height: "0.65rem",
|
|
||||||
borderRadius: "var(--blume-radius)",
|
|
||||||
background: "var(--blume-muted)",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{SETS.map(([set, color]) => {
|
|
||||||
const solved = p[`${set}_done`];
|
|
||||||
return solved > 0 ? (
|
|
||||||
<span
|
|
||||||
key={set}
|
|
||||||
style={{ width: `${(solved / total) * 100}%`, background: color }}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span style={{ ...muted, width: "3.5rem", textAlign: "right", flexShrink: 0 }}>
|
|
||||||
{done}/{total}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Ladder({ stats }: { stats: Stats }) {
|
|
||||||
return (
|
|
||||||
<div style={card}>
|
|
||||||
<div style={heading}>SRS ladder</div>
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
|
||||||
{STAGES.map((stage) => (
|
|
||||||
<span
|
|
||||||
key={stage}
|
|
||||||
style={{
|
|
||||||
background: "var(--blume-muted)",
|
|
||||||
borderRadius: "var(--blume-radius)",
|
|
||||||
padding: "0.35rem 0.75rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={muted}>{stage}</span>{" "}
|
|
||||||
<strong>{stats.ladder[stage] ?? 0}</strong>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Queue({ stats }: { stats: Stats }) {
|
|
||||||
const max = Math.max(1, ...stats.queue.map((q) => q.due));
|
|
||||||
return (
|
|
||||||
<div style={card}>
|
|
||||||
<div style={heading}>Review queue — next 14 days</div>
|
|
||||||
<table style={{ borderCollapse: "collapse" }}>
|
|
||||||
<tbody>
|
|
||||||
{stats.queue.map((q) => (
|
|
||||||
<tr key={q.date}>
|
|
||||||
<td style={{ ...muted, padding: "0.1rem 1rem 0.1rem 0" }}>
|
|
||||||
{dayLabel(q.date)}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: "0.1rem 0.75rem 0.1rem 0" }}>
|
|
||||||
<Bar done={q.due} total={max} />
|
|
||||||
</td>
|
|
||||||
<td>{q.due}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Gates({ stats }: { stats: Stats }) {
|
|
||||||
return (
|
|
||||||
<div style={card}>
|
|
||||||
<div style={heading}>Gate log</div>
|
|
||||||
{stats.gates.length === 0 ? (
|
|
||||||
<span style={muted}>No gates yet — the first weekly review opens Saturday night.</span>
|
|
||||||
) : (
|
|
||||||
stats.gates.map((g) => (
|
|
||||||
<div
|
|
||||||
key={g.week}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: "0.75rem",
|
|
||||||
alignItems: "baseline",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
padding: "0.15rem 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ width: "4.5rem", flexShrink: 0 }}>Week {g.week}</span>
|
|
||||||
{g.issue === null ? (
|
|
||||||
<span style={muted}>no review issue</span>
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
href={`https://github.com/prdlk/leetcode/issues/${g.issue}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
#{g.issue}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{g.pass_rate === null ? (
|
|
||||||
<span style={muted}>in progress</span>
|
|
||||||
) : (
|
|
||||||
<span>
|
|
||||||
{percent(g.pass_rate)} passed
|
|
||||||
{g.closed_on && <span style={muted}> · closed {g.closed_on}</span>}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
<div style={{ ...muted, marginTop: "0.4rem" }}>
|
|
||||||
One gate per campaign week — scored when its review issue closes.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// GREENS ramp from apps/api/src/charts.ts; level 0 swapped for the theme's
|
|
||||||
// muted token so an empty day looks right in both colour schemes.
|
|
||||||
const GREENS = [
|
|
||||||
"var(--blume-muted)",
|
|
||||||
"#0e4429",
|
|
||||||
"#006d32",
|
|
||||||
"#26a641",
|
|
||||||
"#39d353",
|
|
||||||
];
|
|
||||||
|
|
||||||
function Recent({ stats }: { stats: Stats }) {
|
|
||||||
// heat spans the whole campaign, future days and all. The window wanted here
|
|
||||||
// is the seven days ending today, and campaign.day is that day, 1-based.
|
|
||||||
const week = stats.heat.slice(Math.max(0, stats.campaign.day - 7), stats.campaign.day);
|
|
||||||
return (
|
|
||||||
<div style={card}>
|
|
||||||
<div style={heading}>Last 7 days</div>
|
|
||||||
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
|
|
||||||
{week.map((r) => (
|
|
||||||
<span
|
|
||||||
key={r.date}
|
|
||||||
title={`${r.date}: ${r.passes}/${r.attempts} passed`}
|
|
||||||
style={{ textAlign: "center" }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
width: "2.6rem",
|
|
||||||
height: "2rem",
|
|
||||||
borderRadius: "var(--blume-radius)",
|
|
||||||
background: GREENS[Math.min(r.attempts, 4)],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span style={{ ...muted, display: "block", marginTop: "0.25rem", fontSize: "0.75em" }}>
|
|
||||||
{dayLabel(r.date)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ ...muted, marginTop: "0.5rem", display: "flex", alignItems: "center", gap: "0.25rem" }}>
|
|
||||||
<span>Less</span>
|
|
||||||
{GREENS.map((color) => (
|
|
||||||
<span
|
|
||||||
key={color}
|
|
||||||
style={{ display: "inline-block", width: "0.65rem", height: "0.65rem", borderRadius: "2px", background: color }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<span>More · colour = attempts, hover for passes</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── dashboard ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type Load =
|
|
||||||
| { phase: "loading" }
|
|
||||||
| { phase: "error" }
|
|
||||||
| { phase: "ready"; stats: Stats };
|
|
||||||
|
|
||||||
export default function ProgressDashboard() {
|
|
||||||
const [load, setLoad] = useState<Load>({ phase: "loading" });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
fetch(`${API_BASE}/api/stats`, { signal: controller.signal })
|
|
||||||
.then((res) => {
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then((body: unknown) => {
|
|
||||||
setLoad(isStats(body) ? { phase: "ready", stats: body } : { phase: "error" });
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!controller.signal.aborted) setLoad({ phase: "error" });
|
|
||||||
});
|
|
||||||
return () => controller.abort();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (load.phase === "loading") {
|
|
||||||
return <p style={muted}>Loading progress…</p>;
|
|
||||||
}
|
|
||||||
if (load.phase === "error") {
|
|
||||||
return (
|
|
||||||
<p style={muted}>
|
|
||||||
API unreachable — stats are served live from the SRS Worker and it did
|
|
||||||
not respond.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { stats } = load;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Header stats={stats} />
|
|
||||||
<Phases stats={stats} />
|
|
||||||
<Ladder stats={stats} />
|
|
||||||
<Queue stats={stats} />
|
|
||||||
<Gates stats={stats} />
|
|
||||||
<Recent stats={stats} />
|
|
||||||
<p style={muted}>Generated {stats.generated}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
let cursor = START;
|
||||||
|
const segments = rows.map((r) => {
|
||||||
|
const size = total > 0 ? (span * r.total) / total : 0;
|
||||||
|
const from = cursor;
|
||||||
|
cursor -= size + GAP;
|
||||||
|
const solved = r.total > 0 ? size * (r.done / r.total) : 0;
|
||||||
|
return { ...r, from, to: from - size, solvedTo: from - solved };
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* 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 hover = 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={hover.ref} className="relative w-full" onPointerLeave={hover.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) =>
|
||||||
|
hover.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>
|
||||||
|
{hover.node}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* 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 hover = 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={hover.ref} className="relative mx-auto w-full max-w-xl" onPointerLeave={hover.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) =>
|
||||||
|
hover.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>
|
||||||
|
{hover.node}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
// 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* 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(() => {
|
||||||
|
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!));
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* 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] };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
+12
-3
@@ -8,12 +8,21 @@
|
|||||||
"doctor": "blume doctor"
|
"doctor": "blume doctor"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"blume": "^2.0.1",
|
"blume": "^2.1.0",
|
||||||
|
"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": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8",
|
||||||
|
"recharts": "3.8.0",
|
||||||
|
"tailwind-merge": "^3.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
"@fontsource/ibm-plex-mono": "^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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 9.6 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
<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 < 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>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -15,7 +15,11 @@ export default [
|
|||||||
{ from: "/46", to: "/backtracking/permutations" },
|
{ from: "/46", to: "/backtracking/permutations" },
|
||||||
{ from: "/48", to: "/matrix-index-math/rotate-image" },
|
{ from: "/48", to: "/matrix-index-math/rotate-image" },
|
||||||
{ from: "/49", to: "/hash-table/group-anagrams" },
|
{ from: "/49", to: "/hash-table/group-anagrams" },
|
||||||
|
{ from: "/53", to: "/greedy/maximum-subarray" },
|
||||||
{ from: "/54", to: "/matrix-index-math/spiral-matrix" },
|
{ 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: "/62", to: "/multi-d-grid-dp/unique-paths" },
|
||||||
{ from: "/64", to: "/multi-d-grid-dp/minimum-path-sum" },
|
{ from: "/64", to: "/multi-d-grid-dp/minimum-path-sum" },
|
||||||
{ from: "/70", to: "/1d-dynamic-programming/climbing-stairs" },
|
{ from: "/70", to: "/1d-dynamic-programming/climbing-stairs" },
|
||||||
@@ -29,6 +33,7 @@ export default [
|
|||||||
{ from: "/125", to: "/two-pointers/valid-palindrome" },
|
{ from: "/125", to: "/two-pointers/valid-palindrome" },
|
||||||
{ from: "/127", to: "/graph-bfs/word-ladder" },
|
{ from: "/127", to: "/graph-bfs/word-ladder" },
|
||||||
{ from: "/133", to: "/graph-dfs/clone-graph" },
|
{ from: "/133", to: "/graph-dfs/clone-graph" },
|
||||||
|
{ from: "/134", to: "/greedy/gas-station" },
|
||||||
{ from: "/141", to: "/linked-list/linked-list-cycle" },
|
{ from: "/141", to: "/linked-list/linked-list-cycle" },
|
||||||
{ from: "/146", to: "/hybrid-structures/lru-cache" },
|
{ from: "/146", to: "/hybrid-structures/lru-cache" },
|
||||||
{ from: "/150", to: "/stack/evaluate-reverse-polish-notation" },
|
{ from: "/150", to: "/stack/evaluate-reverse-polish-notation" },
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* LeetCode topic-tag slug → the campaign landing routes that teach it, in
|
* LeetCode topic-tag slug → the campaign landing routes that teach it, in
|
||||||
* default preference order. The one answer to "does the campaign cover this
|
* default preference order. The one answer to "does the campaign cover this
|
||||||
* tag", read by two sides that must agree:
|
* tag", read by the sides that must agree:
|
||||||
*
|
*
|
||||||
* - `components/ProblemBadges.astro` links a covered tag's badge to its
|
* - `components/ProblemBadges.astro` links a covered tag's badge to its
|
||||||
* landing page, and every other tag's badge to `/guides/<slug>`;
|
* landing page, and every other tag's badge to `/guides/<slug>`;
|
||||||
|
* - `components/BacklinksList.astro` lists, under a landing or guide page,
|
||||||
|
* every problem whose badge links there — both via `tagTarget()`;
|
||||||
* - `apps/cli/sync.ts` creates a placeholder `content/guides/<slug>.mdx` for
|
* - `apps/cli/sync.ts` creates a placeholder `content/guides/<slug>.mdx` for
|
||||||
* every tag a problem page carries that is NOT a key here.
|
* every tag a problem page carries that is NOT a key here.
|
||||||
*
|
*
|
||||||
@@ -33,3 +35,33 @@ export const TOPIC_LANDINGS: Record<string, readonly string[]> = {
|
|||||||
"dynamic-programming": ["/1d-dynamic-programming", "/multi-d-grid-dp"],
|
"dynamic-programming": ["/1d-dynamic-programming", "/multi-d-grid-dp"],
|
||||||
greedy: ["/greedy"],
|
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 or
|
||||||
|
* guide route (the caller adds the base), or LeetCode's problem list when the
|
||||||
|
* site serves 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" | "guide" | "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" };
|
||||||
|
const guide = `/guides/${tag.slug}`;
|
||||||
|
if (served.has(guide)) return { href: guide, kind: "guide" };
|
||||||
|
return { href: `https://leetcode.com/problem-list/${tag.slug}/`, kind: "external" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"paths": { "@/*": ["./*"] }
|
||||||
|
},
|
||||||
|
"include": ["islands", "components/ui", "lib", "topic-landings.ts", "redirects.ts"]
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.DS_Store
|
|
||||||
.env
|
|
||||||
|
|
||||||
# Ignore the output video from Git but not videos you import into src/.
|
|
||||||
out
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"useTabs": false,
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"tabWidth": 2
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Remotion video
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://github.com/remotion-dev/logo">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/remotion-dev/logo/raw/main/animated-logo-banner-dark.apng">
|
|
||||||
<img alt="Animated Remotion Logo" src="https://github.com/remotion-dev/logo/raw/main/animated-logo-banner-light.gif">
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
Welcome to your Remotion project!
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
**Install Dependencies**
|
|
||||||
|
|
||||||
```console
|
|
||||||
npm i
|
|
||||||
```
|
|
||||||
|
|
||||||
**Start Preview**
|
|
||||||
|
|
||||||
```console
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
**Render video**
|
|
||||||
|
|
||||||
```console
|
|
||||||
npx remotion render
|
|
||||||
```
|
|
||||||
|
|
||||||
**Upgrade Remotion**
|
|
||||||
|
|
||||||
```console
|
|
||||||
npx remotion upgrade
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docs
|
|
||||||
|
|
||||||
Get started with Remotion by reading the [fundamentals page](https://www.remotion.dev/docs/the-fundamentals).
|
|
||||||
|
|
||||||
## Help
|
|
||||||
|
|
||||||
We provide help on our [Discord server](https://discord.gg/6VzzNDwUwV).
|
|
||||||
|
|
||||||
## Issues
|
|
||||||
|
|
||||||
Found an issue with Remotion? [File an issue here](https://github.com/remotion-dev/remotion/issues/new).
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Note that for some entities a company license is needed. [Read the terms here](https://github.com/remotion-dev/remotion/blob/main/LICENSE.md).
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { config } from "@remotion/eslint-config-flat";
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user