mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2835f618aa | ||
|
|
7249da8bd6 | ||
|
|
83cb284e87 | ||
|
|
f33ded37ec | ||
|
|
ddf5dc483c | ||
|
|
e3d915513d | ||
|
|
f6c580b14b | ||
|
|
c753937852 | ||
|
|
8e07899157 | ||
|
|
886024c4f0 | ||
|
|
37a2d39269 | ||
|
|
a7ab989d57 | ||
|
|
0ccd9794f0 | ||
|
|
b922205bd0 | ||
|
|
def494d524 | ||
|
|
0a4b8d8b49 | ||
|
|
f1a952f2bd | ||
|
|
ea44a99c40 | ||
|
|
00fa322a14 | ||
|
|
bf56e170a4 | ||
|
|
4cf976f683 | ||
|
|
ace1944786 | ||
|
|
13734e2e27 | ||
|
|
cb4a03b6fa | ||
|
|
e14432d32a | ||
|
|
8843cb776c | ||
|
|
0e58b63d4d | ||
|
|
b5c3f1a159 | ||
|
|
37d3d9ceaf | ||
|
|
5627d0ca64 | ||
|
|
598688977a | ||
|
|
a3e846c606 | ||
|
|
52d9654ade | ||
|
|
f202c3a0a1 | ||
|
|
030887c7df | ||
|
|
733c71d364 | ||
|
|
115b901fbe | ||
|
|
ceaa9a2e75 | ||
|
|
7c7019ec0c | ||
|
|
cd5f416939 | ||
|
|
6247915ae5 | ||
|
|
31a1fcfdbd |
No files matched your search
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: problem-approach
|
name: problem-approach
|
||||||
description: Write the `## Approach` section — a Mermaid flowchart that traces the submitted solution — for every docs solution page under docs/ that lacks one, using the repo's exact placement and node conventions. Use when asked to add/implement/fill in missing approaches, draw the approach diagram for a problem page, or when `/problem-approach` is run.
|
description: Write the `## Approach` section — a Mermaid flowchart that traces the submitted solution — for every docs solution page under apps/docs/content/ that lacks one, using the repo's exact placement and node conventions. Use when asked to add/implement/fill in missing approaches, draw the approach diagram for a problem page, or when `/problem-approach` is run.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Problem approach diagrams
|
# Problem approach diagrams
|
||||||
|
|
||||||
Every solution page (`docs/(<phase>)/<topic>/(problems)/<num>_<slug>.mdx`) has a
|
Every solution page (`apps/docs/content/(<phase>)/<topic>/(problems)/<num>_<slug>.mdx`) has a
|
||||||
hand-owned section before the script-owned one:
|
hand-owned section before the script-owned one:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -23,7 +23,7 @@ next `work/` push.
|
|||||||
1. **Find the pages** (numbered pages with no `## Approach` heading):
|
1. **Find the pages** (numbered pages with no `## Approach` heading):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
grep -rL '^## Approach' docs --include='[0-9]*_*.mdx'
|
grep -L '^## Approach' apps/docs/content/*/*.mdx
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **For each page**, read `## Solution` and write the diagram from *that code* — the
|
2. **For each page**, read `## Solution` and write the diagram from *that code* — the
|
||||||
@@ -47,13 +47,11 @@ next `work/` push.
|
|||||||
````
|
````
|
||||||
|
|
||||||
4. **Check** each diagram by hand against the syntax rules below (the docs build does
|
4. **Check** each diagram by hand against the syntax rules below (the docs build does
|
||||||
not parse Mermaid — a broken diagram fails only in the browser). The site is built
|
not parse Mermaid — a broken diagram fails only in the browser), then run the
|
||||||
from prdlk/cs, which reads `docs/` over HTTP, so preview unpushed pages there
|
docs build once at the end and review the pages in `bun run dev`:
|
||||||
against this checkout (its `README` names the local-content switch), then build
|
|
||||||
once at the end:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd ../cs && bunx blume build --isolated && rm -rf .blume-verify
|
cd apps/docs && bunx blume build --isolated && rm -rf .blume-verify
|
||||||
```
|
```
|
||||||
|
|
||||||
Pages with two languages (`<CodeGroup>`) get **one** diagram, traced from the
|
Pages with two languages (`<CodeGroup>`) get **one** diagram, traced from the
|
||||||
@@ -61,7 +59,7 @@ Python version. Pages with only JavaScript trace the JavaScript.
|
|||||||
|
|
||||||
## Diagram conventions
|
## Diagram conventions
|
||||||
|
|
||||||
Reference page: `docs/(01-linear-structures)/two-pointers/(problems)/15_3sum.mdx`. Every existing diagram
|
Reference page: `apps/docs/content/(array)/15-3sum.mdx`. Every existing diagram
|
||||||
follows this shape:
|
follows this shape:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|||||||
@@ -5,19 +5,53 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# The Worker only. The docs site is built and published from prdlk/cs, which
|
|
||||||
# reads docs/ from this repo over HTTP (Blume's mdxRemote()), so this repo has
|
|
||||||
# no Pages build.
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
# The Worker deploy is idempotent (wrangler uploads the whole bundle), so on a
|
# The Worker deploy is idempotent (wrangler uploads the whole bundle), so on a
|
||||||
# burst of pushes only the last one matters: cancel-in-progress is correct.
|
# burst of pushes only the last one matters: cancel-in-progress is correct for
|
||||||
|
# both the Pages build and the Worker job.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy
|
group: deploy
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22 # Blume requires 22.12+
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2 # skip if you use npm/pnpm
|
||||||
|
|
||||||
|
- run: bun install --frozen-lockfile
|
||||||
|
# pages/changelog.astro reads this repo's GitHub releases at build time
|
||||||
|
# (apps/docs/lib/releases.ts). The token authenticates that fetch —
|
||||||
|
# required if the repo is private, and it lifts the shared runner's
|
||||||
|
# unauthenticated API rate limit. `contents: read` covers it.
|
||||||
|
- run: bun run build
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: apps/docs/dist
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
|
|
||||||
# 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),
|
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit),
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
# Finder (MacOS) folder config
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
|
.blume/
|
||||||
|
/apps/docs/docs/.wrangler/state/v3
|
||||||
|
.blume-verify/
|
||||||
|
|
||||||
# api worker
|
# api worker
|
||||||
apps/api/.dev.vars
|
apps/api/.dev.vars
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
LeetCode interview-prep campaign (2026-08-17 → Nov 3: eight weeks of topics, then the Review-issue phase; the end is the last date in `apps/api/data/schedule.json` — `CAMPAIGN_END`/`CAMPAIGN_DAYS` in `apps/api/src/srs.ts` are derived from it, never declared) run as a repo: solution files under `work/`, the docs content under `docs/` (MDX only — the Blume site that publishes it lives in [prdlk/cs](https://github.com/prdlk/cs) and reads `docs/` over HTTP), GitHub Actions that reconcile issues, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
|
LeetCode interview-prep campaign (2026-08-17 → Dec 5: eight weeks of topics, the Review-issue phase, a two-week break, then a 30-day interview sprint from Fri Nov 6 (`SPRINT_START`); the end is the last date in `apps/api/data/schedule.json` — `CAMPAIGN_END`/`CAMPAIGN_DAYS`/`SPRINT_DAYS` in `apps/api/src/srs.ts` are derived from it, never declared) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
|
||||||
|
|
||||||
## Architecture & Data Flow
|
## Architecture & Data Flow
|
||||||
|
|
||||||
@@ -10,8 +10,7 @@ Three owners, one direction of truth:
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
work[work/ solutions] -->|bun run sync| docs[docs/*.mdx + docs/site.json]
|
work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
|
||||||
docs -->|mdxRemote + site.json fetch| Site[prdlk/cs docs site]
|
|
||||||
work -->|close-solved.yml| PI[problem issues]
|
work -->|close-solved.yml| PI[problem issues]
|
||||||
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
|
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
|
||||||
PI -->|close-topics.yml| TI[topic + Review N issues]
|
PI -->|close-topics.yml| TI[topic + Review N issues]
|
||||||
@@ -39,9 +38,9 @@ flowchart LR
|
|||||||
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
|
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
|
||||||
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
|
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
|
||||||
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
||||||
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first, then the problems closed on the finish day / earlier, with solution links at the tag — problems still open at the finish are left out). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs site's `/changelog` (prdlk/cs `pages/changelog.astro` + `lib/releases.ts`) fetches this repo's releases at its build, dates each by its timever tag, not `published_at` (which the API cannot backdate), tags it `Topic` or `Review` from the release name, and lists the rows of its `### Closed …` tables — so the tag format and that table shape are its cross-repo contract with `render()`.
|
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first, then the problems closed on the finish day / earlier, with solution links at the tag — problems still open at the finish are left out). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs `/changelog` is ONE custom page, `apps/docs/pages/changelog.astro` (a NeonUI-style timeline: date left, a card per release right, no per-release subpages, so nothing of it enters the sidebar or prev/next), linked from the header by a `navigation.tabs` entry (beside `/appendix`'s). `apps/docs/lib/releases.ts` fetches the releases at build (`GITHUB_TOKEN` in `deploy.yml`; a failed fetch fails the build), dates each by its timever tag, not `published_at` (which the API cannot backdate), tags it `Topic` or `Review` from the release name, and lists the rows of its `### Closed …` tables — so the tag format and that table shape are the page's contract with `render()`. There is no changelog RSS feed.
|
||||||
- **Review issues re-parent problems; topics still own them.** The transfer phase's 20 `Review <N> — <Phase>` issues (label `review`, milestone `Review — Transfer & Mocks`; not the Worker's `Review — Week N` gate issues) hold open optional/deferred problems as sub-issues in solve order, moved there with `replace_parent`. `apps/api/src/reviews.ts` is the ONE recovery of ownership — `readReviews()` (one GraphQL request per 50 `review` issues, cheap enough for the Worker's cron subrequest budget) reads each moved problem's topic off its last `parent_issue_removed` event naming a topic, `movedFromTopics()` groups them — shared by the Worker's catalog reconcile (a moved problem's D1 row keeps syncing its labels and `topic_issue`) and `apps/cli/topics.ts` (`readProblems()` returns a topic's sub-issues plus its moved problems, so the finished rule and existing release bodies do not change when a problem moves). A Review issue finishes by `completion()` in `topics.ts` — every sub-issue closed, any set — which `close-topics.ts` closes it on and `release-topics.ts` releases it on, exactly like a topic: timever of the last close, same `<!-- release-topics: #N -->` marker (issue numbers never collide), body fields from the review's `**Scheduled:**`/`**Phase:**`/`**Load:**` lines.
|
- **Review issues re-parent problems; topics still own them.** The transfer phase's 20 `Review <N> — <Phase>` issues (label `review`, milestone `Review — Transfer & Mocks`; not the Worker's `Review — Week N` gate issues) hold open optional/deferred problems as sub-issues in solve order, moved there with `replace_parent`. `apps/api/src/reviews.ts` is the ONE recovery of ownership — `readReviews()` (one GraphQL request per 50 `review` issues, cheap enough for the Worker's cron subrequest budget) reads each moved problem's topic off its last `parent_issue_removed` event naming a topic, `movedFromTopics()` groups them — shared by the Worker's catalog reconcile (a moved problem's D1 row keeps syncing its labels and `topic_issue`) and `apps/cli/topics.ts` (`readProblems()` returns a topic's sub-issues plus its moved problems, so the finished rule and existing release bodies do not change when a problem moves). A Review issue finishes by `completion()` in `topics.ts` — every sub-issue closed, any set — which `close-topics.ts` closes it on and `release-topics.ts` releases it on, exactly like a topic: timever of the last close, same `<!-- release-topics: #N -->` marker (issue numbers never collide), body fields from the review's `**Scheduled:**`/`**Phase:**`/`**Load:**` lines.
|
||||||
- **Review days are plan days.** `schedule.json` books each Review issue on its own date — Mon/Tue/Thu/Fri only, Oct 1 → Nov 3 — as `review: { issue, title, problems }` (solve order). The digest renders it as a linked "Today's review" section with the issue's day rules (`REVIEW_RULES` in `email.tsx`, one sentence for HTML and text), its problems join `RESERVED` so drills and sampled gates never draw them early, and `gate: []` skips a Saturday's checkpoint (Oct 3). The issue's sub-issues stay the truth: the catalog reconcile compares every booked review's title and problem list with GitHub and returns any mismatch as `reviewDrift` (also logged), so a re-arranged Review issue shows up the next sync instead of silently disagreeing with the email. Move a review by editing its `schedule.json` date AND the issue's `**Scheduled:**` line (the release body reads it).
|
- **Review days are plan days.** `schedule.json` books each Review issue on its own date — Mon/Tue/Thu/Fri only; Reviews 1–6 ran Oct 1–9, Review 4 closed out Nov 2 after the break, and Reviews 7–20 run through the sprint (Nov 6 → Dec 1, Thanksgiving left unbooked), followed by two sweep days and a `gate: []` Saturday that marks the last day — as `review: { issue, title, problems }` (solve order). The digest renders it as a linked "Today's review" section with the issue's day rules (`REVIEW_RULES` in `email.tsx`, one sentence for HTML and text), its problems join `RESERVED` so drills and sampled gates never draw them early, and `gate: []` skips a Saturday's checkpoint (Oct 3, Dec 5). The issue's sub-issues stay the truth: the catalog reconcile compares every booked review's title and problem list with GitHub and returns any mismatch as `reviewDrift` (also logged), so a re-arranged Review issue shows up the next sync instead of silently disagreeing with the email. Move a review by editing its `schedule.json` date AND the issue's `**Scheduled:**` line (the release body reads it).
|
||||||
|
|
||||||
## Key Directories
|
## Key Directories
|
||||||
|
|
||||||
@@ -49,10 +48,10 @@ flowchart LR
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
||||||
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
|
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
|
||||||
| `docs/` | The docs content, and nothing of the site: no `blume.config.ts`, no `meta.ts`, no components. prdlk/cs publishes it with Blume's `mdxRemote()` source, which fetches `.md`/`.mdx` files only. `docs/(0N-<phase>)/<topic>/(problems)/<num>_<slug>.mdx` is generated by sync; `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is a topic's hand-written landing page, and `index.mdx`/`keywords.mdx`/`progress.mdx` sit at the root. The phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment, and `(problems)` is a second `(group)` inside it, so `tree-dfs/(problems)/226_invert-binary-tree.mdx` routes to `/tree-dfs/invert-binary-tree` (Blume sorts by the `<num>_` prefix and strips it from the route). `docs/site.json` is sync's generated manifest and the site's ONE other input: `folders` (path under `docs/` → `{ title, order, icon?, display? }`, exactly what a `meta.ts` in that folder would say — every topic `display: "page"`, so it is one sidebar row whose panel holds its landing page and a "Problems" section) and `redirects` (the `/<num>` short links). Images are root-absolute (``, `<CarouselImage src='/binary_search.001.png' />`) and served from the site's `public/` — a page-relative path cannot resolve once the MDX is fetched over HTTP. Frontmatter keys beyond Blume's (`topics:`, `video:`, `issue:`) and the MDX components pages use (`<CodeGroup>`, `<Carousel>`/`<CarouselImage>`, the islands) are the site's to declare — add a new one there before a page uses it |
|
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `pages/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/(problems)/<num>_<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync (a phase's once, then hand-owned): the phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment, and `(problems)` is a second `(group)` inside it, so `tree-dfs/(problems)/226_invert-binary-tree.mdx` routes to `/tree-dfs/invert-binary-tree` (Blume sorts by the `<num>_` prefix and strips it from the route). The `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order; every topic meta is `display: "page"`, so a topic is one sidebar row whose panel holds its landing page and a "Problems" section. A problem page's badge row (between the description and the body) is rendered, not written: the MDX `wrapper` (`components/ProblemBadges.astro`, registered as `mdx.wrapper` in `components.ts` — Blume's page template renders the title and description itself with no slot before the body, so the wrapper, which MDX hands no route, reads it off `Astro.url`) reads the page's `topics:` (LeetCode's topic tags, from sync) and `video:` (NeetCode YouTube id, from `bun run videos`) frontmatter — both declared via `frontmatter.extend` in `blume.config.ts`, hand-rolled Standard Schemas, no zod — and links a tag the campaign teaches to its topic landing page (filled), any other tag to its card on the Appendix glossary `/appendix#<slug>` (outlined; LeetCode's problem list if the tag has no term, gray and iconless), and the video as a red `Explanation` badge. Every badge in the row is a pill. A tag badge carries the icon its target shows (a landing's: the topic `meta.ts` icon read off `data.navigation`; an Appendix term's: its `icon` in `appendix.ts`) and the tag's own colour from `lib/tag-colors.ts` — unique per tag site-wide, assigned greedily over every tag with a landing or a term so tags that share problem pages sit furthest apart in OKLab, in a lightness band that keeps ≥ 3:1 contrast in both themes (Blume's Badge takes one hex for both). The reverse direction is the `PageFooter` slot (`components/BacklinksList.astro`): an Obsidian-style `<num> <name>` list under every topic landing page of the problems whose badges link there, plus a topic's own folder members (so a topic no tag names, `hybrid-structures`, still lists its problems), in a native `<details>` open on load whose top-right chevron collapses it (no JS); problem pages are recognised by their `<num>. <name>` title and render no list. The sidebar is Blume's default nav tree (no `Sidebar` slot override). The custom pages `/progress`, `/changelog` and `/appendix` are header `navigation.tabs` (on mobile, Blume lists them at the top of the drawer); never `navigation.featured`, which Blume renders above the nav tree. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), and where a badge points is ONE function beside it, `tagTarget()` — read by both components and the Appendix page (so badges, backlinks and term cards cannot disagree) and the map by sync. Every other tag a problem carries is ONE hand-written table, `apps/docs/appendix.ts` (`APPENDIX`: slug → `{ name, icon, category, definition }`), rendered by ONE custom page, `pages/appendix.astro` — a glossary with an A–Z letter index and a filter box (an `<appendix-filter>` custom element over static markup; no JS, the full list), one card per term (icon tinted the tag's colour, category, definition, LeetCode list link, and a `<details>` "Solved" list of the problem pages whose badge `tagTarget()` sends there, paged `PAGE_SIZE` (5) rows at a time by numbered buttons under it — a `<solved-pages>` custom element; without JS every row shows and the buttons stay hidden), each card `id`'d by slug and highlighted as `:target`. It is no content folder, so it enters neither the sidebar tree nor prev/next: its header tab is its link. Sync writes nothing for it — it warns about a carried tag with no term, a term whose tag a topic now teaches (delete it), and a term `icon` that is a `TOPIC_ICONS` value or another term's. The former per-term pages `/appendix/<slug>` are exact redirects to `/appendix#<slug>` in `blume.config.ts`, generated from `APPENDIX`'s keys. A hand-written `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is that topic's landing page: Blume ≥ 1.7.3 links the sidebar group row to it (`sliding-window/index.mdx` → `/sliding-window`) and the hidden flag drops the duplicate row. Two kinds of custom MDX component, both import-free on every page: `islands/<Name>.tsx` is hydrated React (auto-discovered; `DependencySpine` — the README topic map as data, zoom/pan, nodes link to whatever route Blume's `/api/docs/navigaLine truncated
|
||||||
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | review | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
|
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | review | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
|
||||||
| `.github/workflows/` | deploy (Worker → Cloudflare with the `OPENROUTER_API_KEY` Actions secret put into it first, on every main push; the docs site deploys from prdlk/cs), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
|
| `.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/` | `bun install` points `core.hooksPath` here (root `prepare` script, no husky). Its `pre-commit` hook (run `bun run sync`, stage only what sync changed — pages and `docs/site.json`) was removed in `e63d517`, so nothing runs on commit today: run `bun run sync` yourself before committing a solution, or its page and short link ship a commit later |
|
| `.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
|
||||||
|
|
||||||
@@ -69,14 +68,20 @@ bun run stats # terminal dashboard over GET /api/stats — four panes
|
|||||||
# r refetches, q quits. Outside a TTY it prints every pane once.
|
# r refetches, q quits. Outside a TTY it prints every pane once.
|
||||||
# SRS_API points it at a local Worker like close-solved does.
|
# SRS_API points it at a local Worker like close-solved does.
|
||||||
bun run test # run ONE solution against LeetCode's judge (not a test suite;
|
bun run test # run ONE solution against LeetCode's judge (not a test suite;
|
||||||
# most recently tested first, stamped in the db on exit 0)
|
# files git sees as modified/untracked first, so Enter runs the
|
||||||
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
|
# one in progress; then most recently tested; stamped on exit 0)
|
||||||
# hidden; last_submitted stamped in the db on exit 0)
|
bun run submit # submit ONE solution to LeetCode (uncommitted first, then newest
|
||||||
bun run sync # work/1 → docs/ pages + docs/site.json (folder meta + /<num> short
|
# scaffold, nothing hidden; last_submitted stamped on exit 0).
|
||||||
|
# Both fold in the signed-in profile's submission history once
|
||||||
|
# a day (leetcode-cli's keychain login; GraphQL only — LeetCode's
|
||||||
|
# REST /api/* answers 403), so a lost db or fresh clone recovers
|
||||||
|
# both orderings
|
||||||
|
bun run sync # work/1 → apps/docs/content/ pages + apps/docs/redirects.ts (/<num> short
|
||||||
# links; re-solves are never published)
|
# links; re-solves are never published)
|
||||||
bun run videos # docs pages with no `video:` frontmatter → NeetCode video per page
|
bun run videos # docs pages with no `video:` frontmatter → NeetCode video per page
|
||||||
# (channel search scraped, no API key; --write adds the
|
# (channel search scraped, no API key; --write adds the
|
||||||
# `video:` key; "no NeetCode match" pages stay untouched)
|
# `video:` key; "no NeetCode match" pages stay untouched)
|
||||||
|
bun run dev|build # Blume docs site (runs in apps/docs/)
|
||||||
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
|
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
|
||||||
bun run close-topics -- --dry-run
|
bun run close-topics -- --dry-run
|
||||||
bun run release-topics -- --dry-run # prints every would-be topic release body
|
bun run release-topics -- --dry-run # prints every would-be topic release body
|
||||||
@@ -108,25 +113,26 @@ 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` (5 since the break, to drain its backlog within the sprint) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 5 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`, 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/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/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice.
|
||||||
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/<CAMPAIGN_DAYS>) LeetCode Daily Digest` — 79 with the plan ending Nov 3; booking a later day in `schedule.json` lengthens it, and the heatmaps and `/api/stats` `heat` with it.
|
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/<SPRINT_DAYS>) LeetCode Daily Digest` and the progress line `Day N of <SPRINT_DAYS> · Week k` (Mon–Sun weeks, so the Friday start makes week 1 three days long) — 30 with the sprint running Nov 6 → Dec 5; before day 1 they count down (`(Sprint in N days)`). Booking a later day in `schedule.json` lengthens the sprint, and the whole-campaign heatmaps and `/api/stats` `heat` (still from `CAMPAIGN_START`) with it.
|
||||||
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
|
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
|
||||||
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `docs/(0N-<phase>)/<topic>/`, with every problem page in the topic's `(problems)/` group as `<num>_<slug>.mdx` (a page is found by its frontmatter title, wherever it sits, so a layout change is one sync of `rename`s). `docs/site.json` is wholly script-owned and rewritten whenever it differs, because `mdxRemote()` reads no `meta.ts`: its `folders` are what a `meta.ts` per phase and topic folder would carry — a phase `N. Name` at `order: N`, a topic its exact label, curriculum order, `TOPIC_ICONS` icon and `display: "page"` — all derived from the README, so a rename there is a rename on the site; its `redirects` are the `/<num>` → `/<topic>/<slug>` short links. Which tags the site teaches as topics (`topic-landings.ts`) and its Appendix glossary (`appendix.ts`) are the site's, in prdlk/cs: sync no longer checks them. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing through an emptied `(problems)` and topic into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
|
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `companies:` (`{ name, period, frequency }`: every company that asked the problem in the last six months, at the most recent of the `Thirty Days`/`Three Months`/`Six Months` windows it appears in and its frequency there, most recent first — the vocabulary is ONE module, `apps/docs/companies.ts`, shared by sync, the schema and both readers) is the exception that IS rewritten every run, since recency ages: it comes from the local DuckDB company-frequency database (`~/.local/share/duckdb/data/leetcode-company-frequency.db`, built by that project's `mise run lc:build`) via one `duckdb -readonly -json` query, and where the database or the `duckdb` CLI is missing (CI) the step is skipped and pages keep the key as it is. `ProblemBadges.astro` renders it as a TypeTable-style table under the badge row (company pill · asked within · frequency bar), each pill linking to `companyHref()` — the company's Appendix card, filed A–Z among the glossary terms (category `Company`, no definition, no hand-written table: derived from every page's `companies:`), whose Solved backlinks are the problems it asked, each with how recently. `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`, with every problem page in the topic's `(problems)/` group as `<num>_<slug>.mdx` (a page is found by its frontmatter title, wherever it sits, so a layout change is one sync of `rename`s). The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "page"`); the phase `meta.ts` is written once when missing (`Phase N: Name`, `order: N`) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing through an emptied `(problems)` and topic into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
|
||||||
- `apps/cli/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, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs site's `/progress` islands (prdlk/cs `lib/stats.ts`) and `bun run stats`, so its shape is a cross-repo contract: change the Worker and both readers together (additive fields a reader ignores are safe). `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
|
- `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 campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`, `PHASE_ALIASES`). `/api/stats` and `/ask` reflect a requesting origin only when it is in the `DOCS_ORIGIN` var (`apps/api/wrangler.jsonc`, comma-separated: `https://lc.prd.lk,https://prdlk.github.io`) — a new host for the docs site must be added there.
|
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache). The docs `/progress` page is a custom page, `apps/docs/pages/progress.astro` (no content folder, so outside the sidebar and prev/next; its header tab is its link), mounting five LeetCode-style React cards from `components/progress/` with `client:load` — imported by the page, never used from MDX, so they are not in `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 cards hydrate. shadcn sources live outside `islands/` (every `.tsx` there becomes an island) and import `lib/utils` relatively; `components.json` + `tsconfig.json` exist for the shadcn CLI and `tsc`, so after `shadcn add` rewrite `@/…` imports to relative ones (house style) — and check `package.json`: the CLI has twice installed an unrelated `cn` package for `lib/utils` and skipped real deps (`class-variance-authority`). Blume turns every tsconfig `paths` target into a Vite alias AND a dev dep-scan glob (`<target>/**/*.astro`…), so `paths` maps only `@/components`, `@/lib`, `@/hooks` — each bare and `/*`, since `components.json` names the bare forms — never `@/*` → `./*`: that glob reaches through `node_modules/blume`, where Vite's scanner reads a `<script>` inside a `RootLayout.astro` comment as a tag and `blume dev` skips dependency pre-bundling. Blume's `.prose :where(a, p, ol)` rules ignore `not-prose`, so island markup overrides them with `!` utilities, as Blume's own components do.
|
||||||
|
- `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`.
|
- **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. One exception, confined to one surface: the digest email, `apps/api/src/email.tsx`, renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. Do not extend it to any other module. (The docs site's own dependencies are prdlk/cs's.)
|
- **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
|
||||||
|
|
||||||
- Repo-wide: no test framework — `bun run test` submits one solution to LeetCode's judge. Exception: `apps/api/src/srs.test.ts` (`bun:test`) proves the DST cron guards with fixed dates; run via `bun run api:test`.
|
- Repo-wide: no test framework — `bun run test` submits one solution to LeetCode's judge. Exception: `apps/api/src/srs.test.ts` (`bun:test`) proves the DST cron guards with fixed dates; run via `bun run api:test`.
|
||||||
- QA is dry-runs + local state: Worker logic against `wrangler dev` + local D1 with `?dry=1&date=`; reconcilers with `--dry-run`; idempotency check = run twice, `cmp` output.
|
- QA is dry-runs + local state: Worker logic against `wrangler dev` + local D1 with `?dry=1&date=`; reconcilers with `--dry-run`; idempotency check = run twice, `cmp` output.
|
||||||
- Docs changes: the site builds in prdlk/cs, which must pass `bunx blume build --isolated` with no new warnings against this repo's `docs/`; after sync, review only *created* pages (prose transforms are heuristic) and `docs/site.json`'s diff.
|
- Docs changes: `bunx blume build --isolated` (from `apps/docs/`) must pass with no new warnings (then `rm -rf apps/docs/.blume-verify`); after sync, review only *created* pages (prose transforms are heuristic).
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<sub>Interview-prep fight camp: eight weeks of topics, then a review phase to Nov 3. Each phase is a milestone; each topic an issue; each problem a sub-issue.<br>
|
<sub>Interview-prep fight camp: eight weeks of topics, a review phase, then a 30-day interview sprint to Dec 5. Each phase is a milestone; each topic an issue; each problem a sub-issue.<br>
|
||||||
New topic Mon–Fri, gate on Saturday, Sunday off — every solve comes back at <strong>+3</strong> and <strong>+7</strong> days,
|
New topic Mon–Fri, gate on Saturday, Sunday off — every solve comes back at <strong>+3</strong> and <strong>+7</strong> days,
|
||||||
the two windows that keep the rest day clear (a Thursday solve's +3 slides to Monday).<br>
|
the two windows that keep the rest day clear (a Thursday solve's +3 slides to Monday).<br>
|
||||||
Charts are live from the SRS Worker, never committed: pushing a solution or tapping the daily digest moves them within ~5 min.</sub>
|
Charts are live from the SRS Worker, never committed: pushing a solution or tapping the daily digest moves them within ~5 min.</sub>
|
||||||
|
|||||||
+18
-15
@@ -26,21 +26,24 @@
|
|||||||
"2026-10-02": { "review": { "issue": 226, "title": "Review 2 — Linear Structures", "problems": [290, 525, 189, 1004, 84] }, "sweep": true },
|
"2026-10-02": { "review": { "issue": 226, "title": "Review 2 — Linear Structures", "problems": [290, 525, 189, 1004, 84] }, "sweep": true },
|
||||||
"2026-10-03": { "gate": [] },
|
"2026-10-03": { "gate": [] },
|
||||||
"2026-10-05": { "review": { "issue": 227, "title": "Review 3 — Linear Structures", "problems": [643, 128, 162, 71, 4] } },
|
"2026-10-05": { "review": { "issue": 227, "title": "Review 3 — Linear Structures", "problems": [643, 128, 162, 71, 4] } },
|
||||||
"2026-10-06": { "review": { "issue": 228, "title": "Review 4 — Linear Structures", "problems": [496, 80, 901, 76, 224] } },
|
|
||||||
"2026-10-08": { "review": { "issue": 229, "title": "Review 5 — Nodal & Grid", "problems": [876, 19, 36] } },
|
"2026-10-08": { "review": { "issue": 229, "title": "Review 5 — Nodal & Grid", "problems": [876, 19, 36] } },
|
||||||
"2026-10-09": { "review": { "issue": 230, "title": "Review 6 — Nodal & Grid", "problems": [92, 82, 380] } },
|
"2026-10-09": { "review": { "issue": 230, "title": "Review 6 — Nodal & Grid", "problems": [92, 82, 380] } },
|
||||||
"2026-10-12": { "review": { "issue": 231, "title": "Review 7 — Nodal & Grid", "problems": [143, 86, 289] } },
|
"2026-11-02": { "review": { "issue": 228, "title": "Review 4 — Linear Structures", "problems": [496, 80, 901, 76, 224] } },
|
||||||
"2026-10-13": { "review": { "issue": 232, "title": "Review 8 — Nodal & Grid", "problems": [61, 138] } },
|
"2026-11-06": { "review": { "issue": 231, "title": "Review 7 — Nodal & Grid", "problems": [143, 86, 289] } },
|
||||||
"2026-10-15": { "review": { "issue": 233, "title": "Review 9 — Hierarchical", "problems": [100, 112, 530, 1046, 103, 124] } },
|
"2026-11-09": { "review": { "issue": 232, "title": "Review 8 — Nodal & Grid", "problems": [61, 138] } },
|
||||||
"2026-10-16": { "review": { "issue": 234, "title": "Review 10 — Hierarchical", "problems": [572, 543, 703, 105, 295] } },
|
"2026-11-10": { "review": { "issue": 233, "title": "Review 9 — Hierarchical", "problems": [100, 112, 530, 1046, 103, 124] } },
|
||||||
"2026-10-19": { "review": { "issue": 235, "title": "Review 11 — Hierarchical", "problems": [101, 637, 173, 621, 502] } },
|
"2026-11-12": { "review": { "issue": 234, "title": "Review 10 — Hierarchical", "problems": [572, 543, 703, 105, 295] } },
|
||||||
"2026-10-20": { "review": { "issue": 236, "title": "Review 12 — Hierarchical", "problems": [108, 129, 373, 648, 212] } },
|
"2026-11-13": { "review": { "issue": 235, "title": "Review 11 — Hierarchical", "problems": [101, 637, 173, 621, 502] } },
|
||||||
"2026-10-22": { "review": { "issue": 237, "title": "Review 13 — Relational", "problems": [1971, 695, 547, 433] } },
|
"2026-11-16": { "review": { "issue": 236, "title": "Review 12 — Hierarchical", "problems": [108, 129, 373, 648, 212] } },
|
||||||
"2026-10-23": { "review": { "issue": 238, "title": "Review 14 — Relational", "problems": [130, 261, 909, 1462] } },
|
"2026-11-17": { "review": { "issue": 237, "title": "Review 13 — Relational", "problems": [1971, 695, 547, 433] } },
|
||||||
"2026-10-26": { "review": { "issue": 239, "title": "Review 15 — Relational", "problems": [286, 1254, 721, 399] } },
|
"2026-11-19": { "review": { "issue": 238, "title": "Review 14 — Relational", "problems": [130, 261, 909, 1462] } },
|
||||||
"2026-10-27": { "review": { "issue": 240, "title": "Review 16 — Relational", "problems": [1091, 802, 269] } },
|
"2026-11-20": { "review": { "issue": 239, "title": "Review 15 — Relational", "problems": [286, 1254, 721, 399] } },
|
||||||
"2026-10-29": { "review": { "issue": 241, "title": "Review 17 — Decision Space", "problems": [746, 228, 17, 77, 213, 63, 122] } },
|
"2026-11-23": { "review": { "issue": 240, "title": "Review 16 — Relational", "problems": [1091, 802, 269] } },
|
||||||
"2026-10-30": { "review": { "issue": 242, "title": "Review 18 — Decision Space", "problems": [22, 90, 139, 120, 45, 253, 72] } },
|
"2026-11-24": { "review": { "issue": 241, "title": "Review 17 — Decision Space", "problems": [746, 228, 17, 77, 213, 63, 122] } },
|
||||||
"2026-11-02": { "review": { "issue": 243, "title": "Review 19 — Decision Space", "problems": [79, 131, 91, 494, 763, 986, 920] } },
|
"2026-11-27": { "review": { "issue": 242, "title": "Review 18 — Decision Space", "problems": [22, 90, 139, 120, 45, 253, 72] } },
|
||||||
"2026-11-03": { "review": { "issue": 244, "title": "Review 20 — Decision Space", "problems": [300, 647, 97, 221, 918, 452, 52] } }
|
"2026-11-30": { "review": { "issue": 243, "title": "Review 19 — Decision Space", "problems": [79, 131, 91, 494, 763, 986, 920] } },
|
||||||
|
"2026-12-01": { "review": { "issue": 244, "title": "Review 20 — Decision Space", "problems": [300, 647, 97, 221, 918, 452, 52] } },
|
||||||
|
"2026-12-03": { "sweep": true },
|
||||||
|
"2026-12-04": { "sweep": true },
|
||||||
|
"2026-12-05": { "gate": [] }
|
||||||
}
|
}
|
||||||
+18
-6
@@ -10,12 +10,13 @@
|
|||||||
* either, so the rule holds by construction. The plan's own sections — a
|
* either, so the rule holds by construction. The plan's own sections — a
|
||||||
* learning day's topics (core, then the optional "if time" list), a set
|
* learning day's topics (core, then the optional "if time" list), a set
|
||||||
* day's problems, and a review day's `Review <N>` issue — are the only
|
* day's problems, and a review day's `Review <N>` issue — are the only
|
||||||
* labeled, linked ones. At most REVIEW_CAP (3)
|
* labeled, linked ones. At most REVIEW_CAP (5)
|
||||||
* reviews per day; drills fill toward DAILY_CAP (6) only when the due queue
|
* reviews per day; drills fill toward DAILY_CAP (6) only when the due queue
|
||||||
* is light, and never on a sweep day, which assigns every open miss and
|
* is light, and never on a sweep day, which assigns every open miss and
|
||||||
* nothing new. Overflow is levelled, not stacked: sendDigest gives every
|
* nothing new. Overflow is levelled, not stacked: sendDigest gives every
|
||||||
* review past the cap a concrete future date, ≤ REVIEW_CAP per day, oldest
|
* review past the cap a concrete future date, ≤ REVIEW_CAP per day, oldest
|
||||||
* first. Sunday is a two-line rest note.
|
* first. Sunday is a two-line rest note. Day N/M counts the interview sprint
|
||||||
|
* (SPRINT_START → the plan's last day); before it, the email counts down.
|
||||||
*
|
*
|
||||||
* Idempotency: email_log keys sends by ET date — a same-day re-send is a
|
* Idempotency: email_log keys sends by ET date — a same-day re-send is a
|
||||||
* no-op unless forced; the body itself is deterministic (drill picks are
|
* no-op unless forced; the body itself is deterministic (drill picks are
|
||||||
@@ -23,15 +24,15 @@
|
|||||||
* and a re-run finds nothing left over the cap.
|
* and a re-run finds nothing left over the cap.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
CAMPAIGN_DAYS,
|
|
||||||
MISS_WHERE,
|
MISS_WHERE,
|
||||||
type ProblemRow,
|
type ProblemRow,
|
||||||
RESERVED,
|
RESERVED,
|
||||||
REVIEW_CAP,
|
REVIEW_CAP,
|
||||||
SCHEDULE,
|
SCHEDULE,
|
||||||
|
SPRINT_DAYS,
|
||||||
|
SPRINT_START,
|
||||||
type ScheduledDay,
|
type ScheduledDay,
|
||||||
addDays,
|
addDays,
|
||||||
campaignDay,
|
|
||||||
campaignWeek,
|
campaignWeek,
|
||||||
dueReviews,
|
dueReviews,
|
||||||
isoWeek,
|
isoWeek,
|
||||||
@@ -39,6 +40,8 @@ import {
|
|||||||
lookupProblems,
|
lookupProblems,
|
||||||
pickDrills,
|
pickDrills,
|
||||||
prettyDate,
|
prettyDate,
|
||||||
|
sprintDay,
|
||||||
|
sprintWeek,
|
||||||
streak,
|
streak,
|
||||||
weekdayOf,
|
weekdayOf,
|
||||||
} from "./srs.ts";
|
} from "./srs.ts";
|
||||||
@@ -107,9 +110,13 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
|
|||||||
const drills =
|
const drills =
|
||||||
rest || plan.sweep ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
|
rest || plan.sweep ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
|
||||||
|
|
||||||
|
const sprint = sprintDay(date);
|
||||||
const data: DigestData = {
|
const data: DigestData = {
|
||||||
day: prettyDate(date),
|
day: prettyDate(date),
|
||||||
progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`,
|
progress:
|
||||||
|
sprint >= 1
|
||||||
|
? `Day ${sprint} of ${SPRINT_DAYS} · Week ${sprintWeek(date)}`
|
||||||
|
: `The ${SPRINT_DAYS}-day interview sprint starts ${prettyDate(SPRINT_START)}`,
|
||||||
streak: await streak(db, date),
|
streak: await streak(db, date),
|
||||||
rest,
|
rest,
|
||||||
topics: [],
|
topics: [],
|
||||||
@@ -206,8 +213,13 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
|
|||||||
export async function buildDigest(env: Env, date: string): Promise<Digest> {
|
export async function buildDigest(env: Env, date: string): Promise<Digest> {
|
||||||
const data = await collectDigest(env, date);
|
const data = await collectDigest(env, date);
|
||||||
const { html, text } = await renderDigest(data);
|
const { html, text } = await renderDigest(data);
|
||||||
|
const sprint = sprintDay(date);
|
||||||
|
const until = 1 - sprint;
|
||||||
return {
|
return {
|
||||||
subject: `(Day ${campaignDay(date)}/${CAMPAIGN_DAYS}) LeetCode Daily Digest`,
|
subject:
|
||||||
|
sprint >= 1
|
||||||
|
? `(Day ${sprint}/${SPRINT_DAYS}) LeetCode Daily Digest`
|
||||||
|
: `(Sprint in ${until} ${until === 1 ? "day" : "days"}) LeetCode Daily Digest`,
|
||||||
html,
|
html,
|
||||||
text,
|
text,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export interface TopicSection {
|
|||||||
export interface DigestData {
|
export interface DigestData {
|
||||||
/** "Tuesday, August 25" */
|
/** "Tuesday, August 25" */
|
||||||
day: string;
|
day: string;
|
||||||
/** "Day 9 of 79 · Week 2" — the length is CAMPAIGN_DAYS, derived from the plan. */
|
/** "Day 9 of 30 · Week 2" of the sprint (SPRINT_DAYS, derived from the plan), or its countdown before day 1. */
|
||||||
progress: string;
|
progress: string;
|
||||||
streak: number;
|
streak: number;
|
||||||
rest: boolean;
|
rest: boolean;
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import {
|
|||||||
addDays,
|
addDays,
|
||||||
campaignDay,
|
campaignDay,
|
||||||
campaignWeek,
|
campaignWeek,
|
||||||
|
CAMPAIGN_DAYS,
|
||||||
|
CAMPAIGN_END,
|
||||||
|
SPRINT_DAYS,
|
||||||
|
SPRINT_START,
|
||||||
|
sprintDay,
|
||||||
|
sprintWeek,
|
||||||
etDate,
|
etDate,
|
||||||
etHour,
|
etHour,
|
||||||
isRestDay,
|
isRestDay,
|
||||||
@@ -28,8 +34,8 @@ import {
|
|||||||
workingDay,
|
workingDay,
|
||||||
} from "./srs.ts";
|
} from "./srs.ts";
|
||||||
|
|
||||||
/** Every ET date of the campaign, plus the deferred-Hard tail. */
|
/** Every ET date of the campaign (sprint included), plus two weeks of ladder tail. */
|
||||||
const CALENDAR = Array.from({ length: 70 }, (_, i) => addDays("2026-08-17", i));
|
const CALENDAR = Array.from({ length: CAMPAIGN_DAYS + 14 }, (_, i) => addDays("2026-08-17", i));
|
||||||
|
|
||||||
describe("DST-proof cron guard", () => {
|
describe("DST-proof cron guard", () => {
|
||||||
test("digest fires only at ET hour 8 — EDT (UTC-4)", () => {
|
test("digest fires only at ET hour 8 — EDT (UTC-4)", () => {
|
||||||
@@ -67,6 +73,15 @@ describe("date math (noon-UTC anchored)", () => {
|
|||||||
expect(campaignWeek("2026-08-30")).toBe(2);
|
expect(campaignWeek("2026-08-30")).toBe(2);
|
||||||
expect(campaignWeek("2026-08-31")).toBe(3);
|
expect(campaignWeek("2026-08-31")).toBe(3);
|
||||||
});
|
});
|
||||||
|
test("sprint math: day 1 is SPRINT_START, the last is the plan's end, weeks run Mon–Sun", () => {
|
||||||
|
expect(sprintDay(SPRINT_START)).toBe(1);
|
||||||
|
expect(sprintDay(addDays(SPRINT_START, -1))).toBe(0); // countdown, never "Day 0 of N"
|
||||||
|
expect(sprintDay(CAMPAIGN_END)).toBe(SPRINT_DAYS);
|
||||||
|
expect(sprintWeek("2026-11-06")).toBe(1); // Friday start
|
||||||
|
expect(sprintWeek("2026-11-08")).toBe(1); // its Sunday closes week 1
|
||||||
|
expect(sprintWeek("2026-11-09")).toBe(2);
|
||||||
|
expect(sprintWeek(CAMPAIGN_END)).toBe(5);
|
||||||
|
});
|
||||||
test("iso week is stable across a week", () => {
|
test("iso week is stable across a week", () => {
|
||||||
expect(isoWeek("2026-08-24")).toBe(isoWeek("2026-08-29"));
|
expect(isoWeek("2026-08-24")).toBe(isoWeek("2026-08-29"));
|
||||||
expect(isoWeek("2026-08-30")).toBe(isoWeek("2026-08-24")); // Sun ends the ISO week
|
expect(isoWeek("2026-08-30")).toBe(isoWeek("2026-08-24")); // Sun ends the ISO week
|
||||||
|
|||||||
+30
-2
@@ -166,6 +166,30 @@ export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
|
|||||||
export const CAMPAIGN_END = Object.keys(SCHEDULE).sort().at(-1)!;
|
export const CAMPAIGN_END = Object.keys(SCHEDULE).sort().at(-1)!;
|
||||||
export const CAMPAIGN_DAYS = daysBetween(CAMPAIGN_START, CAMPAIGN_END) + 1;
|
export const CAMPAIGN_DAYS = daysBetween(CAMPAIGN_START, CAMPAIGN_END) + 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day 1 of the interview sprint: the plan restarted here after two weeks off,
|
||||||
|
* with every open Review issue rebooked between this Friday and CAMPAIGN_END.
|
||||||
|
* Only the start is declared; the length is derived like CAMPAIGN_DAYS, so
|
||||||
|
* booking a later day lengthens both. The digest counts days and weeks from
|
||||||
|
* here; the heatmaps and /api/stats keep the whole campaign.
|
||||||
|
*/
|
||||||
|
export const SPRINT_START = "2026-11-06";
|
||||||
|
export const SPRINT_DAYS = daysBetween(SPRINT_START, CAMPAIGN_END) + 1;
|
||||||
|
|
||||||
|
/** 1-based sprint day; zero or negative before SPRINT_START. */
|
||||||
|
export function sprintDay(date: string): number {
|
||||||
|
return daysBetween(SPRINT_START, date) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1-based sprint week, Mon–Sun like the campaign's, so the Friday start makes
|
||||||
|
* week 1 three days long and every later week a whole one.
|
||||||
|
*/
|
||||||
|
export function sprintWeek(date: string): number {
|
||||||
|
const monday = addDays(SPRINT_START, -((weekdayOf(SPRINT_START) + 6) % 7));
|
||||||
|
return Math.floor(daysBetween(monday, date) / 7) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
/** Date the schedule teaches a topic on, if it teaches it at all. */
|
/** Date the schedule teaches a topic on, if it teaches it at all. */
|
||||||
export function topicDate(topic: number): string | undefined {
|
export function topicDate(topic: number): string | undefined {
|
||||||
for (const [date, day] of Object.entries(SCHEDULE)) {
|
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||||
@@ -413,8 +437,12 @@ export async function dueToday(db: D1Database, date: string): Promise<{ date: st
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reviews surfaced per day; the digest levels everything past this forward. */
|
/**
|
||||||
export const REVIEW_CAP = 3;
|
* Reviews surfaced per day; the digest levels everything past this forward.
|
||||||
|
* 5, not 3, since the two-week break: at 3 a day the ~110-review backlog would
|
||||||
|
* outlast the sprint by a fortnight.
|
||||||
|
*/
|
||||||
|
export const REVIEW_CAP = 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load-level an overloaded review queue: everything due beyond today's
|
* Load-level an overloaded review queue: everything due beyond today's
|
||||||
|
|||||||
+219
-41
@@ -9,12 +9,20 @@
|
|||||||
* slug and kept forever. Only the campaign problems linked from README.md
|
* slug and kept forever. Only the campaign problems linked from README.md
|
||||||
* are ever fetched, and paid-only problems are skipped because GraphQL
|
* are ever fetched, and paid-only problems are skipped because GraphQL
|
||||||
* returns null content for them.
|
* returns null content for them.
|
||||||
|
* Everything goes through /graphql: Cloudflare challenges the REST endpoints
|
||||||
|
* (`/api/problems/all/`, `/api/submissions/`) with a 403 for any non-browser
|
||||||
|
* client, signed in or not.
|
||||||
*
|
*
|
||||||
* A `solutions` table tracks the lifecycle of each work/ file by LC number:
|
* A `solutions` table tracks the lifecycle of each work/ file by LC number:
|
||||||
* `created_at` (backfilled from the file's birthtime on every picker run),
|
* `created_at` (backfilled from the file's birthtime on every picker run),
|
||||||
* `last_tested` and `last_submitted` (stamped on exit 0 of leetcode-cli).
|
* `last_tested` and `last_submitted` (stamped on exit 0 of leetcode-cli).
|
||||||
* The test picker sorts by last_tested, the submit picker by created_at, and
|
* The test picker sorts by last_tested, the submit picker by created_at.
|
||||||
* submit hides anything with a last_submitted.
|
* Local stamps die with the db and birthtimes with a clone, so at most once
|
||||||
|
* per 24h (`meta.history_fetched_at`) the signed-in profile's submission
|
||||||
|
* history is folded in: a problem's first submission caps `created_at`, its
|
||||||
|
* latest raises `last_submitted` and `last_tested` (a submission is a judge
|
||||||
|
* run too; LeetCode keeps no record of `leetcode test` runs). Folding only
|
||||||
|
* ever moves a timestamp outward, so a re-run is a no-op.
|
||||||
*
|
*
|
||||||
* Library: side-effect-free on import — nothing is opened or fetched until
|
* Library: side-effect-free on import — nothing is opened or fetched until
|
||||||
* problemDb() is called. Index upserts never touch the description column, so
|
* problemDb() is called. Index upserts never touch the description column, so
|
||||||
@@ -40,8 +48,8 @@ export interface SolutionActivity {
|
|||||||
lastSubmitted: string | null;
|
lastSubmitted: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
|
const DIFFICULTY = { EASY: "Easy", MEDIUM: "Medium", HARD: "Hard" } as const;
|
||||||
const INDEX_TTL_MS = 24 * 60 * 60 * 1000;
|
const TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export const DB_PATH = join(
|
export const DB_PATH = join(
|
||||||
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
|
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
|
||||||
@@ -62,50 +70,185 @@ export function readmeSlugs(markdown: string): string[] {
|
|||||||
|
|
||||||
// ── remote fetches ───────────────────────────────────────────────
|
// ── remote fetches ───────────────────────────────────────────────
|
||||||
|
|
||||||
async function fetchIndex(): Promise<Problem[]> {
|
/** leetcode-cli's login: the two cookies every signed-in request carries. */
|
||||||
const res = await fetch("https://leetcode.com/api/problems/all/", {
|
export interface Credentials {
|
||||||
headers: { "user-agent": "Mozilla/5.0" },
|
session: string;
|
||||||
});
|
csrfToken: string;
|
||||||
if (!res.ok) throw new Error(`GET /api/problems/all -> ${res.status} ${res.statusText}`);
|
|
||||||
const data = (await res.json()) as {
|
|
||||||
stat_status_pairs: Array<{
|
|
||||||
stat: {
|
|
||||||
frontend_question_id: number;
|
|
||||||
question__title: string;
|
|
||||||
question__title_slug: string;
|
|
||||||
};
|
|
||||||
difficulty: { level: 1 | 2 | 3 };
|
|
||||||
paid_only: boolean;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
return data.stat_status_pairs
|
|
||||||
.map((p) => ({
|
|
||||||
id: p.stat.frontend_question_id,
|
|
||||||
title: p.stat.question__title,
|
|
||||||
slug: p.stat.question__title_slug,
|
|
||||||
difficulty: DIFFICULTY[p.difficulty.level] as Problem["difficulty"],
|
|
||||||
paidOnly: p.paid_only,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.id - b.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HTML problem statement, or null when LeetCode withholds it (paid-only). */
|
/**
|
||||||
export async function fetchDescription(slug: string): Promise<string | null> {
|
* POST one query to leetcode.com/graphql. Throws on an HTTP error or a
|
||||||
|
* response with no `data`; partial `errors` beside data are the caller's.
|
||||||
|
*/
|
||||||
|
async function graphql<T>(
|
||||||
|
query: string,
|
||||||
|
variables: Record<string, unknown>,
|
||||||
|
{ referer = "https://leetcode.com/", auth }: { referer?: string; auth?: Credentials } = {},
|
||||||
|
): Promise<T> {
|
||||||
const res = await fetch("https://leetcode.com/graphql", {
|
const res = await fetch("https://leetcode.com/graphql", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
"user-agent": "Mozilla/5.0",
|
"user-agent": "Mozilla/5.0",
|
||||||
referer: `https://leetcode.com/problems/${slug}/`,
|
referer,
|
||||||
},
|
...(auth && {
|
||||||
body: JSON.stringify({
|
cookie: `LEETCODE_SESSION=${auth.session}; csrftoken=${auth.csrfToken}`,
|
||||||
query: "query questionContent($titleSlug: String!) { question(titleSlug: $titleSlug) { content } }",
|
"x-csrftoken": auth.csrfToken,
|
||||||
variables: { titleSlug: slug },
|
|
||||||
}),
|
}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`POST /graphql (${slug}) -> ${res.status} ${res.statusText}`);
|
if (!res.ok) throw new Error(`POST /graphql -> ${res.status} ${res.statusText}`);
|
||||||
const data = (await res.json()) as { data?: { question?: { content?: string | null } | null } };
|
const body = (await res.json()) as { data?: T | null; errors?: { message: string }[] };
|
||||||
return data.data?.question?.content ?? null;
|
if (!body.data) {
|
||||||
|
throw new Error(`POST /graphql: ${body.errors?.map((e) => e.message).join("; ") || "no data"}`);
|
||||||
|
}
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-side cap on `limit` for the problem list. */
|
||||||
|
const INDEX_PAGE = 100;
|
||||||
|
|
||||||
|
async function fetchIndex(): Promise<Problem[]> {
|
||||||
|
type Page = {
|
||||||
|
problemsetQuestionListV2: {
|
||||||
|
totalLength: number;
|
||||||
|
questions: Array<{
|
||||||
|
questionFrontendId: string;
|
||||||
|
title: string;
|
||||||
|
titleSlug: string;
|
||||||
|
difficulty: keyof typeof DIFFICULTY;
|
||||||
|
paidOnly: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const page = async (skip: number) =>
|
||||||
|
(
|
||||||
|
await graphql<Page>(
|
||||||
|
`query problemIndex($skip: Int!, $limit: Int!) {
|
||||||
|
problemsetQuestionListV2(skip: $skip, limit: $limit) {
|
||||||
|
totalLength
|
||||||
|
questions { questionFrontendId title titleSlug difficulty paidOnly }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ skip, limit: INDEX_PAGE },
|
||||||
|
{ referer: "https://leetcode.com/problemset/" },
|
||||||
|
)
|
||||||
|
).problemsetQuestionListV2;
|
||||||
|
|
||||||
|
// The first page names the total; the rest go six at a time.
|
||||||
|
const first = await page(0);
|
||||||
|
const questions = [...first.questions];
|
||||||
|
const skips = Array.from({ length: Math.ceil(first.totalLength / INDEX_PAGE) - 1 }, (_, i) => (i + 1) * INDEX_PAGE);
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 6 }, async () => {
|
||||||
|
for (let skip = skips.shift(); skip !== undefined; skip = skips.shift()) {
|
||||||
|
questions.push(...(await page(skip)).questions);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (questions.length < first.totalLength) {
|
||||||
|
throw new Error(`problem index came back short: ${questions.length} of ${first.totalLength}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return questions
|
||||||
|
.map((q) => ({
|
||||||
|
id: Number(q.questionFrontendId),
|
||||||
|
title: q.title,
|
||||||
|
slug: q.titleSlug,
|
||||||
|
difficulty: DIFFICULTY[q.difficulty],
|
||||||
|
paidOnly: q.paidOnly,
|
||||||
|
}))
|
||||||
|
.filter((p) => Number.isInteger(p.id))
|
||||||
|
.sort((a, b) => a.id - b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTML problem statement, or null when LeetCode withholds it (paid-only). */
|
||||||
|
export async function fetchDescription(slug: string): Promise<string | null> {
|
||||||
|
const data = await graphql<{ question?: { content?: string | null } | null }>(
|
||||||
|
"query questionContent($titleSlug: String!) { question(titleSlug: $titleSlug) { content } }",
|
||||||
|
{ titleSlug: slug },
|
||||||
|
{ referer: `https://leetcode.com/problems/${slug}/` },
|
||||||
|
);
|
||||||
|
return data.question?.content ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The login leetcode-cli holds, read from where it keeps it: its own
|
||||||
|
* LEETCODE_SESSION + LEETCODE_CSRF_TOKEN override, else the OS keychain entry
|
||||||
|
* `leetcode login` writes through keytar (service `leetcode-cli`, account
|
||||||
|
* `auth`). Null when signed out, or when the login lives in leetcode-cli's
|
||||||
|
* encrypted-file fallback, whose key is the CLI's own.
|
||||||
|
*/
|
||||||
|
export function leetcodeCredentials(): Credentials | null {
|
||||||
|
const { LEETCODE_SESSION: session, LEETCODE_CSRF_TOKEN: csrfToken } = process.env;
|
||||||
|
if (session && csrfToken) return { session, csrfToken };
|
||||||
|
|
||||||
|
const lookup =
|
||||||
|
process.platform === "darwin"
|
||||||
|
? ["security", "find-generic-password", "-s", "leetcode-cli", "-a", "auth", "-w"]
|
||||||
|
: ["secret-tool", "lookup", "service", "leetcode-cli", "account", "auth"];
|
||||||
|
let stored: string;
|
||||||
|
try {
|
||||||
|
const out = Bun.spawnSync(lookup, { stderr: "ignore" });
|
||||||
|
if (!out.success) return null;
|
||||||
|
stored = out.stdout.toString();
|
||||||
|
} catch {
|
||||||
|
return null; // no keychain CLI on this machine
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(stored);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!parsed ||
|
||||||
|
typeof parsed !== "object" ||
|
||||||
|
!("session" in parsed) ||
|
||||||
|
typeof parsed.session !== "string" ||
|
||||||
|
!("csrfToken" in parsed) ||
|
||||||
|
typeof parsed.csrfToken !== "string"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { session: parsed.session, csrfToken: parsed.csrfToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One submission of the signed-in profile, any verdict. */
|
||||||
|
export interface Submission {
|
||||||
|
slug: string;
|
||||||
|
/** ISO timestamp. */
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-side cap on `limit` for the submission list. */
|
||||||
|
const SUBMISSION_PAGE = 20;
|
||||||
|
|
||||||
|
/** The signed-in profile's whole submission history, newest first. */
|
||||||
|
export async function fetchSubmissions(auth: Credentials): Promise<Submission[]> {
|
||||||
|
type Page = {
|
||||||
|
submissionList: { hasNext: boolean; submissions: Array<{ titleSlug: string; timestamp: string }> } | null;
|
||||||
|
};
|
||||||
|
const all: Submission[] = [];
|
||||||
|
for (let offset = 0; ; offset += SUBMISSION_PAGE) {
|
||||||
|
const { submissionList } = await graphql<Page>(
|
||||||
|
`query submissionList($offset: Int!, $limit: Int!) {
|
||||||
|
submissionList(offset: $offset, limit: $limit) {
|
||||||
|
hasNext
|
||||||
|
submissions { titleSlug timestamp }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ offset, limit: SUBMISSION_PAGE },
|
||||||
|
{ referer: "https://leetcode.com/submissions/", auth },
|
||||||
|
);
|
||||||
|
// An expired session is answered with a null list, not an HTTP error.
|
||||||
|
if (!submissionList) throw new Error("submission history unavailable — session expired? run `leetcode login`");
|
||||||
|
for (const s of submissionList.submissions) {
|
||||||
|
all.push({ slug: s.titleSlug, at: new Date(Number(s.timestamp) * 1000).toISOString() });
|
||||||
|
}
|
||||||
|
if (!submissionList.hasNext) return all;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── the cache ────────────────────────────────────────────────────
|
// ── the cache ────────────────────────────────────────────────────
|
||||||
@@ -149,14 +292,28 @@ export function problemDb(path = DB_PATH) {
|
|||||||
const descState = db.query<{ paid_only: number; described: number }, [string]>(
|
const descState = db.query<{ paid_only: number; described: number }, [string]>(
|
||||||
"SELECT paid_only, description IS NOT NULL AS described FROM problems WHERE slug = ?1",
|
"SELECT paid_only, description IS NOT NULL AS described FROM problems WHERE slug = ?1",
|
||||||
);
|
);
|
||||||
|
// MIN/MAX on TEXT compare ISO timestamps chronologically; COALESCE because
|
||||||
|
// SQLite's scalar MAX returns NULL when any argument is NULL.
|
||||||
|
const fold = db.query<never, [number, string, string]>(
|
||||||
|
`UPDATE solutions SET
|
||||||
|
created_at = MIN(created_at, ?2),
|
||||||
|
last_tested = MAX(COALESCE(last_tested, ''), ?3),
|
||||||
|
last_submitted = MAX(COALESCE(last_submitted, ''), ?3)
|
||||||
|
WHERE id = ?1`,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** True when `key` has never been stamped or was stamped over 24h ago. */
|
||||||
|
const stale = (key: string) => {
|
||||||
|
const row = getMeta.get(key);
|
||||||
|
return !row || Date.now() - Number(row.value) > TTL_MS;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path,
|
path,
|
||||||
|
|
||||||
/** True when the index has never been fetched or is older than 24h. */
|
/** True when the index has never been fetched or is older than 24h. */
|
||||||
indexStale(): boolean {
|
indexStale(): boolean {
|
||||||
const row = getMeta.get("index_fetched_at");
|
return stale("index_fetched_at");
|
||||||
return !row || Date.now() - Number(row.value) > INDEX_TTL_MS;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Pull the full index from leetcode.com and upsert it. Returns the count. */
|
/** Pull the full index from leetcode.com and upsert it. Returns the count. */
|
||||||
@@ -228,6 +385,27 @@ export function problemDb(path = DB_PATH) {
|
|||||||
).run(id, new Date().toISOString());
|
).run(id, new Date().toISOString());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** True when submission history has never been folded in or over 24h ago. */
|
||||||
|
historyStale(): boolean {
|
||||||
|
return stale("history_fetched_at");
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold the profile's submissions (LC number → first and latest submission,
|
||||||
|
* ISO) into existing solution rows and stamp the refresh. Rows are never
|
||||||
|
* created here — the table holds work/ files only — and a timestamp only
|
||||||
|
* ever moves outward, so folding twice changes nothing. Returns the number
|
||||||
|
* of solutions that have submissions.
|
||||||
|
*/
|
||||||
|
foldHistory(history: Map<number, { first: string; last: string }>): number {
|
||||||
|
let matched = 0;
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const [id, { first, last }] of history) matched += fold.run(id, first, last).changes;
|
||||||
|
setMeta.run("history_fetched_at", String(Date.now()));
|
||||||
|
})();
|
||||||
|
return matched;
|
||||||
|
},
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
db.close();
|
db.close();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,14 +21,14 @@
|
|||||||
* tagged commit — the last main commit before the finish. Problems still open
|
* tagged commit — the last main commit before the finish. Problems still open
|
||||||
* at the finish are left out: the release records what finished, and an open
|
* at the finish are left out: the release records what finished, and an open
|
||||||
* optional problem is tracked by its issue (or its Review day), not here.
|
* optional problem is tracked by its issue (or its Review day), not here.
|
||||||
* The docs site (prdlk/cs `lib/releases.ts`) reads these `### Closed …`
|
* apps/docs/lib/releases.ts reads these `### Closed …` tables back for the
|
||||||
* tables back for its `/changelog` timeline, so their row shape is its contract.
|
* docs `/changelog` timeline, so their row shape is its contract.
|
||||||
*
|
*
|
||||||
* The tag is annotated and backdated (tagger date = finish), so
|
* The tag is annotated and backdated (tagger date = finish), so
|
||||||
* `git tag --sort=taggerdate` reads as the campaign's history even for tags
|
* `git tag --sort=taggerdate` reads as the campaign's history even for tags
|
||||||
* minted by a backfill. The release's own published date cannot be backdated
|
* minted by a backfill. The release's own published date cannot be backdated
|
||||||
* (the API has no field for it), so the docs changelog dates each entry from
|
* (the API has no field for it), so the docs changelog dates each entry from
|
||||||
* this timever tag instead (prdlk/cs `lib/releases.ts`)
|
* this timever tag instead (apps/docs/lib/releases.ts)
|
||||||
* — the tag format is that contract.
|
* — the tag format is that contract.
|
||||||
*
|
*
|
||||||
* Reconciles instead of reacting: every finished topic or review without a
|
* Reconciles instead of reacting: every finished topic or review without a
|
||||||
@@ -59,7 +59,7 @@ const DRY =
|
|||||||
process.env.DRY_RUN === "1" ||
|
process.env.DRY_RUN === "1" ||
|
||||||
process.env.DRY_RUN === "true";
|
process.env.DRY_RUN === "true";
|
||||||
|
|
||||||
/** The docs site (prdlk/cs `blume.config.ts` `deployment.site`); it publishes sync's `/<num>` short links. */
|
/** The docs site (apps/docs/blume.config.ts `deployment.site`); sync publishes `/<num>` short links. */
|
||||||
const DOCS_SITE = "https://lc.prd.lk";
|
const DOCS_SITE = "https://lc.prd.lk";
|
||||||
const TIMEVER = "https://gist.github.com/twolfson/de1b004dd22536b8e668";
|
const TIMEVER = "https://gist.github.com/twolfson/de1b004dd22536b8e668";
|
||||||
|
|
||||||
|
|||||||
+72
-7
@@ -9,18 +9,22 @@
|
|||||||
*
|
*
|
||||||
* Every run backfills the db's solutions table with created_at (from the
|
* Every run backfills the db's solutions table with created_at (from the
|
||||||
* file's birthtime, mtime when the filesystem has none) so ordering works for
|
* file's birthtime, mtime when the filesystem has none) so ordering works for
|
||||||
* files that predate the db. Ordering is per command — test lists the most
|
* files that predate the db, and at most once a day folds in the signed-in
|
||||||
* recently tested first (never-tested after, by name); submit lists the most
|
* LeetCode profile's submission history (see db.ts), which survives a lost db
|
||||||
* recently created first. Nothing is ever hidden. Exit 0 of leetcode-cli
|
* or a fresh clone. Files git reports as modified, added or
|
||||||
* stamps last_tested / last_submitted.
|
* untracked come first, newest edit first — the problem being worked on is
|
||||||
|
* the focused option, so Enter runs it without a search. The rest are ordered
|
||||||
|
* per command — test lists the most recently tested first (never-tested
|
||||||
|
* after, by name); submit lists the most recently created first. Nothing is
|
||||||
|
* ever hidden. Exit 0 of leetcode-cli stamps last_tested / last_submitted.
|
||||||
*
|
*
|
||||||
* Library: side-effect-free on import — nothing scans the filesystem until
|
* Library: side-effect-free on import — nothing scans the filesystem until
|
||||||
* runOnSolution() is called.
|
* runOnSolution() is called.
|
||||||
*/
|
*/
|
||||||
import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
|
import { autocomplete, cancel, isCancel, log, spinner } from "@clack/prompts";
|
||||||
import { statSync } from "node:fs";
|
import { statSync } from "node:fs";
|
||||||
import { basename, join, relative } from "node:path";
|
import { basename, join, relative } from "node:path";
|
||||||
import { problemDb } from "./db";
|
import { fetchSubmissions, leetcodeCredentials, problemDb } from "./db";
|
||||||
import { ROOT, WORK as WORK_DIR, parseWorkPath } from "./work";
|
import { ROOT, WORK as WORK_DIR, parseWorkPath } from "./work";
|
||||||
|
|
||||||
/** Leading LC number of a work/ file, or undefined for unnumbered names. */
|
/** Leading LC number of a work/ file, or undefined for unnumbered names. */
|
||||||
@@ -29,6 +33,29 @@ function lcNumber(rel: string): number | undefined {
|
|||||||
return m ? Number(m[1]) : undefined;
|
return m ? Number(m[1]) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* work/-relative paths of solutions git sees as modified, added or untracked,
|
||||||
|
* mapped to their mtime. Empty outside a git checkout.
|
||||||
|
*/
|
||||||
|
function dirtySolutions(scanned: Set<string>): Map<string, number> {
|
||||||
|
const git = Bun.spawnSync(
|
||||||
|
["git", "status", "--porcelain", "-z", "--untracked-files=all", "--", WORK_DIR],
|
||||||
|
{ cwd: ROOT },
|
||||||
|
);
|
||||||
|
const dirty = new Map<string, number>();
|
||||||
|
if (!git.success) return dirty;
|
||||||
|
const entries = git.stdout.toString().split("\0");
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
const entry = entries[i]!;
|
||||||
|
// renames/copies carry their source path as the next entry; skip it
|
||||||
|
if (/[RC]/.test(entry.slice(0, 2))) i++;
|
||||||
|
const rel = relative(WORK_DIR, join(ROOT, entry.slice(3)));
|
||||||
|
// deletions and non-solution files are not in the scan
|
||||||
|
if (scanned.has(rel)) dirty.set(rel, statSync(join(WORK_DIR, rel)).mtimeMs);
|
||||||
|
}
|
||||||
|
return dirty;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
|
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
|
||||||
export async function runOnSolution(
|
export async function runOnSolution(
|
||||||
command: "test" | "submit",
|
command: "test" | "submit",
|
||||||
@@ -52,6 +79,36 @@ export async function runOnSolution(
|
|||||||
new Date(stat.birthtimeMs || stat.mtimeMs).toISOString(),
|
new Date(stat.birthtimeMs || stat.mtimeMs).toISOString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the profile's history: first and latest submission per work/ problem
|
||||||
|
if (db.historyStale()) {
|
||||||
|
const auth = leetcodeCredentials();
|
||||||
|
if (!auth) {
|
||||||
|
log.warn("Not signed in to leetcode-cli — run `leetcode login` to restore history from your profile.");
|
||||||
|
} else {
|
||||||
|
const s = spinner();
|
||||||
|
s.start("Syncing submission history from your LeetCode profile");
|
||||||
|
try {
|
||||||
|
const bySlug = new Map<string, number>();
|
||||||
|
for (const f of scanned) {
|
||||||
|
const parsed = parseWorkPath(f);
|
||||||
|
if (parsed) bySlug.set(parsed.slug, parsed.lc);
|
||||||
|
}
|
||||||
|
const history = new Map<number, { first: string; last: string }>();
|
||||||
|
for (const { slug, at } of await fetchSubmissions(auth)) {
|
||||||
|
const id = bySlug.get(slug);
|
||||||
|
if (id === undefined) continue;
|
||||||
|
const seen = history.get(id);
|
||||||
|
if (!seen) history.set(id, { first: at, last: at });
|
||||||
|
else if (at < seen.first) seen.first = at;
|
||||||
|
else if (at > seen.last) seen.last = at;
|
||||||
|
}
|
||||||
|
s.stop(`Synced submission history for ${db.foldHistory(history)} solutions`);
|
||||||
|
} catch (err) {
|
||||||
|
s.error(`Submission history sync failed (${err}) — using cached timestamps`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const activity = db.solutionActivity();
|
const activity = db.solutionActivity();
|
||||||
|
|
||||||
// test → most recently tested first; submit → most recently created first
|
// test → most recently tested first; submit → most recently created first
|
||||||
@@ -61,7 +118,15 @@ export async function runOnSolution(
|
|||||||
return (command === "test" ? row?.lastTested : row?.createdAt) ?? undefined;
|
return (command === "test" ? row?.lastTested : row?.createdAt) ?? undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dirty = dirtySolutions(new Set(scanned));
|
||||||
const files = scanned.sort((a, b) => {
|
const files = scanned.sort((a, b) => {
|
||||||
|
const ma = dirty.get(a);
|
||||||
|
const mb = dirty.get(b);
|
||||||
|
if (ma !== mb) {
|
||||||
|
if (ma === undefined) return 1;
|
||||||
|
if (mb === undefined) return -1;
|
||||||
|
return mb - ma;
|
||||||
|
}
|
||||||
const ka = sortKey(a);
|
const ka = sortKey(a);
|
||||||
const kb = sortKey(b);
|
const kb = sortKey(b);
|
||||||
if (ka !== kb) {
|
if (ka !== kb) {
|
||||||
@@ -91,7 +156,7 @@ export async function runOnSolution(
|
|||||||
value: f,
|
value: f,
|
||||||
label: basename(f),
|
label: basename(f),
|
||||||
hint: parsed
|
hint: parsed
|
||||||
? `${stage} · ${parsed.difficulty} · ${parsed.category}`
|
? `${dirty.has(f) ? "uncommitted · " : ""}${stage} · ${parsed.difficulty} · ${parsed.category}`
|
||||||
: f,
|
: f,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
+369
-109
@@ -1,13 +1,6 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Sync work/1 leetcode solutions into docs/ pages, plus docs/site.json.
|
* Sync work/1 leetcode solutions into apps/docs/content/ pages.
|
||||||
*
|
|
||||||
* docs/ is content only — MDX pages and one generated JSON manifest, no Blume
|
|
||||||
* config. The docs site itself (config, components, islands, the Appendix)
|
|
||||||
* lives in prdlk/cs and reads docs/ over HTTP with Blume's `mdxRemote()`
|
|
||||||
* source, which fetches `.md`/`.mdx` and nothing else: no `meta.ts`. So the
|
|
||||||
* sidebar meta a folder `meta.ts` used to carry, and the `/<num>` short links,
|
|
||||||
* travel in `docs/site.json` instead, which the site fetches at the same ref.
|
|
||||||
*
|
*
|
||||||
* - New problems get a full page (frontmatter, warning, examples,
|
* - New problems get a full page (frontmatter, warning, examples,
|
||||||
* constraints, solution) in the gold-standard format.
|
* constraints, solution) in the gold-standard format.
|
||||||
@@ -22,9 +15,9 @@
|
|||||||
* `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 site's MDX `wrapper` (prdlk/cs
|
* renders no badges: the docs MDX `wrapper`
|
||||||
* `components/ProblemBadges.astro`) turns `topics:` and the `video:` key
|
* (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the
|
||||||
* `videos.ts` writes into the badge row under the description.
|
* `video:` key `videos.ts` writes into the badge row under the description.
|
||||||
* - Every planned page (a problem the README plan lists) carries its GitHub
|
* - Every planned page (a problem the README plan lists) carries its GitHub
|
||||||
* problem issue as frontmatter `issue:` (the URL of the `problem`-labelled
|
* problem issue as frontmatter `issue:` (the URL of the `problem`-labelled
|
||||||
* issue titled `LC <num> · …`), rendered by the same wrapper as a GitHub
|
* issue titled `LC <num> · …`), rendered by the same wrapper as a GitHub
|
||||||
@@ -34,11 +27,27 @@
|
|||||||
* (never on a sync with nothing new); offline, or with no `gh` auth, the
|
* (never on a sync with nothing new); offline, or with no `gh` auth, the
|
||||||
* page is written without it and the next sync retries. Delete the key to
|
* page is written without it and the next sync retries. Delete the key to
|
||||||
* refetch it.
|
* refetch it.
|
||||||
|
* - Every page carries the companies that asked it in the last six months as
|
||||||
|
* frontmatter `companies:` (`{ name, period, frequency }`, the most recent
|
||||||
|
* window each company falls in and its frequency there, most recent first;
|
||||||
|
* `apps/docs/companies.ts`), read from the local DuckDB
|
||||||
|
* company-frequency database (`COMPANY_DB`, built by its own `mise run
|
||||||
|
* lc:build`) with the `duckdb` CLI. Unlike the keys above it is rewritten
|
||||||
|
* every run, since recency ages and a rebuilt database should show; a
|
||||||
|
* problem no company asked lately carries no key. Where the database or the
|
||||||
|
* CLI is missing (CI), the step is skipped and pages keep the key as is.
|
||||||
|
* - Every tag a page carries that no campaign topic teaches (not a key of
|
||||||
|
* `apps/docs/topic-landings.ts`) belongs in the Appendix glossary,
|
||||||
|
* `apps/docs/appendix.ts`, which is hand-written: sync only checks it,
|
||||||
|
* warning about a carried tag with no term (its badge falls back to
|
||||||
|
* LeetCode's list), a term whose tag a topic now teaches, and a term icon
|
||||||
|
* that is missing, a topic's (`TOPIC_ICONS`), or another term's — every
|
||||||
|
* badge linking to the term shows it.
|
||||||
*
|
*
|
||||||
* 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/`,
|
||||||
* A problem outside the plan (warm-ups) has no topic, so it groups under the
|
* ordered by topic number via a script-owned `meta.ts`). A problem outside the
|
||||||
* data-structure CATEGORY its file sits under in
|
* plan (warm-ups) has no topic, so it groups under the data-structure CATEGORY
|
||||||
* `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 adding no route
|
* The phase folder is a Blume `(group)` — a sidebar section adding no route
|
||||||
@@ -49,33 +58,34 @@
|
|||||||
* `tree-dfs/(problems)/226_invert-binary-tree.mdx`: no route segment either,
|
* `tree-dfs/(problems)/226_invert-binary-tree.mdx`: no route segment either,
|
||||||
* and Blume strips the `<num>_` prefix from the route while sorting by it, so
|
* and Blume strips the `<num>_` prefix from the route while sorting by it, so
|
||||||
* the URL is still `/tree-dfs/invert-binary-tree`. Sync never writes or moves
|
* the URL is still `/tree-dfs/invert-binary-tree`. Sync never writes or moves
|
||||||
* an `index.mdx`, and never removes a folder that still holds one. An
|
* an `index.mdx`, and never removes a folder that still holds one. A phase
|
||||||
* unplanned category group has no phase and sits at the docs/ root.
|
* folder's `meta.ts` is written once, when missing, then hand-owned (its
|
||||||
*
|
* title is yours to rename); a topic's is script-owned. An unplanned category
|
||||||
* `docs/site.json` is wholly script-owned and rewritten whenever it differs:
|
* group has no phase and sits at the content root.
|
||||||
* - `folders`: Blume folder meta keyed by folder path under docs/ — exactly
|
|
||||||
* the key a `meta.ts` in that folder would have. A phase is `N. Name` in
|
|
||||||
* phase order; a topic is its exact name (the folder slug loses `/` and
|
|
||||||
* case), curriculum order, `TOPIC_ICONS` icon and `display: "page"`, so it
|
|
||||||
* is one sidebar row drilling into its own panel. `(problems)` has none:
|
|
||||||
* Blume's humanized "Problems" is the label.
|
|
||||||
* - `redirects`: one `{ from: "/<num>", to: "/<topic>/<slug>" }` per page, in
|
|
||||||
* number order — exact root-relative paths; Blume prepends the deployment
|
|
||||||
* base itself.
|
|
||||||
*
|
*
|
||||||
* 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
|
||||||
* there would otherwise wipe the page's `## Solution`.
|
* there would otherwise wipe the page's `## Solution`.
|
||||||
|
*
|
||||||
|
* Every page also gets a short link: `/<num>` → `/<topic>/<slug>` (so
|
||||||
|
* `/leetcode/217` opens Contains Duplicate). The rules live in the generated
|
||||||
|
* `apps/docs/redirects.ts`, which `blume.config.ts` imports — Blume takes exact
|
||||||
|
* root-relative paths and prepends the deployment base itself.
|
||||||
*/
|
*/
|
||||||
import { mkdir, readdir, rename, rmdir } from "node:fs/promises";
|
import { existsSync } from "node:fs";
|
||||||
|
import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
import { basename, dirname, join } from "node:path";
|
import { basename, dirname, join } from "node:path";
|
||||||
|
|
||||||
|
import { APPENDIX } from "../docs/appendix.ts";
|
||||||
|
import { RECENCY, type Company } from "../docs/companies.ts";
|
||||||
|
import { TOPIC_LANDINGS } from "../docs/topic-landings.ts";
|
||||||
import { github } from "./github.ts";
|
import { github } from "./github.ts";
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
const WORK = join(ROOT, "work", "1");
|
const WORK = join(ROOT, "work", "1");
|
||||||
const DOCS = join(ROOT, "docs");
|
const DOCS = join(ROOT, "apps", "docs", "content");
|
||||||
const MANIFEST = join(DOCS, "site.json");
|
const REDIRECTS = join(ROOT, "apps", "docs", "redirects.ts");
|
||||||
|
|
||||||
type Lang = "js" | "py";
|
type Lang = "js" | "py";
|
||||||
|
|
||||||
@@ -106,11 +116,15 @@ interface Solution {
|
|||||||
// ── work/ parsing ────────────────────────────────────────────────
|
// ── work/ parsing ────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Split a source file into (header comment text, code). */
|
/** Split a source file into (header comment text, code). */
|
||||||
function splitSource(src: string, lang: Lang): { header: string; code: string } {
|
function splitSource(
|
||||||
|
src: string,
|
||||||
|
lang: Lang,
|
||||||
|
): { header: string; code: string } {
|
||||||
let header: string, code: string;
|
let header: string, code: string;
|
||||||
if (lang === "js") {
|
if (lang === "js") {
|
||||||
const end = src.indexOf("*/");
|
const end = src.indexOf("*/");
|
||||||
if (!src.trimStart().startsWith("/*") || end === -1) throw new Error("missing /* header */");
|
if (!src.trimStart().startsWith("/*") || end === -1)
|
||||||
|
throw new Error("missing /* header */");
|
||||||
header = src
|
header = src
|
||||||
.slice(src.indexOf("/*") + 2, end)
|
.slice(src.indexOf("/*") + 2, end)
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -142,7 +156,11 @@ function paragraphs(lines: string[]): string[] {
|
|||||||
flush();
|
flush();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(text)) {
|
if (
|
||||||
|
/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
) {
|
||||||
flush();
|
flush();
|
||||||
cur = text;
|
cur = text;
|
||||||
} else {
|
} else {
|
||||||
@@ -160,7 +178,10 @@ function parseHeader(header: string): Header {
|
|||||||
const num = Number.parseInt(titleLine, 10);
|
const num = Number.parseInt(titleLine, 10);
|
||||||
const title = titleLine.replace(/^\d+\.\s*/, "");
|
const title = titleLine.replace(/^\d+\.\s*/, "");
|
||||||
const difficulty =
|
const difficulty =
|
||||||
lines.find((l) => l.trim().startsWith("Difficulty:"))?.split(":")[1]?.trim() ?? "";
|
lines
|
||||||
|
.find((l) => l.trim().startsWith("Difficulty:"))
|
||||||
|
?.split(":")[1]
|
||||||
|
?.trim() ?? "";
|
||||||
|
|
||||||
// Body: everything after the ───── separator.
|
// Body: everything after the ───── separator.
|
||||||
const sep = lines.findIndex((l) => /^[─]{3,}/.test(l.trim()));
|
const sep = lines.findIndex((l) => /^[─]{3,}/.test(l.trim()));
|
||||||
@@ -290,10 +311,18 @@ async function readmeTopics(): Promise<Map<number, Topic>> {
|
|||||||
const p = line.match(/^## Phase ([IVX]+) — (.+?) · /);
|
const p = line.match(/^## Phase ([IVX]+) — (.+?) · /);
|
||||||
if (p) {
|
if (p) {
|
||||||
const name = p[2]!.replaceAll("&", "&");
|
const name = p[2]!.replaceAll("&", "&");
|
||||||
phase = { order: ROMAN.indexOf(p[1]!) + 1, name: PHASE_ALIASES[name] ?? name };
|
phase = {
|
||||||
|
order: ROMAN.indexOf(p[1]!) + 1,
|
||||||
|
name: PHASE_ALIASES[name] ?? name,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const t = line.match(/<strong>(\d+)\.\s*([^<]+)<\/strong>/);
|
const t = line.match(/<strong>(\d+)\.\s*([^<]+)<\/strong>/);
|
||||||
if (t) topic = { order: Number(t[1]), name: TOPIC_ALIASES[t[2]!.trim()] ?? t[2]!.trim(), phase };
|
if (t)
|
||||||
|
topic = {
|
||||||
|
order: Number(t[1]),
|
||||||
|
name: TOPIC_ALIASES[t[2]!.trim()] ?? t[2]!.trim(),
|
||||||
|
phase,
|
||||||
|
};
|
||||||
if (!topic) continue;
|
if (!topic) continue;
|
||||||
const n = line.match(/<td align="center">(\d+)<\/td>/);
|
const n = line.match(/<td align="center">(\d+)<\/td>/);
|
||||||
if (n && !map.has(Number(n[1]))) map.set(Number(n[1]), topic);
|
if (n && !map.has(Number(n[1]))) map.set(Number(n[1]), topic);
|
||||||
@@ -329,34 +358,38 @@ 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)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A sidebar group's Blume folder meta, as `docs/site.json` carries it. */
|
/**
|
||||||
interface FolderMeta {
|
* Blume `(group)` meta for a phase: the overview page's heading, in phase
|
||||||
title: string;
|
* order. Written only when the folder has none — the title is hand-owned after
|
||||||
order: number;
|
* that. Nothing is lost by never rewriting it: the order and name it is built
|
||||||
icon?: string;
|
* from are also the folder name (`phaseDir`), so a README change to either
|
||||||
display?: "page";
|
* lands in a new folder that gets a fresh meta.
|
||||||
|
*/
|
||||||
|
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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Folder meta for a phase `(group)`: the overview page's heading, `N. Name`,
|
* Blume folder meta for a topic: exact label (the folder slug loses `/` and
|
||||||
* in phase order. Derived like everything in the manifest, so a README rename
|
* case), curriculum order, the sidebar icon, and `display: "page"` so the
|
||||||
* renames the heading — and lands in a new folder (`phaseDir`) anyway.
|
* topic is one row under its phase heading that drills into its own panel
|
||||||
|
* (the landing page, then its `(problems)` list). An unplanned category group
|
||||||
|
* (warm-ups) has no README number and sorts at 0, ahead of phase 1, so the
|
||||||
|
* sidebar opens on the basics.
|
||||||
*/
|
*/
|
||||||
function phaseMeta(phase: Phase): FolderMeta {
|
function groupMeta(topic: Topic): string {
|
||||||
return { title: `${phase.order}. ${phase.name}`, order: phase.order };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Folder meta for a topic: exact label, curriculum order, the sidebar icon,
|
|
||||||
* and `display: "page"` so the topic is one row under its phase heading that
|
|
||||||
* drills into its own panel (the landing page, then its `(problems)` list).
|
|
||||||
* An unplanned category group (warm-ups) has no README number and sorts at 0,
|
|
||||||
* ahead of phase 1, so the sidebar opens on the basics.
|
|
||||||
*/
|
|
||||||
function groupMeta(topic: Topic): FolderMeta {
|
|
||||||
const icon = TOPIC_ICONS[topic.name];
|
const icon = TOPIC_ICONS[topic.name];
|
||||||
if (!icon) console.warn(`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`);
|
if (!icon)
|
||||||
return { title: topic.name, order: topic.order ?? 0, ...(icon ? { icon } : {}), display: "page" };
|
console.warn(
|
||||||
|
`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`,
|
||||||
|
);
|
||||||
|
const fields = [
|
||||||
|
`title: ${JSON.stringify(topic.name)}`,
|
||||||
|
`order: ${topic.order ?? 0}`,
|
||||||
|
];
|
||||||
|
if (icon) fields.push(`icon: ${JSON.stringify(icon)}`);
|
||||||
|
fields.push(`display: "group"`);
|
||||||
|
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ ${fields.join(", ")} });\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── LeetCode topic tags ──────────────────────────────────────────
|
// ── LeetCode topic tags ──────────────────────────────────────────
|
||||||
@@ -382,17 +415,24 @@ async function fetchTopicTags(sol: Solution): Promise<TopicTag[] | undefined> {
|
|||||||
referer: `https://leetcode.com/problems/${sol.slug}/`,
|
referer: `https://leetcode.com/problems/${sol.slug}/`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
query: "query topicTags($titleSlug: String!) { question(titleSlug: $titleSlug) { topicTags { name slug } } }",
|
query:
|
||||||
|
"query topicTags($titleSlug: String!) { question(titleSlug: $titleSlug) { topicTags { name slug } } }",
|
||||||
variables: { titleSlug: sol.slug },
|
variables: { titleSlug: sol.slug },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`POST /graphql -> ${res.status} ${res.statusText}`);
|
if (!res.ok)
|
||||||
const data = (await res.json()) as { data?: { question?: { topicTags?: TopicTag[] } | null } };
|
throw new Error(`POST /graphql -> ${res.status} ${res.statusText}`);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
data?: { question?: { topicTags?: TopicTag[] } | null };
|
||||||
|
};
|
||||||
const tags = data.data?.question?.topicTags;
|
const tags = data.data?.question?.topicTags;
|
||||||
if (!Array.isArray(tags)) throw new Error("POST /graphql -> no such question");
|
if (!Array.isArray(tags))
|
||||||
|
throw new Error("POST /graphql -> no such question");
|
||||||
return tags.map(({ name, slug }) => ({ name, slug }));
|
return tags.map(({ name, slug }) => ({ name, slug }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`no topics for ${sol.num}.${sol.slug}: ${err instanceof Error ? err.message : err}`);
|
console.warn(
|
||||||
|
`no topics for ${sol.num}.${sol.slug}: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,7 +440,30 @@ async function fetchTopicTags(sol: Solution): Promise<TopicTag[] | undefined> {
|
|||||||
/** The frontmatter `topics:` block, one flow mapping per tag (JSON strings are valid YAML). */
|
/** The frontmatter `topics:` block, one flow mapping per tag (JSON strings are valid YAML). */
|
||||||
function topicsYaml(tags: TopicTag[]): string {
|
function topicsYaml(tags: TopicTag[]): string {
|
||||||
if (tags.length === 0) return "topics: []";
|
if (tags.length === 0) return "topics: []";
|
||||||
return ["topics:", ...tags.map((t) => ` - { name: ${JSON.stringify(t.name)}, slug: ${JSON.stringify(t.slug)} }`)].join("\n");
|
return [
|
||||||
|
"topics:",
|
||||||
|
...tags.map(
|
||||||
|
(t) =>
|
||||||
|
` - { name: ${JSON.stringify(t.name)}, slug: ${JSON.stringify(t.slug)} }`,
|
||||||
|
),
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The frontmatter's `topics:` as sync wrote them (or a human edited them); [] when absent. */
|
||||||
|
function frontmatterTopics(page: string): TopicTag[] {
|
||||||
|
const fm = page.match(/^---\n([\s\S]*?)\n---/)?.[1];
|
||||||
|
const data: unknown = fm ? Bun.YAML.parse(fm) : undefined;
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
typeof data !== "object" ||
|
||||||
|
!("topics" in data) ||
|
||||||
|
!Array.isArray(data.topics)
|
||||||
|
)
|
||||||
|
return [];
|
||||||
|
return data.topics.filter(
|
||||||
|
(t): t is TopicTag =>
|
||||||
|
typeof t?.name === "string" && typeof t?.slug === "string",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GitHub problem issues ────────────────────────────────────────
|
// ── GitHub problem issues ────────────────────────────────────────
|
||||||
@@ -418,7 +481,9 @@ function problemIssues(): Promise<Map<number, string> | undefined> {
|
|||||||
try {
|
try {
|
||||||
const gh = await github();
|
const gh = await github();
|
||||||
const urls = new Map<number, string>();
|
const urls = new Map<number, string>();
|
||||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=all`)) {
|
for await (const raw of gh.list(
|
||||||
|
`/repos/${gh.repo}/issues?labels=problem&state=all`,
|
||||||
|
)) {
|
||||||
if (!raw || typeof raw !== "object" || "pull_request" in raw) continue;
|
if (!raw || typeof raw !== "object" || "pull_request" in raw) continue;
|
||||||
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||||
if (!("html_url" in raw) || typeof raw.html_url !== "string") continue;
|
if (!("html_url" in raw) || typeof raw.html_url !== "string") continue;
|
||||||
@@ -427,7 +492,9 @@ function problemIssues(): Promise<Map<number, string> | undefined> {
|
|||||||
}
|
}
|
||||||
return urls;
|
return urls;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`no problem issues: ${err instanceof Error ? err.message : err}`);
|
console.warn(
|
||||||
|
`no problem issues: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -444,23 +511,84 @@ async function issueUrl(sol: Solution): Promise<string | undefined> {
|
|||||||
if (!topics.has(sol.num)) return undefined;
|
if (!topics.has(sol.num)) return undefined;
|
||||||
const urls = await problemIssues();
|
const urls = await problemIssues();
|
||||||
const url = urls?.get(sol.num);
|
const url = urls?.get(sol.num);
|
||||||
if (urls && !url) console.warn(`no problem issue titled "LC ${sol.num} · …" for ${sol.num}.${sol.slug}`);
|
if (urls && !url)
|
||||||
|
console.warn(
|
||||||
|
`no problem issue titled "LC ${sol.num} · …" for ${sol.num}.${sol.slug}`,
|
||||||
|
);
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── company frequency ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// The windows and the key's shape are `apps/docs/companies.ts`, shared with
|
||||||
|
// the schema and the readers; `More Than Six Months` and `All` are not recent.
|
||||||
|
const COMPANY_DB = join(
|
||||||
|
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
|
||||||
|
"duckdb",
|
||||||
|
"data",
|
||||||
|
"leetcode-company-frequency.db",
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LC number → the companies that asked it in the last six months, each at the
|
||||||
|
* most recent window it appears in (the windows nest), ordered most recent
|
||||||
|
* first, then by that window's frequency. Undefined when this machine has no
|
||||||
|
* database or no `duckdb` CLI — the step is skipped — or the query fails
|
||||||
|
* (warned). One query for every solved problem.
|
||||||
|
*/
|
||||||
|
function recentCompanies(nums: number[]): Map<number, Company[]> | undefined {
|
||||||
|
if (!existsSync(COMPANY_DB) || !Bun.which("duckdb")) return undefined;
|
||||||
|
const order = `list_position([${RECENCY.map((p) => `'${p}'`).join(", ")}], period)`;
|
||||||
|
const sql = `SELECT question_id, company, period, frequency FROM company_frequency
|
||||||
|
WHERE question_id IN (${nums.join(", ")}) AND ${order} IS NOT NULL
|
||||||
|
QUALIFY row_number() OVER (PARTITION BY question_id, company ORDER BY ${order}) = 1
|
||||||
|
ORDER BY question_id, ${order}, frequency DESC, company`;
|
||||||
|
const proc = Bun.spawnSync(["duckdb", "-readonly", "-json", COMPANY_DB, "-c", sql]);
|
||||||
|
if (!proc.success) {
|
||||||
|
console.warn(`no companies: duckdb -> ${proc.stderr.toString().trim()}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const out = proc.stdout.toString().trim();
|
||||||
|
const rows: unknown = out ? JSON.parse(out) : [];
|
||||||
|
const companies = new Map<number, Company[]>(nums.map((n) => [n, []]));
|
||||||
|
for (const row of Array.isArray(rows) ? rows : []) {
|
||||||
|
if (!row || typeof row !== "object") continue;
|
||||||
|
if (!("question_id" in row) || typeof row.question_id !== "number") continue;
|
||||||
|
if (!("company" in row) || typeof row.company !== "string") continue;
|
||||||
|
if (!("period" in row) || !("frequency" in row) || typeof row.frequency !== "number") continue;
|
||||||
|
const period = RECENCY.find((p) => p === row.period);
|
||||||
|
if (period)
|
||||||
|
companies.get(row.question_id)?.push({ name: row.company, period, frequency: row.frequency });
|
||||||
|
}
|
||||||
|
return companies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The frontmatter `companies:` block, one flow mapping per company (JSON strings are valid YAML). */
|
||||||
|
function companiesYaml(list: Company[]): string {
|
||||||
|
return [
|
||||||
|
"companies:",
|
||||||
|
...list.map(
|
||||||
|
(c) =>
|
||||||
|
` - { name: ${JSON.stringify(c.name)}, period: ${JSON.stringify(c.period)}, frequency: ${c.frequency} }`,
|
||||||
|
),
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
// ── 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. */
|
||||||
function identifiers(sol: Solution): Set<string> {
|
function identifiers(sol: Solution): Set<string> {
|
||||||
const ids = new Set<string>(["n", "m", "k"]);
|
const ids = new Set<string>(["n", "m", "k"]);
|
||||||
for (const code of Object.values(sol.code)) {
|
for (const code of Object.values(sol.code)) {
|
||||||
for (const m of code.matchAll(/@param\s*\{[^}]*\}\s*(\w+)/g)) ids.add(m[1]!);
|
for (const m of code.matchAll(/@param\s*\{[^}]*\}\s*(\w+)/g))
|
||||||
|
ids.add(m[1]!);
|
||||||
for (const m of code.matchAll(/def \w+\(self,?\s*([^)]*)\)/g))
|
for (const m of code.matchAll(/def \w+\(self,?\s*([^)]*)\)/g))
|
||||||
for (const arg of m[1]!.split(","))
|
for (const arg of m[1]!.split(","))
|
||||||
if (arg.trim()) ids.add(arg.split(":")[0]!.trim());
|
if (arg.trim()) ids.add(arg.split(":")[0]!.trim());
|
||||||
for (const m of code.matchAll(/(?:var|const|let) (\w+) = function\s*\(([^)]*)\)/g))
|
for (const m of code.matchAll(
|
||||||
for (const arg of m[2]!.split(","))
|
/(?:var|const|let) (\w+) = function\s*\(([^)]*)\)/g,
|
||||||
if (arg.trim()) ids.add(arg.trim());
|
))
|
||||||
|
for (const arg of m[2]!.split(",")) if (arg.trim()) ids.add(arg.trim());
|
||||||
}
|
}
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
@@ -517,7 +645,11 @@ function backtickify(text: string, ids: Set<string>): string {
|
|||||||
while (j > i && /^[+\-*/=%]$/.test(toks[j]!.core)) j--;
|
while (j > i && /^[+\-*/=%]$/.test(toks[j]!.core)) j--;
|
||||||
const span = toks
|
const span = toks
|
||||||
.slice(i, j + 1)
|
.slice(i, j + 1)
|
||||||
.map((tok, idx, arr) => (idx === arr.length - 1 ? `${tok.pre}${tok.core}` : `${tok.pre}${tok.core}${tok.post}`))
|
.map((tok, idx, arr) =>
|
||||||
|
idx === arr.length - 1
|
||||||
|
? `${tok.pre}${tok.core}`
|
||||||
|
: `${tok.pre}${tok.core}${tok.post}`,
|
||||||
|
)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
out.push(`\`${span}\`${toks[j]!.post}`);
|
out.push(`\`${span}\`${toks[j]!.post}`);
|
||||||
i = j + 1;
|
i = j + 1;
|
||||||
@@ -545,14 +677,23 @@ function warningText(h: Header): string {
|
|||||||
return (
|
return (
|
||||||
h.statement
|
h.statement
|
||||||
.slice(1)
|
.slice(1)
|
||||||
.find((p) => /^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(p)) ?? ""
|
.find((p) =>
|
||||||
|
/^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(
|
||||||
|
p,
|
||||||
|
),
|
||||||
|
) ?? ""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */
|
/** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */
|
||||||
const PAGE_ICON = "dot";
|
const PAGE_ICON = "dot";
|
||||||
|
|
||||||
function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string | undefined): string {
|
function renderPage(
|
||||||
|
sol: Solution,
|
||||||
|
tags: TopicTag[] | undefined,
|
||||||
|
issue: string | undefined,
|
||||||
|
companies: Company[] | undefined,
|
||||||
|
): string {
|
||||||
const h = sol.header;
|
const h = sol.header;
|
||||||
const ids = identifiers(sol);
|
const ids = identifiers(sol);
|
||||||
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
|
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
|
||||||
@@ -561,10 +702,13 @@ function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string |
|
|||||||
parts.push(
|
parts.push(
|
||||||
"---",
|
"---",
|
||||||
`title: '${h.num}. ${h.title.replaceAll("'", "''")}'`,
|
`title: '${h.num}. ${h.title.replaceAll("'", "''")}'`,
|
||||||
/[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`,
|
/[:#]/.test(description)
|
||||||
|
? `description: '${description.replaceAll("'", "''")}'`
|
||||||
|
: `description: ${description}`,
|
||||||
`icon: ${PAGE_ICON}`,
|
`icon: ${PAGE_ICON}`,
|
||||||
...(tags ? [topicsYaml(tags)] : []),
|
...(tags ? [topicsYaml(tags)] : []),
|
||||||
...(issue ? [`issue: ${JSON.stringify(issue)}`] : []),
|
...(issue ? [`issue: ${JSON.stringify(issue)}`] : []),
|
||||||
|
...(companies?.length ? [companiesYaml(companies)] : []),
|
||||||
"sidebar:",
|
"sidebar:",
|
||||||
` label: '${h.title.replaceAll("'", "''")}'`,
|
` label: '${h.title.replaceAll("'", "''")}'`,
|
||||||
` badge: '${h.difficulty}'`,
|
` badge: '${h.difficulty}'`,
|
||||||
@@ -583,7 +727,8 @@ function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string |
|
|||||||
parts.push(`- Explanation: ${backtickify(ex.explanation[0]!, ids)}`);
|
parts.push(`- Explanation: ${backtickify(ex.explanation[0]!, ids)}`);
|
||||||
else if (ex.explanation.length > 1) {
|
else if (ex.explanation.length > 1) {
|
||||||
parts.push("- Explanation:");
|
parts.push("- Explanation:");
|
||||||
for (const item of ex.explanation) parts.push(` - ${backtickify(item, ids)}`);
|
for (const item of ex.explanation)
|
||||||
|
parts.push(` - ${backtickify(item, ids)}`);
|
||||||
}
|
}
|
||||||
parts.push("");
|
parts.push("");
|
||||||
});
|
});
|
||||||
@@ -608,7 +753,10 @@ for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
|||||||
}
|
}
|
||||||
const num = Number(m[1]);
|
const num = Number(m[1]);
|
||||||
const slug = m[2]!;
|
const slug = m[2]!;
|
||||||
const { header, code } = splitSource(await Bun.file(join(WORK, rel)).text(), lang);
|
const { header, code } = splitSource(
|
||||||
|
await Bun.file(join(WORK, rel)).text(),
|
||||||
|
lang,
|
||||||
|
);
|
||||||
const parsed = parseHeader(header);
|
const parsed = parseHeader(header);
|
||||||
const existing = solutions.get(num);
|
const existing = solutions.get(num);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -626,6 +774,7 @@ for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const topics = await readmeTopics();
|
const topics = await readmeTopics();
|
||||||
|
const companies = recentCompanies([...solutions.keys()]);
|
||||||
// An unplanned problem whose work/ category is also a README topic name (a
|
// An unplanned problem whose work/ category is also a README topic name (a
|
||||||
// `Hash Table` warm-up) files under that topic, in its phase, not a second
|
// `Hash Table` warm-up) files under that topic, in its phase, not a second
|
||||||
// root-level `hash-table/`.
|
// root-level `hash-table/`.
|
||||||
@@ -657,7 +806,10 @@ async function ensureTopics(page: string, sol: Solution): Promise<string> {
|
|||||||
if (!fm || /^topics:/m.test(fm[0])) return page;
|
if (!fm || /^topics:/m.test(fm[0])) return page;
|
||||||
const tags = await fetchTopicTags(sol);
|
const tags = await fetchTopicTags(sol);
|
||||||
if (!tags) return page;
|
if (!tags) return page;
|
||||||
return fm[0].replace(/^sidebar:$/m, `${topicsYaml(tags)}\nsidebar:`) + page.slice(fm[0].length);
|
return (
|
||||||
|
fm[0].replace(/^sidebar:$/m, `${topicsYaml(tags)}\nsidebar:`) +
|
||||||
|
page.slice(fm[0].length)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Insert `issue:` before `sidebar:` when the frontmatter lacks it; an existing URL is kept as is. */
|
/** Insert `issue:` before `sidebar:` when the frontmatter lacks it; an existing URL is kept as is. */
|
||||||
@@ -666,7 +818,27 @@ async function ensureIssue(page: string, sol: Solution): Promise<string> {
|
|||||||
if (!fm || /^issue:/m.test(fm[0])) return page;
|
if (!fm || /^issue:/m.test(fm[0])) return page;
|
||||||
const url = await issueUrl(sol);
|
const url = await issueUrl(sol);
|
||||||
if (!url) return page;
|
if (!url) return page;
|
||||||
return fm[0].replace(/^sidebar:$/m, `issue: ${JSON.stringify(url)}\nsidebar:`) + page.slice(fm[0].length);
|
return (
|
||||||
|
fm[0].replace(/^sidebar:$/m, `issue: ${JSON.stringify(url)}\nsidebar:`) +
|
||||||
|
page.slice(fm[0].length)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the frontmatter's `companies:` with this run's list, inserted before
|
||||||
|
* `sidebar:` (dropped when no company asked lately). Without the database the
|
||||||
|
* page is returned untouched, so an environment lacking it never strips the key.
|
||||||
|
*/
|
||||||
|
function ensureCompanies(page: string, sol: Solution): string {
|
||||||
|
if (!companies) return page;
|
||||||
|
const fm = page.match(/^---\n[\s\S]*?\n---/);
|
||||||
|
if (!fm) return page;
|
||||||
|
const list = companies.get(sol.num) ?? [];
|
||||||
|
const without = fm[0].replace(/^companies:.*\n(?: {2}- .*\n)*/m, "");
|
||||||
|
const next = list.length
|
||||||
|
? without.replace(/^sidebar:$/m, `${companiesYaml(list)}\nsidebar:`)
|
||||||
|
: without;
|
||||||
|
return next + page.slice(fm[0].length);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -689,7 +861,9 @@ function solutionEnd(page: string, from: number): number {
|
|||||||
|
|
||||||
// Existing docs pages, keyed by leetcode number from the frontmatter title.
|
// Existing docs pages, keyed by leetcode number from the frontmatter title.
|
||||||
const pages = new Map<number, string>(); // num -> path relative to DOCS
|
const pages = new Map<number, string>(); // num -> path relative to DOCS
|
||||||
for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).sort()) {
|
for (const rel of (
|
||||||
|
await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))
|
||||||
|
).sort()) {
|
||||||
const text = await Bun.file(join(DOCS, rel)).text();
|
const text = await Bun.file(join(DOCS, rel)).text();
|
||||||
const t = text.match(/^title: '(\d+)\./m);
|
const t = text.match(/^title: '(\d+)\./m);
|
||||||
if (t) pages.set(Number(t[1]), rel);
|
if (t) pages.set(Number(t[1]), rel);
|
||||||
@@ -697,9 +871,11 @@ for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).s
|
|||||||
|
|
||||||
const report: [string, string, string][] = [];
|
const report: [string, string, string][] = [];
|
||||||
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
|
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
|
||||||
const folders = new Map<string, FolderMeta>(); // folder under docs/ -> its sidebar meta, for site.json
|
const groups = new Map<string, Topic>(); // topic folder -> topic, for meta.ts
|
||||||
|
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: { from: string; to: string }[] = []; // one per page, 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
|
||||||
|
|
||||||
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("+");
|
||||||
@@ -707,14 +883,16 @@ for (const sol of ordered) {
|
|||||||
// (0N-<phase>)/<topic>/(problems)/ folder (the topic is a real route segment,
|
// (0N-<phase>)/<topic>/(problems)/ folder (the topic is a real route segment,
|
||||||
// so its index.mdx is the landing page; `(problems)` adds none) +
|
// so its index.mdx is the landing page; `(problems)` adds none) +
|
||||||
// leetcode-number prefix: numeric sidebar order, prefix stripped from the URL.
|
// leetcode-number prefix: numeric sidebar order, prefix stripped from the URL.
|
||||||
const topic = topics.get(sol.num) ?? topicByName.get(sol.category) ?? { name: sol.category };
|
const topic = topics.get(sol.num) ??
|
||||||
|
topicByName.get(sol.category) ?? { name: sol.category };
|
||||||
const dir = topicDir(topic);
|
const dir = topicDir(topic);
|
||||||
if (!folders.has(dir)) folders.set(dir, groupMeta(topic));
|
groups.set(dir, topic);
|
||||||
if (topic.phase) folders.set(phaseDir(topic.phase), phaseMeta(topic.phase));
|
if (topic.phase) phases.set(phaseDir(topic.phase), topic.phase);
|
||||||
const name = `${dir}/(problems)/${sol.num}_${sol.slug}.mdx`;
|
const name = `${dir}/${sol.num}_${sol.slug}.mdx`;
|
||||||
const existing = pages.get(sol.num);
|
const existing = pages.get(sol.num);
|
||||||
await mkdir(join(DOCS, dirname(name)), { recursive: true });
|
await mkdir(join(DOCS, dirname(name)), { 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");
|
||||||
@@ -723,8 +901,15 @@ for (const sol of ordered) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const tail = page.slice(solutionEnd(page, at));
|
const tail = page.slice(solutionEnd(page, at));
|
||||||
const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
|
const body =
|
||||||
const next = await ensureIssue(await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol), sol);
|
page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
|
||||||
|
const next = ensureCompanies(
|
||||||
|
await ensureIssue(
|
||||||
|
await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol),
|
||||||
|
sol,
|
||||||
|
),
|
||||||
|
sol,
|
||||||
|
);
|
||||||
const actions: string[] = [];
|
const actions: string[] = [];
|
||||||
if (existing !== name) {
|
if (existing !== name) {
|
||||||
await rename(join(DOCS, existing), join(DOCS, name));
|
await rename(join(DOCS, existing), join(DOCS, name));
|
||||||
@@ -736,41 +921,115 @@ 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)"]);
|
||||||
|
written = next;
|
||||||
} else {
|
} else {
|
||||||
await Bun.write(join(DOCS, name), renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol)));
|
const page = renderPage(
|
||||||
|
sol,
|
||||||
|
await fetchTopicTags(sol),
|
||||||
|
await issueUrl(sol),
|
||||||
|
companies?.get(sol.num),
|
||||||
|
);
|
||||||
|
await Bun.write(join(DOCS, name), page);
|
||||||
report.push([workRef, name, "created"]);
|
report.push([workRef, name, "created"]);
|
||||||
|
written = page;
|
||||||
}
|
}
|
||||||
// The route drops the phase `(group)`, `(problems)` and the number prefix.
|
for (const tag of frontmatterTopics(written)) tags.set(tag.slug, tag);
|
||||||
redirects.push({ from: `/${sol.num}`, to: `/${slugify(topic.name)}/${sol.slug}` });
|
// 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}`)} },`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The site's one generated input besides the pages; script-owned, rewritten
|
// Script-owned short links, one per page; imported by blume.config.ts.
|
||||||
// only when it differs. Folders sorted by path so a diff reads by location.
|
|
||||||
{
|
{
|
||||||
const manifest = `${JSON.stringify(
|
const module = [
|
||||||
{ folders: Object.fromEntries([...folders].sort(([a], [b]) => (a < b ? -1 : 1))), redirects },
|
"// Generated by `bun run sync` (apps/cli/sync.ts) — do not edit.",
|
||||||
null,
|
"// `/leetcode/<num>` → that problem's page; exact paths, base added by Blume.",
|
||||||
2,
|
"export default [",
|
||||||
)}\n`;
|
...redirects,
|
||||||
if (!(await Bun.file(MANIFEST).exists()) || (await Bun.file(MANIFEST).text()) !== manifest) {
|
"];",
|
||||||
await Bun.write(MANIFEST, manifest);
|
"",
|
||||||
report.push([`${folders.size} folders, ${redirects.length} redirects`, "docs/site.json", "written"]);
|
].join("\n");
|
||||||
|
if (
|
||||||
|
!(await Bun.file(REDIRECTS).exists()) ||
|
||||||
|
(await Bun.file(REDIRECTS).text()) !== module
|
||||||
|
) {
|
||||||
|
await Bun.write(REDIRECTS, module);
|
||||||
|
report.push([
|
||||||
|
`${redirects.length} redirects`,
|
||||||
|
"apps/docs/redirects.ts",
|
||||||
|
"written",
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A folder every page left behind is dead: drop it, then the folders above it
|
// Every carried tag no campaign topic teaches is an Appendix term, filed by
|
||||||
// that emptied too (`(problems)`, its topic, the phase). A folder that still
|
// hand in apps/docs/appendix.ts: warn about the gaps and the dead weight. A
|
||||||
// holds anything (a hand-written index.mdx, another topic) stops the climb.
|
// term's icon is also the icon on every problem-page badge linking to its
|
||||||
|
// card, so it must not be a topic group's (`TOPIC_ICONS`), so a term badge
|
||||||
|
// never reads as a topic's, nor another term's.
|
||||||
|
for (const tag of [...tags.values()].sort((a, b) =>
|
||||||
|
a.slug.localeCompare(b.slug),
|
||||||
|
)) {
|
||||||
|
if (!TOPIC_LANDINGS[tag.slug] && !APPENDIX[tag.slug])
|
||||||
|
console.warn(
|
||||||
|
`tag ${tag.slug}: no Appendix term; add ${JSON.stringify(tag.name)} to apps/docs/appendix.ts`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const topicIcons = new Set(Object.values(TOPIC_ICONS));
|
||||||
|
const termIcons = new Map<string, string>(); // icon -> the term that took it first
|
||||||
|
for (const [slug, term] of Object.entries(APPENDIX)) {
|
||||||
|
if (TOPIC_LANDINGS[slug])
|
||||||
|
console.warn(
|
||||||
|
`appendix ${slug}: a campaign topic teaches it now; delete its term`,
|
||||||
|
);
|
||||||
|
if (topicIcons.has(term.icon))
|
||||||
|
console.warn(
|
||||||
|
`appendix ${slug}: icon ${JSON.stringify(term.icon)} is a topic's (TOPIC_ICONS); pick another`,
|
||||||
|
);
|
||||||
|
else if (termIcons.has(term.icon))
|
||||||
|
console.warn(
|
||||||
|
`appendix ${slug}: icon ${JSON.stringify(term.icon)} is ${termIcons.get(term.icon)}'s too; pick another`,
|
||||||
|
);
|
||||||
|
else termIcons.set(term.icon, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 — rewritten only when
|
||||||
|
// they differ.
|
||||||
|
const metas: [label: string, dir: string, meta: string][] = [...groups].map(
|
||||||
|
([dir, topic]) => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)],
|
||||||
|
);
|
||||||
|
for (const [label, dir, meta] of metas) {
|
||||||
|
const path = join(DOCS, dir, "meta.ts");
|
||||||
|
if ((await Bun.file(path).exists()) && (await Bun.file(path).text()) === meta)
|
||||||
|
continue;
|
||||||
|
await Bun.write(path, meta);
|
||||||
|
report.push([label, `${dir}/meta.ts`, "written"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A folder every page left behind is dead: drop its meta and the folder itself,
|
||||||
|
// then the phase folder above it if that emptied too. A folder that still holds
|
||||||
|
// anything else (a hand-written index.mdx, another topic) stops the climb.
|
||||||
for (const start of vacated) {
|
for (const start of vacated) {
|
||||||
for (let dir = start; dir !== "."; dir = dirname(dir)) {
|
for (let dir = start; dir !== "."; dir = dirname(dir)) {
|
||||||
if (folders.has(dir)) break;
|
if (groups.has(dir) || phases.has(dir)) break;
|
||||||
let left: string[];
|
let left: string[];
|
||||||
try {
|
try {
|
||||||
left = await readdir(join(DOCS, dir));
|
left = await readdir(join(DOCS, dir));
|
||||||
} catch {
|
} catch {
|
||||||
break; // already removed via a sibling's climb
|
break; // already removed via a sibling's climb
|
||||||
}
|
}
|
||||||
if (left.length) break;
|
if (left.some((f) => f !== "meta.ts")) break;
|
||||||
|
if (left.length) await unlink(join(DOCS, dir, "meta.ts"));
|
||||||
await rmdir(join(DOCS, dir));
|
await rmdir(join(DOCS, dir));
|
||||||
report.push(["", dir, "removed (empty)"]);
|
report.push(["", dir, "removed (empty)"]);
|
||||||
}
|
}
|
||||||
@@ -780,4 +1039,5 @@ const w0 = Math.max(...report.map((r) => r[0].length), 9);
|
|||||||
const w1 = Math.max(...report.map((r) => r[1].length), 9);
|
const w1 = Math.max(...report.map((r) => r[1].length), 9);
|
||||||
console.log(`${"work file".padEnd(w0)} ${"docs page".padEnd(w1)} status`);
|
console.log(`${"work file".padEnd(w0)} ${"docs page".padEnd(w1)} status`);
|
||||||
console.log(`${"─".repeat(w0)} ${"─".repeat(w1)} ${"─".repeat(20)}`);
|
console.log(`${"─".repeat(w0)} ${"─".repeat(w1)} ${"─".repeat(20)}`);
|
||||||
for (const [a, b, c] of report) console.log(`${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`);
|
for (const [a, b, c] of report)
|
||||||
|
console.log(`${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`);
|
||||||
+3
-3
@@ -3,8 +3,8 @@
|
|||||||
* 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 site's MDX `wrapper`
|
* NeetCode's walkthrough of the problem — which the docs MDX `wrapper`
|
||||||
* (prdlk/cs `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
|
||||||
* one. This script lists every numbered page lacking `video:` and looks each
|
* one. This script lists every numbered page lacking `video:` and looks each
|
||||||
@@ -25,7 +25,7 @@ import { join } from "node:path";
|
|||||||
import { printTable } from "./report.ts";
|
import { printTable } from "./report.ts";
|
||||||
import { ROOT } from "./work.ts";
|
import { ROOT } from "./work.ts";
|
||||||
|
|
||||||
const DOCS = join(ROOT, "docs");
|
const DOCS = join(ROOT, "apps", "docs", "content");
|
||||||
const WRITE = process.argv.includes("--write");
|
const WRITE = process.argv.includes("--write");
|
||||||
|
|
||||||
/** Channel handles searched in order; the first exact match wins. */
|
/** Channel handles searched in order; the first exact match wins. */
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
.blume-verify/
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* The Appendix glossary: one term per LeetCode topic tag that problem pages
|
||||||
|
* carry but no campaign topic teaches (not a key of `topic-landings.ts`).
|
||||||
|
* `pages/appendix.astro` renders each as a card — icon, category, definition,
|
||||||
|
* and the solved problems carrying the tag — anchored at `/appendix#<slug>`,
|
||||||
|
* which is where `tagTarget()` sends that tag's badge on a problem page.
|
||||||
|
*
|
||||||
|
* Hand-written: `apps/cli/sync.ts` only checks it, warning about a carried
|
||||||
|
* tag with no entry (its card renders bare), an entry whose tag a campaign
|
||||||
|
* topic now teaches (dead weight, delete it), and an icon that is a topic's
|
||||||
|
* (`TOPIC_ICONS`) or another term's — the icon is also the one on every
|
||||||
|
* badge linking here, so it must not read as anything else. Plain data, no
|
||||||
|
* Astro imports: sync imports this file under Bun.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AppendixCategory = "Data Structures" | "Algorithms" | "Techniques";
|
||||||
|
|
||||||
|
export interface AppendixTerm {
|
||||||
|
/** LeetCode's tag name. */
|
||||||
|
name: string;
|
||||||
|
/** Lucide icon, unique across terms and topics. */
|
||||||
|
icon: string;
|
||||||
|
category: AppendixCategory;
|
||||||
|
/** One sentence: what the term means, the way a solution uses it. */
|
||||||
|
definition: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APPENDIX: Record<string, AppendixTerm> = {
|
||||||
|
// ── data structures ──────────────────────────────────────────────
|
||||||
|
array: {
|
||||||
|
name: "Array",
|
||||||
|
icon: "list-ordered",
|
||||||
|
category: "Data Structures",
|
||||||
|
definition: "A contiguous, index-addressed sequence: O(1) access by position, O(n) to insert or delete in the middle.",
|
||||||
|
},
|
||||||
|
"binary-tree": {
|
||||||
|
name: "Binary Tree",
|
||||||
|
icon: "git-branch",
|
||||||
|
category: "Data Structures",
|
||||||
|
definition: "A rooted tree in which every node has at most two children, left and right.",
|
||||||
|
},
|
||||||
|
"directed-acyclic-graph": {
|
||||||
|
name: "Directed Acyclic Graph",
|
||||||
|
icon: "route",
|
||||||
|
category: "Data Structures",
|
||||||
|
definition: "A directed graph with no cycles, which is exactly what lets its nodes be put in a topological order.",
|
||||||
|
},
|
||||||
|
"doubly-linked-list": {
|
||||||
|
name: "Doubly-Linked List",
|
||||||
|
icon: "link-2",
|
||||||
|
category: "Data Structures",
|
||||||
|
definition: "A linked list whose nodes point to both neighbours, so a node you already hold is unlinked in O(1).",
|
||||||
|
},
|
||||||
|
graph: {
|
||||||
|
name: "Graph Theory",
|
||||||
|
icon: "share-2",
|
||||||
|
category: "Data Structures",
|
||||||
|
definition: "Nodes joined by edges, directed or not, weighted or not, stored as an adjacency list or matrix.",
|
||||||
|
},
|
||||||
|
"k-d-tree": {
|
||||||
|
name: "K-D Tree",
|
||||||
|
icon: "axis-3d",
|
||||||
|
category: "Data Structures",
|
||||||
|
definition: "A binary tree that splits k-dimensional points on alternating axes, for nearest-neighbour and range queries.",
|
||||||
|
},
|
||||||
|
queue: {
|
||||||
|
name: "Queue",
|
||||||
|
icon: "list-end",
|
||||||
|
category: "Data Structures",
|
||||||
|
definition: "First in, first out: push at the back, pop from the front, both O(1).",
|
||||||
|
},
|
||||||
|
string: {
|
||||||
|
name: "String",
|
||||||
|
icon: "quote",
|
||||||
|
category: "Data Structures",
|
||||||
|
definition: "A sequence of characters; solutions lean on its small alphabet, its substrings, and its immutability.",
|
||||||
|
},
|
||||||
|
tree: {
|
||||||
|
name: "Tree",
|
||||||
|
icon: "tree-deciduous",
|
||||||
|
category: "Data Structures",
|
||||||
|
definition: "A connected acyclic graph, usually rooted, in which every node but the root has exactly one parent.",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── algorithms ───────────────────────────────────────────────────
|
||||||
|
"bidirectional-search": {
|
||||||
|
name: "Bidirectional Search",
|
||||||
|
icon: "move-horizontal",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "Search from the start and the goal at once and stop where the frontiers meet, about 2·b^(d/2) states instead of b^d.",
|
||||||
|
},
|
||||||
|
"binary-lifting": {
|
||||||
|
name: "Binary Lifting",
|
||||||
|
icon: "chevrons-up",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "Precompute every node's 2^k-th ancestor so any k-th ancestor, and so the LCA, takes O(log n) jumps.",
|
||||||
|
},
|
||||||
|
"bit-manipulation": {
|
||||||
|
name: "Bit Manipulation",
|
||||||
|
icon: "binary",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "Treat integers as bit vectors: masks, shifts, and XOR in place of arithmetic or extra memory.",
|
||||||
|
},
|
||||||
|
"bracket-sequences": {
|
||||||
|
name: "Bracket Sequences",
|
||||||
|
icon: "parentheses",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "Strings of opening and closing brackets; balanced means the running depth never dips below zero and ends at zero.",
|
||||||
|
},
|
||||||
|
"bucket-sort": {
|
||||||
|
name: "Bucket Sort",
|
||||||
|
icon: "container",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "Scatter values into buckets by range or frequency, then read the buckets in order: linear when the key range is bounded.",
|
||||||
|
},
|
||||||
|
"complete-knapsack": {
|
||||||
|
name: "Complete Knapsack",
|
||||||
|
icon: "package-plus",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "Knapsack in which every item may be taken any number of times, so the DP walks capacity upward and reuses the row.",
|
||||||
|
},
|
||||||
|
"divide-and-conquer": {
|
||||||
|
name: "Divide and Conquer",
|
||||||
|
icon: "split",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "Split the input into independent parts, solve each recursively, and combine their answers.",
|
||||||
|
},
|
||||||
|
"floyds-cycle-finding-algorithm": {
|
||||||
|
name: "Floyd's Cycle Finding Algorithm",
|
||||||
|
icon: "repeat",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "A slow and a fast pointer meet inside any cycle; restarting one from the head finds the cycle's entry, in O(1) space.",
|
||||||
|
},
|
||||||
|
"knapsack-problem": {
|
||||||
|
name: "Knapsack Problem",
|
||||||
|
icon: "backpack",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "Pick items with weights and values to maximise value under a capacity; the 0/1 version is an O(n·W) DP.",
|
||||||
|
},
|
||||||
|
"longest-common-subsequence": {
|
||||||
|
name: "Longest Common Subsequence",
|
||||||
|
icon: "diff",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "The longest sequence that appears in order, not necessarily contiguously, in two strings: the classic O(n·m) 2-D DP.",
|
||||||
|
},
|
||||||
|
"lowest-common-ancestor": {
|
||||||
|
name: "Lowest Common Ancestor",
|
||||||
|
icon: "git-merge",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "The deepest node of a rooted tree that is an ancestor of both given nodes.",
|
||||||
|
},
|
||||||
|
memoization: {
|
||||||
|
name: "Memoization",
|
||||||
|
icon: "database",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "Cache a pure function's result per argument so every subproblem is solved once: top-down dynamic programming.",
|
||||||
|
},
|
||||||
|
"merge-sort": {
|
||||||
|
name: "Merge Sort",
|
||||||
|
icon: "blend",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "Sort each half recursively, then merge the two sorted halves: O(n log n), stable, O(n) extra space.",
|
||||||
|
},
|
||||||
|
quickselect: {
|
||||||
|
name: "Quickselect",
|
||||||
|
icon: "crosshair",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "Partition around a pivot and recurse into one side only, finding the k-th smallest element in O(n) on average.",
|
||||||
|
},
|
||||||
|
quicksort: {
|
||||||
|
name: "Quicksort",
|
||||||
|
icon: "shuffle",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "Partition around a pivot, then sort each side in place: O(n log n) on average, O(n²) at worst.",
|
||||||
|
},
|
||||||
|
recursion: {
|
||||||
|
name: "Recursion",
|
||||||
|
icon: "iteration-ccw",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "Solve a problem by calling the same function on smaller inputs until a base case answers directly.",
|
||||||
|
},
|
||||||
|
simulation: {
|
||||||
|
name: "Simulation",
|
||||||
|
icon: "monitor-play",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "Carry out the process the statement describes, step by step; the work is modelling the state faithfully.",
|
||||||
|
},
|
||||||
|
sorting: {
|
||||||
|
name: "Sorting",
|
||||||
|
icon: "arrow-down-wide-narrow",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "Order the input first so a later step, such as two pointers, a greedy choice, or binary search, becomes possible.",
|
||||||
|
},
|
||||||
|
"tournament-sort": {
|
||||||
|
name: "Tournament Sort",
|
||||||
|
icon: "trophy",
|
||||||
|
category: "Algorithms",
|
||||||
|
definition: "A winner tree of pairwise comparisons: the root is the extreme element, and replaying one path yields the next in O(log n).",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── techniques ───────────────────────────────────────────────────
|
||||||
|
combinatorics: {
|
||||||
|
name: "Combinatorics",
|
||||||
|
icon: "dices",
|
||||||
|
category: "Techniques",
|
||||||
|
definition: "Count arrangements and selections (permutations, combinations, Catalan numbers), usually modulo a prime.",
|
||||||
|
},
|
||||||
|
counting: {
|
||||||
|
name: "Counting",
|
||||||
|
icon: "tally-5",
|
||||||
|
category: "Techniques",
|
||||||
|
definition: "Tally occurrences in a hash map or fixed array, then answer from the counts instead of the raw input.",
|
||||||
|
},
|
||||||
|
design: {
|
||||||
|
name: "Design",
|
||||||
|
icon: "pencil-ruler",
|
||||||
|
category: "Techniques",
|
||||||
|
definition: "Implement a class to an API spec, choosing the internals so every operation meets its time bound.",
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
name: "Geometry",
|
||||||
|
icon: "shapes",
|
||||||
|
category: "Techniques",
|
||||||
|
definition: "Points, lines, and areas on a plane or grid: distances, slopes, overlaps, and orientation by cross product.",
|
||||||
|
},
|
||||||
|
math: {
|
||||||
|
name: "Math",
|
||||||
|
icon: "calculator",
|
||||||
|
category: "Techniques",
|
||||||
|
definition: "An arithmetic or number-theory insight (modular arithmetic, GCD, digit manipulation, a closed form) in place of brute force.",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { defineConfig } from "blume";
|
||||||
|
import type { StandardSchema } from "blume/core/standard-schema.ts";
|
||||||
|
import { filesystem } from "blume/sources";
|
||||||
|
import { APPENDIX } from "./appendix";
|
||||||
|
import { RECENCY, type Company } from "./companies";
|
||||||
|
import redirects from "./redirects";
|
||||||
|
|
||||||
|
// Blume's defaults (`inter`, `ibm-plex-mono`) are fetched from fonts.gstatic.com
|
||||||
|
// on every build, which has timed out on the Pages runner and, fully offline,
|
||||||
|
// silently ships no fonts at all. The same two families from Fontsource, as
|
||||||
|
// local files, keep the build network-free. Blume resolves `src` from this
|
||||||
|
// directory; the workspace links these packages under ./node_modules.
|
||||||
|
const inter = {
|
||||||
|
name: "Inter",
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
src: "./node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2",
|
||||||
|
weight: "100..900",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const plexMono = {
|
||||||
|
name: "IBM Plex Mono",
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
src: "./node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-400-normal.woff2",
|
||||||
|
weight: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "./node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
|
||||||
|
weight: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom frontmatter, rendered between the description and the body by
|
||||||
|
* components/ProblemBadges.astro. Problem pages: `topics:` is LeetCode's topic
|
||||||
|
* tags, `issue:` the problem's GitHub issue URL and `companies:` the companies
|
||||||
|
* that asked it in the last six months (`companies.ts`; the table under the
|
||||||
|
* badge row) — all three written by apps/cli/sync.ts — and `video:` the
|
||||||
|
* NeetCode walkthrough's YouTube id (apps/cli/videos.ts). Blume rejects
|
||||||
|
* undeclared keys and takes any Standard Schema; these are hand-rolled so the
|
||||||
|
* docs workspace needs no zod. All optional — every other page carries none.
|
||||||
|
*/
|
||||||
|
const optional = <T>(
|
||||||
|
message: string,
|
||||||
|
valid: (value: unknown) => value is T,
|
||||||
|
): StandardSchema<unknown, T | undefined> => ({
|
||||||
|
"~standard": {
|
||||||
|
version: 1,
|
||||||
|
vendor: "prdlk",
|
||||||
|
validate: (value) =>
|
||||||
|
value === undefined || valid(value)
|
||||||
|
? { value }
|
||||||
|
: { issues: [{ message }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const topicsSchema = optional(
|
||||||
|
"expected a list of { name, slug } LeetCode topic tags",
|
||||||
|
(value): value is { name: string; slug: string }[] =>
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.every(
|
||||||
|
(t) => typeof t?.name === "string" && typeof t?.slug === "string",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const videoSchema = optional(
|
||||||
|
"expected an 11-character YouTube video id",
|
||||||
|
(value): value is string =>
|
||||||
|
typeof value === "string" && /^[\w-]{11}$/.test(value),
|
||||||
|
);
|
||||||
|
const issueSchema = optional(
|
||||||
|
"expected a https://github.com/<owner>/<repo>/issues/<n> URL",
|
||||||
|
(value): value is string =>
|
||||||
|
typeof value === "string" &&
|
||||||
|
/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+$/.test(value),
|
||||||
|
);
|
||||||
|
const companiesSchema = optional(
|
||||||
|
`expected a list of { name, period, frequency } with period one of ${RECENCY.join(" | ")}`,
|
||||||
|
(value): value is Company[] =>
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.every(
|
||||||
|
(c) =>
|
||||||
|
typeof c?.name === "string" &&
|
||||||
|
RECENCY.includes(c?.period) &&
|
||||||
|
typeof c?.frequency === "number",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
title: "prdlk",
|
||||||
|
description: "Documentation for my LeetCode solutions",
|
||||||
|
logo: "./public/logo.svg",
|
||||||
|
deployment: {
|
||||||
|
site: "https://lc.prd.lk",
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
owner: "prdlk",
|
||||||
|
repo: "leetcode",
|
||||||
|
},
|
||||||
|
lastModified: "git",
|
||||||
|
// Sync's `/<num>` short links, then the Appendix's former per-term pages
|
||||||
|
// (`/appendix/<slug>`, published until the glossary became one page) onto
|
||||||
|
// their cards, so old links and search results still land.
|
||||||
|
redirects: [
|
||||||
|
...redirects,
|
||||||
|
...Object.keys(APPENDIX).map((slug) => ({
|
||||||
|
from: `/appendix/${slug}`,
|
||||||
|
to: `/appendix#${slug}`,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
frontmatter: {
|
||||||
|
extend: {
|
||||||
|
topics: topicsSchema,
|
||||||
|
video: videoSchema,
|
||||||
|
issue: issueSchema,
|
||||||
|
companies: companiesSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
seo: {
|
||||||
|
og: { enabled: true },
|
||||||
|
// No `rss`: the changelog is one custom page (pages/changelog.astro), not
|
||||||
|
// `type: changelog` entries, so a changelog feed would have nothing in it.
|
||||||
|
sitemap: true,
|
||||||
|
robots: true,
|
||||||
|
structuredData: true,
|
||||||
|
x: { handle: "@prdlk" },
|
||||||
|
},
|
||||||
|
// Blume's own assistant route is server-rendered (it reads the provider key
|
||||||
|
// per request), which a static Pages build cannot ship. The SRS Worker
|
||||||
|
// answers instead (apps/api/src/ask.ts: OpenRouter, grounded on this site's
|
||||||
|
// llms-full.txt, which `agents.llmsTxt` publishes).
|
||||||
|
ai: {
|
||||||
|
assistant: {
|
||||||
|
enabled: true,
|
||||||
|
endpoint: "https://srs-api.prdlk.workers.dev/ask",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
llmsTxt: true,
|
||||||
|
mcp: { enabled: false },
|
||||||
|
},
|
||||||
|
// The only content source. `/changelog`, `/appendix` and `/progress` are
|
||||||
|
// custom pages (pages/*.astro): `/changelog` reads the GitHub releases
|
||||||
|
// itself — one timeline, no per-release pages — `/appendix` is one glossary
|
||||||
|
// page built from appendix.ts, and `/progress` is the live stats cards, so
|
||||||
|
// none enters the sidebar tree or prev/next.
|
||||||
|
content: {
|
||||||
|
sources: [filesystem({ root: "content" })],
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
fonts: { display: inter, body: inter, mono: plexMono },
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
repo: "https://github.com/prdlk/leetcode",
|
||||||
|
sidebar: {
|
||||||
|
display: "flat", // "flat" | "group" | "page"
|
||||||
|
},
|
||||||
|
// The custom pages (pages/*.astro) as header tabs. None is a content
|
||||||
|
// folder, so the tabs scope nothing out of the sidebar tree.
|
||||||
|
tabs: [
|
||||||
|
{ label: "Changelog", path: "/changelog" },
|
||||||
|
{ label: "Progress", path: "/progress" },
|
||||||
|
{ label: "Appendix", path: "/appendix" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* The `companies:` frontmatter vocabulary, shared by its writer
|
||||||
|
* (`apps/cli/sync.ts`, from the local DuckDB company-frequency database), its
|
||||||
|
* schema (`blume.config.ts`) and its two readers: the company table under a
|
||||||
|
* problem page's badge row (`components/ProblemBadges.astro`) and the
|
||||||
|
* Companies section of the Appendix (`pages/appendix.astro`), where each
|
||||||
|
* company's card — anchored at `companyHref()` — lists the problems it asked.
|
||||||
|
* Plain data, no Astro imports: sync imports this file under Bun.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The database's recency windows, most recent first; they nest, so a company is filed under the first it appears in. */
|
||||||
|
export const RECENCY = ["Thirty Days", "Three Months", "Six Months"] as const;
|
||||||
|
export type Recency = (typeof RECENCY)[number];
|
||||||
|
|
||||||
|
/** Read under an "Asked within" heading (the company table) or as a row chip (the Appendix card). */
|
||||||
|
export const RECENCY_LABEL: Record<Recency, string> = {
|
||||||
|
"Thirty Days": "30 days",
|
||||||
|
"Three Months": "3 months",
|
||||||
|
"Six Months": "6 months",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Company {
|
||||||
|
name: string;
|
||||||
|
/** The most recent window the company asked the problem in. */
|
||||||
|
period: Recency;
|
||||||
|
/** 5–100, relative within the company and window: 100 is its most-asked problem there. */
|
||||||
|
frequency: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The company's card on the Appendix; `company-` keeps it clear of the tag cards' `#<slug>` ids. */
|
||||||
|
export function companyHref(name: string): string {
|
||||||
|
return `/appendix#company-${name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")}`;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Project MDX components, available on every page with no import, and layout
|
||||||
|
* slot overrides. Static `.astro` files ship no framework JS (a custom element
|
||||||
|
* carries any behaviour); interactive React lives in `islands/` instead.
|
||||||
|
*/
|
||||||
|
import { defineComponents } from "blume";
|
||||||
|
|
||||||
|
export default defineComponents({
|
||||||
|
mdx: {
|
||||||
|
Carousel: "./components/Carousel.astro",
|
||||||
|
CarouselImage: "./components/CarouselImage.astro",
|
||||||
|
// MDX's own content wrapper: problem pages' topic + explanation badges, from
|
||||||
|
// `topics:` / `video:` frontmatter, between the description and the body.
|
||||||
|
wrapper: "./components/ProblemBadges.astro",
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
// Topic landing pages' "what links here": every problem pointing at the page.
|
||||||
|
PageFooter: "./components/BacklinksList.astro",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* The `PageFooter` slot: an Obsidian-style backlinks list under a topic landing
|
||||||
|
* page — every problem page that points at it, as `<num> <name>` rows in
|
||||||
|
* LeetCode-number order. Pages nothing points at (problem pages, the
|
||||||
|
* overview) render nothing; a tag no topic teaches lists its problems on its
|
||||||
|
* Appendix card instead (`pages/appendix.astro`), from the same answer.
|
||||||
|
*
|
||||||
|
* A problem points at a page when either
|
||||||
|
* - one of its topic badges links there — the same `tagTarget()` answer
|
||||||
|
* `ProblemBadges.astro` renders, so this list and the badges cannot drift
|
||||||
|
* (a DFS problem is a backlink of the one DFS landing its badge picks); or
|
||||||
|
* - it lives under the page's route (`/hybrid-structures/lru-cache` under
|
||||||
|
* `/hybrid-structures`), so a topic no LeetCode tag names still lists its
|
||||||
|
* own problems.
|
||||||
|
*
|
||||||
|
* Problem pages are recognised by their `<num>. <name>` title, the shape
|
||||||
|
* `apps/cli/sync.ts` writes.
|
||||||
|
*
|
||||||
|
* The list is a native `<details>`, open on load: the chevron at the top
|
||||||
|
* right of the header row collapses it like an accordion, with no JS.
|
||||||
|
*/
|
||||||
|
import { getEntry } from "astro:content";
|
||||||
|
import data from "blume:data";
|
||||||
|
import { withBase } from "blume/components/islands/base-path.ts";
|
||||||
|
import Icon from "blume/components/Icon.astro";
|
||||||
|
import { tagTarget, type TopicTag } from "../topic-landings.ts";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
route: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Route {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
collection: string;
|
||||||
|
entryId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { route } = Astro.props;
|
||||||
|
const routes: Route[] = data.routes;
|
||||||
|
const served = new Set(routes.map((r) => r.path));
|
||||||
|
|
||||||
|
const problems = routes.flatMap((r) => {
|
||||||
|
const m = r.title.match(/^(\d+)\.\s+(.+)$/);
|
||||||
|
return m ? [{ route: r, num: Number(m[1]), name: m[2]! }] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// A problem page is never a backlink target: skip the per-problem lookups there.
|
||||||
|
const backlinks = problems.some((p) => p.route.path === route)
|
||||||
|
? []
|
||||||
|
: (
|
||||||
|
await Promise.all(
|
||||||
|
problems.map(async (p) => {
|
||||||
|
if (p.route.path.startsWith(`${route}/`)) return p;
|
||||||
|
const entry = await getEntry(p.route.collection, p.route.entryId);
|
||||||
|
const tags: TopicTag[] = entry?.data.topics ?? [];
|
||||||
|
const page = { route: p.route.path, tags };
|
||||||
|
return tags.some((tag) => tagTarget(tag, page, served).href === route) ? p : undefined;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter((p) => p !== undefined)
|
||||||
|
.sort((a, b) => a.num - b.num);
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
backlinks.length > 0 && (
|
||||||
|
<details
|
||||||
|
class="group not-prose mx-auto mt-12 max-w-content border-border border-t pt-6"
|
||||||
|
open
|
||||||
|
>
|
||||||
|
<summary class="mb-2 flex cursor-pointer list-none items-center justify-between gap-2 text-muted-foreground transition-colors hover:text-foreground [&::-webkit-details-marker]:hidden">
|
||||||
|
<h2 class="flex items-baseline gap-2 font-medium text-xs uppercase tracking-wide" id="backlinks">
|
||||||
|
Backlinks
|
||||||
|
<span class="tabular-nums">{backlinks.length}</span>
|
||||||
|
</h2>
|
||||||
|
<Icon class="shrink-0 transition-transform group-open:rotate-180" name="chevron-down" size={16} />
|
||||||
|
</summary>
|
||||||
|
<ul class="text-sm">
|
||||||
|
{backlinks.map((p) => (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="-mx-2 flex items-baseline gap-3 rounded-md px-2 py-1 text-foreground no-underline transition-colors hover:bg-muted"
|
||||||
|
href={withBase(p.route.path)}
|
||||||
|
>
|
||||||
|
<span class="w-10 shrink-0 text-right font-mono text-muted-foreground text-xs tabular-nums">{p.num}</span>
|
||||||
|
<span class="truncate">{p.name}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* A horizontal carousel of <CarouselImage> slides, built the way Blume's own
|
||||||
|
* content components are: static markup, a custom element for the behaviour,
|
||||||
|
* no framework. The track is a CSS scroll-snap strip (so it also swipes on
|
||||||
|
* touch and the browser does the animation); prev/next sit outside it and
|
||||||
|
* the play toggle sits below with a "n / N" counter.
|
||||||
|
*
|
||||||
|
* `time` is seconds per slide while playing (default 1.25); `autoplay` starts
|
||||||
|
* playing on load. Play wraps around, pauses whenever the tab is hidden, and
|
||||||
|
* never starts on its own for a reader who prefers reduced motion — the
|
||||||
|
* button still works for them. Manual navigation (buttons, arrow keys, swipe)
|
||||||
|
* does not stop playback; it only restarts the interval so a step is never
|
||||||
|
* doubled.
|
||||||
|
*/
|
||||||
|
import Icon from "blume/components/Icon.astro";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Seconds each slide stays while playing. */
|
||||||
|
time?: number;
|
||||||
|
autoplay?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { time = 1.25, autoplay = false } = Astro.props;
|
||||||
|
|
||||||
|
const buttonClass =
|
||||||
|
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors hover:border-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40";
|
||||||
|
---
|
||||||
|
|
||||||
|
<blume-carousel
|
||||||
|
class="not-prose my-6 block"
|
||||||
|
data-time={String(time)}
|
||||||
|
data-autoplay={autoplay ? "" : undefined}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button class={buttonClass} data-prev type="button" aria-label="Previous slide">
|
||||||
|
<Icon class="rtl:-scale-x-100" name="chevron-left" size={18} />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-1 snap-x snap-mandatory overflow-x-auto scroll-smooth rounded-blume [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
data-track
|
||||||
|
tabindex="0"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<button class={buttonClass} data-next type="button" aria-label="Next slide">
|
||||||
|
<Icon class="rtl:-scale-x-100" name="chevron-right" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center justify-center gap-3">
|
||||||
|
<button class={buttonClass} data-play type="button" aria-label="Play" aria-pressed="false">
|
||||||
|
<Icon class="[[aria-pressed=true]_&]:hidden" name="play" size={16} />
|
||||||
|
<Icon class="[[aria-pressed=false]_&]:hidden" name="pause" size={16} />
|
||||||
|
</button>
|
||||||
|
<span class="text-muted-foreground text-xs tabular-nums leading-5" data-count></span>
|
||||||
|
</div>
|
||||||
|
</blume-carousel>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
class BlumeCarousel extends HTMLElement {
|
||||||
|
#track!: HTMLElement;
|
||||||
|
#play!: HTMLButtonElement;
|
||||||
|
#count!: HTMLElement;
|
||||||
|
#timer: number | null = null;
|
||||||
|
#index = 0;
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.#track = this.querySelector("[data-track]")!;
|
||||||
|
this.#play = this.querySelector("[data-play]")!;
|
||||||
|
this.#count = this.querySelector("[data-count]")!;
|
||||||
|
const prev = this.querySelector<HTMLButtonElement>("[data-prev]")!;
|
||||||
|
const next = this.querySelector<HTMLButtonElement>("[data-next]")!;
|
||||||
|
|
||||||
|
const slides = this.#slides().length;
|
||||||
|
if (slides < 2) {
|
||||||
|
for (const b of [prev, next, this.#play]) b.disabled = true;
|
||||||
|
}
|
||||||
|
this.#render();
|
||||||
|
|
||||||
|
prev.addEventListener("click", () => this.#step(-1));
|
||||||
|
next.addEventListener("click", () => this.#step(1));
|
||||||
|
this.#play.addEventListener("click", () => (this.#timer === null ? this.#start() : this.#stop()));
|
||||||
|
this.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "ArrowLeft") this.#step(-1);
|
||||||
|
else if (event.key === "ArrowRight") this.#step(1);
|
||||||
|
else return;
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
// Swipes and trackpad scrolls move the track without #step; follow them
|
||||||
|
// once the scroll settles. Settle-only, not per event: a long smooth
|
||||||
|
// scroll from #step passes over intermediate slides and must not be
|
||||||
|
// read back as the reader's position mid-flight.
|
||||||
|
let settle: number | undefined;
|
||||||
|
this.#track.addEventListener(
|
||||||
|
"scroll",
|
||||||
|
() => {
|
||||||
|
window.clearTimeout(settle);
|
||||||
|
settle = window.setTimeout(() => this.#syncFromScroll(), 150);
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
document.addEventListener("visibilitychange", this.#onVisibility);
|
||||||
|
|
||||||
|
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
if (this.hasAttribute("data-autoplay") && slides > 1 && !reduced) this.#start();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this.#stop();
|
||||||
|
document.removeEventListener("visibilitychange", this.#onVisibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
#slides(): HTMLElement[] {
|
||||||
|
return [...this.#track.querySelectorAll<HTMLElement>(":scope > blume-carousel-image")];
|
||||||
|
}
|
||||||
|
|
||||||
|
#step(delta: number) {
|
||||||
|
const slides = this.#slides();
|
||||||
|
this.#index = (this.#index + delta + slides.length) % slides.length;
|
||||||
|
slides[this.#index]!.scrollIntoView({ block: "nearest", inline: "start", behavior: "smooth" });
|
||||||
|
this.#render();
|
||||||
|
if (this.#timer !== null) this.#start(); // restart the interval from this slide
|
||||||
|
}
|
||||||
|
|
||||||
|
#syncFromScroll() {
|
||||||
|
const at = Math.round(this.#track.scrollLeft / this.#track.clientWidth);
|
||||||
|
if (at !== this.#index && Number.isFinite(at)) {
|
||||||
|
this.#index = Math.min(Math.max(at, 0), this.#slides().length - 1);
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#start() {
|
||||||
|
this.#stop();
|
||||||
|
const ms = Number(this.dataset.time) * 1000;
|
||||||
|
this.#timer = window.setInterval(() => this.#step(1), ms > 0 ? ms : 1250);
|
||||||
|
this.#play.setAttribute("aria-pressed", "true");
|
||||||
|
this.#play.setAttribute("aria-label", "Pause");
|
||||||
|
}
|
||||||
|
|
||||||
|
#stop() {
|
||||||
|
if (this.#timer !== null) window.clearInterval(this.#timer);
|
||||||
|
this.#timer = null;
|
||||||
|
this.#play.setAttribute("aria-pressed", "false");
|
||||||
|
this.#play.setAttribute("aria-label", "Play");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A background tab keeps its timers but not its layout; resume on return.
|
||||||
|
#wasPlaying = false;
|
||||||
|
#onVisibility = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
this.#wasPlaying = this.#timer !== null;
|
||||||
|
this.#stop();
|
||||||
|
} else if (this.#wasPlaying) this.#start();
|
||||||
|
};
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
const slides = this.#slides();
|
||||||
|
this.#count.textContent = `${this.#index + 1} / ${slides.length}`;
|
||||||
|
slides.forEach((s, i) => s.setAttribute("aria-hidden", i === this.#index ? "false" : "true"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customElements.get("blume-carousel")) {
|
||||||
|
customElements.define("blume-carousel", BlumeCarousel);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* One slide of a <Carousel>: Blume's own <Frame> around an <img>, so a slide
|
||||||
|
* is pixel-identical to a framed image elsewhere on the site — `alt` doubles
|
||||||
|
* as the frame's hint line and `caption` is Markdown, exactly as in Frame.
|
||||||
|
*
|
||||||
|
* `src` accepts what MDX authors already write for images: a URL, a
|
||||||
|
* root-relative `/file.png` (served from public/, prefixed with the deploy
|
||||||
|
* base), or the page-relative `../../public/file.png` form. A relative path
|
||||||
|
* that does not pass through public/ has nothing to serve it, so it is a
|
||||||
|
* build error rather than a silent 404.
|
||||||
|
*/
|
||||||
|
import Frame from "blume/components/content/Frame.astro";
|
||||||
|
import { withBase } from "blume/components/islands/base-path.ts";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
caption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { src, alt, caption } = Astro.props;
|
||||||
|
|
||||||
|
const resolveSrc = (value: string): string => {
|
||||||
|
if (URL.canParse(value)) return value;
|
||||||
|
if (value.startsWith("/")) return withBase(value);
|
||||||
|
const at = value.indexOf("public/");
|
||||||
|
if (at === -1) {
|
||||||
|
throw new Error(
|
||||||
|
`<CarouselImage src="${value}">: a relative src must point into public/ (or use /file.png)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return withBase(`/${value.slice(at + "public/".length)}`);
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<blume-carousel-image
|
||||||
|
class="block w-full shrink-0 snap-center [&_blume-frame]:my-0"
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
>
|
||||||
|
<Frame caption={caption} hint={alt}>
|
||||||
|
<img alt={alt ?? ""} src={resolveSrc(src)} loading="lazy" draggable="false" />
|
||||||
|
</Frame>
|
||||||
|
</blume-carousel-image>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* The MDX `wrapper`: the badge row (and, below it, the company table) between
|
||||||
|
* a page's description and its body.
|
||||||
|
* Blume's page template renders the title and description itself and then
|
||||||
|
* the MDX content, with no slot in between, so wrapping the content is the
|
||||||
|
* only way to land right after the description. MDX hands a wrapper no
|
||||||
|
* route, so it is read off the URL, and the page's frontmatter is then
|
||||||
|
* looked up through the route manifest — the same `getEntry` call Blume's own
|
||||||
|
* page template makes. Pages with none of the keys below (landing pages, the
|
||||||
|
* changelog) render only their content.
|
||||||
|
*
|
||||||
|
* - `topics:` (problem pages, written by `apps/cli/sync.ts`) is LeetCode's own
|
||||||
|
* topic tags. A tag the campaign teaches (`topic-landings.ts`) links to that
|
||||||
|
* topic's landing page and is filled; any other tag links to its card on the
|
||||||
|
* Appendix glossary, `/appendix#<slug>` (`appendix.ts`), and is outlined.
|
||||||
|
* `tagTarget()` picks the route (and between two landings, e.g. DFS); the
|
||||||
|
* `PageFooter` backlinks list and the Appendix page read the same answer.
|
||||||
|
* Each tag badge is a pill carrying the icon its target shows (a topic's
|
||||||
|
* `meta.ts` icon in the sidebar, a term's `icon` on its card) and the tag's
|
||||||
|
* own colour (`lib/tag-colors.ts`), assigned over every tag the site has a
|
||||||
|
* page or card for — no two tags share one, and tags that share problem
|
||||||
|
* pages are spread furthest apart. A tag with neither (the LeetCode-list
|
||||||
|
* fallback) stays gray and iconless.
|
||||||
|
* - `video:` (problem pages, written by `apps/cli/videos.ts`) is the NeetCode
|
||||||
|
* walkthrough's YouTube id, the red "Explanation" badge.
|
||||||
|
* - `issue:` (problem pages, written by sync) is the problem's GitHub issue
|
||||||
|
* URL, a neutral "Issue #N" badge with the GitHub mark, right after
|
||||||
|
* "Explanation" at the end of the row.
|
||||||
|
* - `companies:` (problem pages, written by sync; `companies.ts`) is the
|
||||||
|
* companies that asked the problem in the last six months, a table laid
|
||||||
|
* out like Blume's TypeTable under the badge row: each company a pill
|
||||||
|
* linking to its card in the Appendix's Companies section, the most recent
|
||||||
|
* window it asked in, and its frequency there (relative within that
|
||||||
|
* company and window, so a bar of 100 is its most-asked problem).
|
||||||
|
*/
|
||||||
|
import { getEntry } from "astro:content";
|
||||||
|
import data from "blume:data";
|
||||||
|
import Badge from "blume/components/content/Badge.astro";
|
||||||
|
import { stripBase, withBase } from "blume/components/islands/base-path.ts";
|
||||||
|
import { APPENDIX } from "../appendix.ts";
|
||||||
|
import { RECENCY_LABEL, companyHref, type Company } from "../companies.ts";
|
||||||
|
import { tagColors } from "../lib/tag-colors.ts";
|
||||||
|
import { TOPIC_LANDINGS, tagTarget, type TopicTag } from "../topic-landings.ts";
|
||||||
|
|
||||||
|
// `/sub/tree-dfs/invert-binary-tree/` (or `.../index.html` in a static build)
|
||||||
|
// -> `/tree-dfs/invert-binary-tree`, the base-less shape of `data.routes`.
|
||||||
|
const route =
|
||||||
|
stripBase(import.meta.env.BASE_URL ?? "/", Astro.url.pathname)
|
||||||
|
.replace(/(\/index)?\.html$/u, "")
|
||||||
|
.replace(/(.)\/+$/u, "$1");
|
||||||
|
const at = data.routes.find((r: { path: string }) => r.path === route);
|
||||||
|
const entry = at ? await getEntry(at.collection, at.entryId) : undefined;
|
||||||
|
const tags: TopicTag[] = entry?.data.topics ?? [];
|
||||||
|
const video: string | undefined = entry?.data.video;
|
||||||
|
const issue: string | undefined = entry?.data.issue;
|
||||||
|
const companies: Company[] = entry?.data.companies ?? [];
|
||||||
|
|
||||||
|
const served = new Set<string>(data.routes.map((r: { path: string }) => r.path));
|
||||||
|
|
||||||
|
// route → the icon its sidebar row shows: a page's own, or a topic group's
|
||||||
|
// (from its `meta.ts`) for the landing page the group row links to.
|
||||||
|
interface NavNode {
|
||||||
|
route?: string;
|
||||||
|
icon?: string;
|
||||||
|
children?: NavNode[];
|
||||||
|
}
|
||||||
|
const icons = new Map<string, string>();
|
||||||
|
const walk = (nodes: NavNode[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.route && node.icon && !icons.has(node.route)) icons.set(node.route, node.icon);
|
||||||
|
if (node.children) walk(node.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(data.navigation.sidebar);
|
||||||
|
|
||||||
|
// Every tag with a landing or an Appendix card, coloured against how problem
|
||||||
|
// pages (`<num>. <name>` titles, the shape sync writes) combine them. Only a
|
||||||
|
// page with tags needs the answer.
|
||||||
|
const colors = tags.length
|
||||||
|
? tagColors(
|
||||||
|
[...Object.keys(TOPIC_LANDINGS), ...Object.keys(APPENDIX)],
|
||||||
|
await Promise.all(
|
||||||
|
data.routes
|
||||||
|
.filter((r: { title: string }) => /^\d+\.\s/u.test(r.title))
|
||||||
|
.map(async (r: { collection: string; entryId: string }) => {
|
||||||
|
const page = await getEntry(r.collection, r.entryId);
|
||||||
|
return (page?.data.topics ?? []).map((t: TopicTag) => t.slug);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: new Map<string, string>();
|
||||||
|
|
||||||
|
const badges = tags.map((tag) => {
|
||||||
|
const target = tagTarget(tag, { route, tags }, served);
|
||||||
|
const external = target.kind === "external";
|
||||||
|
return {
|
||||||
|
name: tag.name,
|
||||||
|
href: external ? target.href : withBase(target.href),
|
||||||
|
external,
|
||||||
|
icon: target.kind === "appendix" ? APPENDIX[tag.slug]?.icon : external ? undefined : icons.get(target.href),
|
||||||
|
color: external ? undefined : colors.get(tag.slug),
|
||||||
|
stroke: target.kind !== "landing",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkClass = "no-underline transition-opacity hover:opacity-80";
|
||||||
|
// TypeTable's column track, so the header and every row align.
|
||||||
|
const columns = "grid grid-cols-[2fr_1fr_auto] items-center gap-4";
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
(badges.length > 0 || video || issue) && (
|
||||||
|
<div class="not-prose mb-6 flex flex-wrap gap-1.5">
|
||||||
|
{badges.map((b) => (
|
||||||
|
<a
|
||||||
|
href={b.href}
|
||||||
|
target={b.external ? "_blank" : undefined}
|
||||||
|
rel={b.external ? "noopener" : undefined}
|
||||||
|
class={linkClass}
|
||||||
|
>
|
||||||
|
<Badge color={b.color} icon={b.icon} shape="pill" stroke={b.stroke}>
|
||||||
|
{b.name}
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
{video && (
|
||||||
|
<a href={`https://www.youtube.com/watch?v=${video}`} target="_blank" rel="noopener" class={linkClass}>
|
||||||
|
<Badge variant="danger" icon="youtube" shape="pill">
|
||||||
|
Explanation
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{issue && (
|
||||||
|
<a href={issue} target="_blank" rel="noopener" class={linkClass}>
|
||||||
|
<Badge icon="github" shape="pill">
|
||||||
|
Issue #{issue.slice(issue.lastIndexOf("/") + 1)}
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
companies.length > 0 && (
|
||||||
|
<div class="not-prose mb-6 rounded-blume border border-border bg-muted/30 p-1.5" data-blume-narration="skip">
|
||||||
|
<div class={`${columns} px-3 py-2 font-medium text-muted-foreground text-xs`}>
|
||||||
|
<span>Company</span>
|
||||||
|
<span>Asked within</span>
|
||||||
|
<span title="Relative within the company and window: 100 is its most-asked problem there">Frequency</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex flex-col">
|
||||||
|
{companies.map((c) => (
|
||||||
|
<div class={`${columns} border-border border-t px-3 py-2 text-xs`}>
|
||||||
|
<a href={withBase(companyHref(c.name))} class={linkClass}>
|
||||||
|
<Badge icon="building-2" shape="pill" stroke>
|
||||||
|
{c.name}
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
<span class="whitespace-nowrap text-foreground">{RECENCY_LABEL[c.period]}</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="h-1.5 w-16 overflow-hidden rounded-full bg-muted" aria-hidden="true">
|
||||||
|
<span class="block h-full rounded-full bg-accent" style={`width: ${c.frequency}%`} />
|
||||||
|
</span>
|
||||||
|
<code class="w-8 text-right font-mono text-foreground tabular-nums">{Math.round(c.frequency)}</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<slot />
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { useStats, utcDate } from "../../lib/stats";
|
||||||
|
import redirects from "../../redirects";
|
||||||
|
import { StatsPending } from "./shared";
|
||||||
|
|
||||||
|
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,143 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Card, CardContent } from "../ui/card";
|
||||||
|
import { type ChartConfig, ChartContainer } from "../ui/chart";
|
||||||
|
import { DIFFICULTIES, type Difficulty, useStats } from "../../lib/stats";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { StatsPending } from "./shared";
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
easy: { label: "Easy", color: "var(--chart-1)" },
|
||||||
|
medium: { label: "Med.", color: "var(--chart-2)" },
|
||||||
|
hard: { label: "Hard", color: "var(--chart-3)" },
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
// Static class names, so Tailwind's scanner sees every one (the chart's
|
||||||
|
// --color-* variables exist only inside ChartContainer).
|
||||||
|
const DIFFICULTY_TEXT: Record<Difficulty, string> = {
|
||||||
|
easy: "text-chart-1",
|
||||||
|
medium: "text-chart-2",
|
||||||
|
hard: "text-chart-3",
|
||||||
|
};
|
||||||
|
|
||||||
|
// The gauge opens at the bottom: from 225° (lower left) clockwise to -45°
|
||||||
|
// (lower right), in Recharts' counter-clockwise-from-3-o'clock degrees.
|
||||||
|
const START = 225;
|
||||||
|
const SWEEP = 270;
|
||||||
|
const GAP = 8;
|
||||||
|
|
||||||
|
export default function SolvedRing() {
|
||||||
|
const load = useStats();
|
||||||
|
const [focus, setFocus] = useState<Difficulty | null>(null);
|
||||||
|
|
||||||
|
if (load.phase !== "ready") {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<StatsPending load={load} className="h-52 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = DIFFICULTIES.map(
|
||||||
|
(difficulty) => load.stats.difficulty.find((d) => d.difficulty === difficulty) ?? { difficulty, total: 0, done: 0 },
|
||||||
|
);
|
||||||
|
const total = rows.reduce((n, r) => n + r.total, 0);
|
||||||
|
const done = rows.reduce((n, r) => n + r.done, 0);
|
||||||
|
const span = SWEEP - GAP * (rows.length - 1);
|
||||||
|
|
||||||
|
const segments: ((typeof rows)[number] & { from: number; to: number; solvedTo: number })[] = [];
|
||||||
|
let cursor = START;
|
||||||
|
for (const r of rows) {
|
||||||
|
const size = total > 0 ? (span * r.total) / total : 0;
|
||||||
|
const solved = r.total > 0 ? size * (r.done / r.total) : 0;
|
||||||
|
segments.push({ ...r, from: cursor, to: cursor - size, solvedTo: cursor - solved });
|
||||||
|
cursor -= size + GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
const focused = focus ? rows.find((r) => r.difficulty === focus) : undefined;
|
||||||
|
const arc = { innerRadius: "80%", outerRadius: "88%", cornerRadius: 99, stroke: "none", dataKey: "value" } as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center gap-6 sm:flex-row">
|
||||||
|
<div className="relative w-full max-w-56 shrink-0">
|
||||||
|
<ChartContainer config={chartConfig} className="aspect-square w-full">
|
||||||
|
<PieChart>
|
||||||
|
{segments.map((s) => (
|
||||||
|
<Pie
|
||||||
|
key={`${s.difficulty}-track`}
|
||||||
|
{...arc}
|
||||||
|
data={[{ value: 1 }]}
|
||||||
|
startAngle={s.from}
|
||||||
|
endAngle={s.to}
|
||||||
|
fill={`var(--color-${s.difficulty})`}
|
||||||
|
fillOpacity={focus && focus !== s.difficulty ? 0.12 : 0.25}
|
||||||
|
isAnimationActive={false}
|
||||||
|
onMouseEnter={() => setFocus(s.difficulty)}
|
||||||
|
onMouseLeave={() => setFocus(null)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{segments
|
||||||
|
.filter((s) => s.done > 0)
|
||||||
|
.map((s) => (
|
||||||
|
<Pie
|
||||||
|
key={`${s.difficulty}-solved`}
|
||||||
|
{...arc}
|
||||||
|
data={[{ value: 1 }]}
|
||||||
|
startAngle={s.from}
|
||||||
|
endAngle={s.solvedTo}
|
||||||
|
fill={`var(--color-${s.difficulty})`}
|
||||||
|
fillOpacity={focus && focus !== s.difficulty ? 0.35 : 1}
|
||||||
|
onMouseEnter={() => setFocus(s.difficulty)}
|
||||||
|
onMouseLeave={() => setFocus(null)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1">
|
||||||
|
<div className="flex items-baseline">
|
||||||
|
<span className="font-semibold text-4xl tabular-nums">{focused ? focused.done : done}</span>
|
||||||
|
<span className="text-muted-foreground tabular-nums">/{focused ? focused.total : total}</span>
|
||||||
|
</div>
|
||||||
|
<span className={cn("text-sm", focused ? DIFFICULTY_TEXT[focused.difficulty] : "text-muted-foreground")}>
|
||||||
|
{focused ? chartConfig[focused.difficulty].label : "Solved"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-3 text-center text-muted-foreground text-xs">
|
||||||
|
<span className="font-semibold text-foreground tabular-nums">{load.stats.attempting}</span> Attempting
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-1">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.difficulty}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-0.5 rounded-lg bg-muted px-4 py-2 transition-opacity",
|
||||||
|
focus && focus !== r.difficulty && "opacity-50",
|
||||||
|
)}
|
||||||
|
onPointerEnter={() => setFocus(r.difficulty)}
|
||||||
|
onPointerLeave={() => setFocus(null)}
|
||||||
|
>
|
||||||
|
<span className={cn("font-medium text-sm", DIFFICULTY_TEXT[r.difficulty])}>
|
||||||
|
{chartConfig[r.difficulty].label}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm tabular-nums">
|
||||||
|
{r.done}/{r.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Card, CardAction, CardContent, CardFooter, CardHeader } from "../ui/card";
|
||||||
|
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "../ui/chart";
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
|
||||||
|
import { DIFFICULTIES, type Day, type Difficulty, useStats, utcDate } from "../../lib/stats";
|
||||||
|
import { StatsPending } from "./shared";
|
||||||
|
|
||||||
|
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,163 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { type Day, useStats, utcDate } from "../../lib/stats";
|
||||||
|
import { StatsPending, TipRow, useHoverTip } from "./shared";
|
||||||
|
|
||||||
|
const MONTHS = 6;
|
||||||
|
const CELL = 10;
|
||||||
|
const STEP = 13; // cell + gap
|
||||||
|
const MONTH_GAP = 8;
|
||||||
|
const LABEL_H = 16;
|
||||||
|
const HEAT = ["var(--heat-0)", "var(--heat-1)", "var(--heat-2)", "var(--heat-3)", "var(--heat-4)"];
|
||||||
|
|
||||||
|
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
|
||||||
|
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
|
||||||
|
|
||||||
|
interface Cell {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
date: Date;
|
||||||
|
attempts: number;
|
||||||
|
passes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Block {
|
||||||
|
label: string;
|
||||||
|
x: number;
|
||||||
|
width: number;
|
||||||
|
cells: Cell[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Month blocks laid left to right over the last MONTHS calendar months. Within
|
||||||
|
* a block a day sits at row = weekday (Mon = 0) and column = its week counted
|
||||||
|
* from the 1st, so a month starting on a Thursday opens with a short column.
|
||||||
|
*/
|
||||||
|
function layout(year: Day[]): { blocks: Block[]; width: number } {
|
||||||
|
const last = utcDate(year.at(-1)?.date ?? "1970-01-01");
|
||||||
|
const from = Date.UTC(last.getUTCFullYear(), last.getUTCMonth() - (MONTHS - 1), 1);
|
||||||
|
const blocks: Block[] = [];
|
||||||
|
let x = 0;
|
||||||
|
for (const day of year) {
|
||||||
|
const date = utcDate(day.date);
|
||||||
|
if (date.getTime() < from) continue;
|
||||||
|
let block = blocks.at(-1);
|
||||||
|
if (date.getUTCDate() === 1 || !block) {
|
||||||
|
if (block) x += block.width + MONTH_GAP;
|
||||||
|
block = { label: MONTH.format(date), x, width: 0, cells: [] };
|
||||||
|
blocks.push(block);
|
||||||
|
}
|
||||||
|
const first = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
|
||||||
|
const index = (first.getUTCDay() + 6) % 7 + date.getUTCDate() - 1;
|
||||||
|
const col = Math.floor(index / 7);
|
||||||
|
block.cells.push({
|
||||||
|
x: x + col * STEP,
|
||||||
|
y: (index % 7) * STEP,
|
||||||
|
date,
|
||||||
|
attempts: day.attempts.easy + day.attempts.medium + day.attempts.hard,
|
||||||
|
passes: day.passes,
|
||||||
|
});
|
||||||
|
block.width = col * STEP + CELL;
|
||||||
|
}
|
||||||
|
const end = blocks.at(-1);
|
||||||
|
return { blocks, width: end ? end.x + end.width : 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubmissionHeatmap() {
|
||||||
|
const load = useStats();
|
||||||
|
const { ref, show, hide, node } = useHoverTip<HTMLDivElement>();
|
||||||
|
|
||||||
|
if (load.phase !== "ready") {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<StatsPending load={load} className="h-40 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { blocks, width } = layout(load.stats.year);
|
||||||
|
const cells = blocks.flatMap((b) => b.cells);
|
||||||
|
let run = 0;
|
||||||
|
let streak = 0;
|
||||||
|
for (const c of cells) {
|
||||||
|
run = c.attempts > 0 ? run + 1 : 0;
|
||||||
|
streak = Math.max(streak, run);
|
||||||
|
}
|
||||||
|
const height = 7 * STEP - (STEP - CELL) + LABEL_H;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-baseline gap-2">
|
||||||
|
<span className="font-semibold text-2xl tabular-nums">{cells.reduce((n, c) => n + c.attempts, 0)}</span>
|
||||||
|
<span className="font-normal text-muted-foreground text-sm">attempts in the past {MONTHS} months</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="flex gap-4">
|
||||||
|
<span>
|
||||||
|
Active days:{" "}
|
||||||
|
<span className="text-foreground tabular-nums">{cells.filter((c) => c.attempts > 0).length}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Max streak: <span className="text-foreground tabular-nums">{streak}</span>
|
||||||
|
</span>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-1 items-center">
|
||||||
|
<div ref={ref} className="relative w-full" onPointerLeave={hide}>
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
className="h-auto w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label={`Attempts per day over the past ${MONTHS} months`}
|
||||||
|
>
|
||||||
|
{blocks.map((block) => (
|
||||||
|
<g key={`${block.label}-${block.x}`}>
|
||||||
|
{block.cells.map((cell) => (
|
||||||
|
<rect
|
||||||
|
key={cell.x * 1000 + cell.y}
|
||||||
|
x={cell.x}
|
||||||
|
y={cell.y}
|
||||||
|
width={CELL}
|
||||||
|
height={CELL}
|
||||||
|
rx={2}
|
||||||
|
fill={HEAT[Math.min(cell.attempts, 4)]}
|
||||||
|
onPointerMove={(event) =>
|
||||||
|
show(
|
||||||
|
event,
|
||||||
|
<>
|
||||||
|
<div className="font-medium">{DAY.format(cell.date)}</div>
|
||||||
|
<TipRow color={HEAT[Math.min(cell.attempts, 4)]!} label="Attempts" value={cell.attempts} />
|
||||||
|
<TipRow color="var(--chart-4)" label="Passed" value={cell.passes} />
|
||||||
|
</>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<text
|
||||||
|
x={block.x + block.width / 2}
|
||||||
|
y={height - 3}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="fill-muted-foreground text-[9px]"
|
||||||
|
>
|
||||||
|
{block.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{node}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { type Stats, useStats } from "../../lib/stats";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { StatsPending, TipRow, useHoverTip } from "./shared";
|
||||||
|
|
||||||
|
const PAD = 2; // gap between bubbles, in layout units
|
||||||
|
const UNIT = 12; // radius of a one-problem bubble
|
||||||
|
|
||||||
|
type Topic = Stats["topics"][number];
|
||||||
|
type Bubble = Topic & { x: number; y: number; r: number };
|
||||||
|
|
||||||
|
/** Split a name across lines of at most ~10 characters, never breaking a word. */
|
||||||
|
function lines(name: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const word of name.split(" ")) {
|
||||||
|
const last = out.at(-1);
|
||||||
|
if (last !== undefined && `${last} ${word}`.length <= 10) out[out.length - 1] = `${last} ${word}`;
|
||||||
|
else out.push(word);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TopicBubbles() {
|
||||||
|
const load = useStats();
|
||||||
|
const { ref, show, hide, node } = useHoverTip<HTMLDivElement>();
|
||||||
|
const clip = useId().replace(/:/g, "");
|
||||||
|
|
||||||
|
if (load.phase !== "ready") {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Topics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<StatsPending load={load} className="aspect-square w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topics = load.stats.topics.filter((t) => t.total > 0);
|
||||||
|
const circles: Bubble[] = topics
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.total - a.total || a.issue - b.issue)
|
||||||
|
.map((t) => ({ ...t, x: 0, y: 0, r: UNIT * Math.sqrt(t.total) + PAD }));
|
||||||
|
packSiblings(circles);
|
||||||
|
const bound = packEnclose(circles) ?? { x: 0, y: 0, r: 1 };
|
||||||
|
const solved = topics.reduce((n, t) => n + t.done, 0);
|
||||||
|
const total = topics.reduce((n, t) => n + t.total, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Topics</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{solved} of {total} problems across {topics.length} topics · size is problem count, fill is solved
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div ref={ref} className="relative mx-auto w-full max-w-xl" onPointerLeave={hide}>
|
||||||
|
<svg
|
||||||
|
viewBox={`${bound.x - bound.r} ${bound.y - bound.r} ${bound.r * 2} ${bound.r * 2}`}
|
||||||
|
className="h-auto w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label="Campaign topics sized by problem count and filled by problems solved"
|
||||||
|
>
|
||||||
|
{circles.map((c) => {
|
||||||
|
const r = c.r - PAD;
|
||||||
|
const ratio = c.total > 0 ? c.done / c.total : 0;
|
||||||
|
const complete = c.done >= c.total;
|
||||||
|
const words = lines(c.name);
|
||||||
|
const size = Math.min(r / 3.2, (1.7 * r) / Math.max(...words.map((w) => w.length)));
|
||||||
|
const id = `${clip}-${c.issue}`;
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={c.issue}
|
||||||
|
className={cn("transition-opacity hover:opacity-90", !c.taught && "opacity-50")}
|
||||||
|
onPointerMove={(event) =>
|
||||||
|
show(
|
||||||
|
event,
|
||||||
|
<>
|
||||||
|
<div className="font-medium">{c.name}</div>
|
||||||
|
<TipRow color="var(--chart-4)" label="Solved" value={`${c.done}/${c.total}`} />
|
||||||
|
{!c.taught && <div className="text-muted-foreground">Not reached yet</div>}
|
||||||
|
</>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<clipPath id={id}>
|
||||||
|
<circle cx={c.x} cy={c.y} r={r} />
|
||||||
|
</clipPath>
|
||||||
|
<circle cx={c.x} cy={c.y} r={r} className="fill-muted stroke-border" strokeWidth={0.5} />
|
||||||
|
<rect
|
||||||
|
x={c.x - r}
|
||||||
|
y={c.y + r - 2 * r * ratio}
|
||||||
|
width={2 * r}
|
||||||
|
height={2 * r * ratio}
|
||||||
|
clipPath={`url(#${id})`}
|
||||||
|
fill="var(--chart-4)"
|
||||||
|
fillOpacity={complete ? 1 : 0.35}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={c.x}
|
||||||
|
y={c.y - ((words.length - 1) * size * 1.15) / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
clipPath={`url(#${id})`}
|
||||||
|
className={cn("pointer-events-none select-none", complete ? "fill-background" : "fill-foreground")}
|
||||||
|
style={{ fontSize: size }}
|
||||||
|
>
|
||||||
|
{words.map((w, i) => (
|
||||||
|
<tspan key={`${i}-${w}`} x={c.x} dy={i === 0 ? 0 : size * 1.15}>
|
||||||
|
{w}
|
||||||
|
</tspan>
|
||||||
|
))}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
{node}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* What the progress cards 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 }
|
||||||
+7
@@ -7,6 +7,13 @@ topics:
|
|||||||
- { name: "Binary Search", slug: "binary-search" }
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
video: "nIVW4P8b1VA"
|
video: "nIVW4P8b1VA"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/62"
|
issue: "https://github.com/prdlk/leetcode/issues/62"
|
||||||
|
companies:
|
||||||
|
- { name: "Ola Cabs", period: "Thirty Days", frequency: 65.6 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 48.9 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 29 }
|
||||||
|
- { name: "tcs", period: "Six Months", frequency: 43.5 }
|
||||||
|
- { name: "Bloomberg", period: "Six Months", frequency: 40.6 }
|
||||||
|
- { name: "Meta", period: "Six Months", frequency: 32.7 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Find Minimum in Rotated Sorted Array'
|
label: 'Find Minimum in Rotated Sorted Array'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+9
@@ -6,6 +6,15 @@ topics:
|
|||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Binary Search", slug: "binary-search" }
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/64"
|
issue: "https://github.com/prdlk/leetcode/issues/64"
|
||||||
|
companies:
|
||||||
|
- { name: "Infosys", period: "Thirty Days", frequency: 68.6 }
|
||||||
|
- { name: "tcs", period: "Thirty Days", frequency: 67.6 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 29 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 54.8 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 42.1 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 36.8 }
|
||||||
|
- { name: "Amazon", period: "Three Months", frequency: 34.6 }
|
||||||
|
- { name: "Uber", period: "Six Months", frequency: 67.2 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Find Peak Element'
|
label: 'Find Peak Element'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+12
@@ -7,6 +7,18 @@ topics:
|
|||||||
- { name: "Binary Search", slug: "binary-search" }
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
video: "U8XENwh8Oy8"
|
video: "U8XENwh8Oy8"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/60"
|
issue: "https://github.com/prdlk/leetcode/issues/60"
|
||||||
|
companies:
|
||||||
|
- { name: "Meta", period: "Thirty Days", frequency: 53.8 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 36.7 }
|
||||||
|
- { name: "Oracle", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 68.3 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 33.4 }
|
||||||
|
- { name: "TikTok", period: "Six Months", frequency: 64.9 }
|
||||||
|
- { name: "LinkedIn", period: "Six Months", frequency: 63.3 }
|
||||||
|
- { name: "Yandex", period: "Six Months", frequency: 59.6 }
|
||||||
|
- { name: "Goldman Sachs", period: "Six Months", frequency: 53.1 }
|
||||||
|
- { name: "Apple", period: "Six Months", frequency: 48.1 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Search in Rotated Sorted Array'
|
label: 'Search in Rotated Sorted Array'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+11
@@ -6,6 +6,17 @@ topics:
|
|||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Binary Search", slug: "binary-search" }
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/65"
|
issue: "https://github.com/prdlk/leetcode/issues/65"
|
||||||
|
companies:
|
||||||
|
- { name: "Microsoft", period: "Thirty Days", frequency: 57.2 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 48.9 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 36.7 }
|
||||||
|
- { name: "TikTok", period: "Three Months", frequency: 70.9 }
|
||||||
|
- { name: "tcs", period: "Three Months", frequency: 47.7 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 43.3 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 36.8 }
|
||||||
|
- { name: "Instacart", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Apple", period: "Six Months", frequency: 70.2 }
|
||||||
|
- { name: "Infosys", period: "Six Months", frequency: 56.2 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Find First and Last Position of Element in Sorted Array'
|
label: 'Find First and Last Position of Element in Sorted Array'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+9
@@ -7,6 +7,15 @@ topics:
|
|||||||
- { name: "Binary Search", slug: "binary-search" }
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
video: "K-RYzDZkzCI"
|
video: "K-RYzDZkzCI"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/63"
|
issue: "https://github.com/prdlk/leetcode/issues/63"
|
||||||
|
companies:
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 50.2 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||||
|
- { name: "Grammarly", period: "Three Months", frequency: 84.8 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 54 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 49.8 }
|
||||||
|
- { name: "tcs", period: "Three Months", frequency: 47.7 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 42.1 }
|
||||||
|
- { name: "Infosys", period: "Six Months", frequency: 56.2 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Search Insert Position'
|
label: 'Search Insert Position'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
title: '4. Median of Two Sorted Arrays'
|
||||||
|
description: Given two sorted arrays nums1 and nums2 of size m and n respectively, return the median of the two sorted arrays
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Array", slug: "array" }
|
||||||
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
|
- { name: "Divide and Conquer", slug: "divide-and-conquer" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/66"
|
||||||
|
companies:
|
||||||
|
- { name: "Rippling", period: "Thirty Days", frequency: 93 }
|
||||||
|
- { name: "Bloomberg", period: "Thirty Days", frequency: 69.8 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 69.6 }
|
||||||
|
- { name: "Meta", period: "Thirty Days", frequency: 63.8 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 62 }
|
||||||
|
- { name: "Microsoft", period: "Thirty Days", frequency: 57.2 }
|
||||||
|
- { name: "eBay", period: "Three Months", frequency: 82.2 }
|
||||||
|
- { name: "Goldman Sachs", period: "Three Months", frequency: 57 }
|
||||||
|
- { name: "tcs", period: "Three Months", frequency: 47.7 }
|
||||||
|
- { name: "Zoho", period: "Six Months", frequency: 79.6 }
|
||||||
|
- { name: "DE Shaw", period: "Six Months", frequency: 74.2 }
|
||||||
|
- { name: "Apple", period: "Six Months", frequency: 48.1 }
|
||||||
|
sidebar:
|
||||||
|
label: 'Median of Two Sorted Arrays'
|
||||||
|
badge: 'Hard'
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `nums1 = [1,3], nums2 = [2]`
|
||||||
|
- Output: `2.00000`
|
||||||
|
- Explanation: merged array = `[1,2,3]` and median is `2`.
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `nums1 = [1,2], nums2 = [3,4]`
|
||||||
|
- Output: `2.50000`
|
||||||
|
- Explanation: merged array = `[1,2,3,4]` and median is `(2 + 3`) / `2 = 2.5`.
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `nums1.length == m`
|
||||||
|
- `nums2.length == n`
|
||||||
|
- `0 <= m <= 1000`
|
||||||
|
- `0 <= n <= 1000`
|
||||||
|
- `1 <= m + n <= 2000`
|
||||||
|
- `-10^6 <= nums1[i], nums2[i] <= 10^6`
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def findMedianSortedArrays(self, nums1: list[int], nums2: list[int]) -> float:
|
||||||
|
if len(nums1) > len(nums2):
|
||||||
|
return self.findMedianSortedArrays(nums2, nums1)
|
||||||
|
|
||||||
|
m, n = len(nums1), len(nums2)
|
||||||
|
left, right = 0, m
|
||||||
|
|
||||||
|
while left <= right:
|
||||||
|
partA = (left + right) // 2
|
||||||
|
partB = (m + n + 1) // 2 - partA
|
||||||
|
|
||||||
|
maxLeftA = float("-inf") if partA == 0 else nums1[partA - 1]
|
||||||
|
minRightA = float("inf") if partA == m else nums1[partA]
|
||||||
|
maxLeftB = float("-inf") if partB == 0 else nums2[partB - 1]
|
||||||
|
minRightB = float("inf") if partB == n else nums2[partB]
|
||||||
|
|
||||||
|
if maxLeftA <= minRightB and maxLeftB <= minRightA:
|
||||||
|
if (m + n) % 2 == 0:
|
||||||
|
return (max(maxLeftA, maxLeftB) + min(minRightA, minRightB)) / 2
|
||||||
|
else:
|
||||||
|
return max(maxLeftA, maxLeftB)
|
||||||
|
elif maxLeftA > minRightB:
|
||||||
|
right = partA - 1
|
||||||
|
else:
|
||||||
|
left = partA + 1
|
||||||
|
```
|
||||||
+6
@@ -7,6 +7,12 @@ topics:
|
|||||||
- { name: "Binary Search", slug: "binary-search" }
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
video: "s4DPM8ct1pI"
|
video: "s4DPM8ct1pI"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/58"
|
issue: "https://github.com/prdlk/leetcode/issues/58"
|
||||||
|
companies:
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 53.2 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 54 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 49.8 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 42.1 }
|
||||||
|
- { name: "Amazon", period: "Three Months", frequency: 39.1 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Binary Search'
|
label: 'Binary Search'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
+9
@@ -8,6 +8,15 @@ topics:
|
|||||||
- { name: "Matrix", slug: "matrix" }
|
- { name: "Matrix", slug: "matrix" }
|
||||||
video: "Ber2pi2C0j0"
|
video: "Ber2pi2C0j0"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/61"
|
issue: "https://github.com/prdlk/leetcode/issues/61"
|
||||||
|
companies:
|
||||||
|
- { name: "Goldman Sachs", period: "Thirty Days", frequency: 62.9 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 57.2 }
|
||||||
|
- { name: "Amazon", period: "Three Months", frequency: 34.6 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 34.5 }
|
||||||
|
- { name: "Google", period: "Three Months", frequency: 17.3 }
|
||||||
|
- { name: "SAP", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "tcs", period: "Six Months", frequency: 43.5 }
|
||||||
|
- { name: "Bloomberg", period: "Six Months", frequency: 25.5 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Search a 2D Matrix'
|
label: 'Search a 2D Matrix'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+12
@@ -7,6 +7,18 @@ topics:
|
|||||||
- { name: "Binary Search", slug: "binary-search" }
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
video: "U2SozAs9RzA"
|
video: "U2SozAs9RzA"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/59"
|
issue: "https://github.com/prdlk/leetcode/issues/59"
|
||||||
|
companies:
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 69.6 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 29 }
|
||||||
|
- { name: "Nvidia", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Infosys", period: "Three Months", frequency: 87.4 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 62.3 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 54 }
|
||||||
|
- { name: "Coupang", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Swiggy", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Accenture", period: "Six Months", frequency: 75 }
|
||||||
|
- { name: "Microsoft", period: "Six Months", frequency: 56.9 }
|
||||||
|
- { name: "Goldman Sachs", period: "Six Months", frequency: 44.8 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Koko Eating Bananas'
|
label: 'Koko Eating Bananas'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+8
-8
@@ -26,14 +26,14 @@ Time Complexity: `O(log(n))`
|
|||||||
|
|
||||||
|
|
||||||
<Carousel>
|
<Carousel>
|
||||||
<CarouselImage src='/binary_search.001.png' />
|
<CarouselImage src='../../../public/binary_search.001.png' />
|
||||||
<CarouselImage src='/binary_search.002.png' />
|
<CarouselImage src='../../../public/binary_search.002.png' />
|
||||||
<CarouselImage src='/binary_search.003.png' />
|
<CarouselImage src='../../../public/binary_search.003.png' />
|
||||||
<CarouselImage src='/binary_search.004.png' />
|
<CarouselImage src='../../../public/binary_search.004.png' />
|
||||||
<CarouselImage src='/binary_search.005.png' />
|
<CarouselImage src='../../../public/binary_search.005.png' />
|
||||||
<CarouselImage src='/binary_search.006.png' />
|
<CarouselImage src='../../../public/binary_search.006.png' />
|
||||||
<CarouselImage src='/binary_search.007.png' />
|
<CarouselImage src='../../../public/binary_search.007.png' />
|
||||||
<CarouselImage src='/binary_search.008.png' />
|
<CarouselImage src='../../../public/binary_search.008.png' />
|
||||||
</Carousel>
|
</Carousel>
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({ title: "Binary Search", order: 5, icon: "search", display: "group" });
|
||||||
+12
@@ -7,6 +7,18 @@ topics:
|
|||||||
- { name: "Hash Table", slug: "hash-table" }
|
- { name: "Hash Table", slug: "hash-table" }
|
||||||
- { name: "Union-Find", slug: "union-find" }
|
- { name: "Union-Find", slug: "union-find" }
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/34"
|
issue: "https://github.com/prdlk/leetcode/issues/34"
|
||||||
|
companies:
|
||||||
|
- { name: "tcs", period: "Thirty Days", frequency: 74.3 }
|
||||||
|
- { name: "Bloomberg", period: "Thirty Days", frequency: 69.8 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 65.8 }
|
||||||
|
- { name: "Microsoft", period: "Thirty Days", frequency: 57.2 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 53.2 }
|
||||||
|
- { name: "Apple", period: "Three Months", frequency: 69 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 34.5 }
|
||||||
|
- { name: "Paytm", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Nvidia", period: "Six Months", frequency: 91.1 }
|
||||||
|
- { name: "TikTok", period: "Six Months", frequency: 55.2 }
|
||||||
|
- { name: "Salesforce", period: "Six Months", frequency: 50.2 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Longest Consecutive Sequence'
|
label: 'Longest Consecutive Sequence'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+4
@@ -5,6 +5,10 @@ icon: dot
|
|||||||
topics:
|
topics:
|
||||||
- { name: "Hash Table", slug: "hash-table" }
|
- { name: "Hash Table", slug: "hash-table" }
|
||||||
- { name: "String", slug: "string" }
|
- { name: "String", slug: "string" }
|
||||||
|
companies:
|
||||||
|
- { name: "Bloomberg", period: "Six Months", frequency: 34.2 }
|
||||||
|
- { name: "Amazon", period: "Six Months", frequency: 14.3 }
|
||||||
|
- { name: "Google", period: "Six Months", frequency: 11.8 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Check if the Sentence Is Pangram'
|
label: 'Check if the Sentence Is Pangram'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
title: '1. Two Sum'
|
||||||
|
description: You are given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Array", slug: "array" }
|
||||||
|
- { name: "Hash Table", slug: "hash-table" }
|
||||||
|
video: "KLlXCFG5TnA"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/29"
|
||||||
|
companies:
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 100 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 100 }
|
||||||
|
- { name: "Meta", period: "Thirty Days", frequency: 100 }
|
||||||
|
- { name: "Microsoft", period: "Thirty Days", frequency: 100 }
|
||||||
|
- { name: "tcs", period: "Thirty Days", frequency: 79.6 }
|
||||||
|
- { name: "Ola Cabs", period: "Thirty Days", frequency: 76.7 }
|
||||||
|
- { name: "Bloomberg", period: "Thirty Days", frequency: 69.8 }
|
||||||
|
- { name: "Infosys", period: "Thirty Days", frequency: 68.6 }
|
||||||
|
- { name: "Altimetrik", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Capgemini", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Microstrategy", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "MongoDB", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Cognizant", period: "Three Months", frequency: 88.7 }
|
||||||
|
- { name: "Apple", period: "Three Months", frequency: 80.4 }
|
||||||
|
- { name: "Barclays", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Cisco", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Deloitte", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Expedia", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "NetApp", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Samsung", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Sony", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Oracle", period: "Six Months", frequency: 91.2 }
|
||||||
|
- { name: "Qualcomm", period: "Six Months", frequency: 80.7 }
|
||||||
|
- { name: "IBM", period: "Six Months", frequency: 77.2 }
|
||||||
|
- { name: "eBay", period: "Six Months", frequency: 75.2 }
|
||||||
|
- { name: "Walmart Labs", period: "Six Months", frequency: 70.5 }
|
||||||
|
- { name: "Flipkart", period: "Six Months", frequency: 57.1 }
|
||||||
|
- { name: "Goldman Sachs", period: "Six Months", frequency: 44.8 }
|
||||||
|
sidebar:
|
||||||
|
label: 'Two Sum'
|
||||||
|
badge: 'Easy'
|
||||||
|
---
|
||||||
|
|
||||||
|
::::warning
|
||||||
|
Can you come up with an algorithm that is less than O(n^2) time complexity?
|
||||||
|
::::
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `nums = [2,7,11,15], target = 9`
|
||||||
|
- Output: `[0,1]`
|
||||||
|
- Explanation: Because `nums[0] + nums[1]` == `9`, we return [0, 1].
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `nums = [3,2,4], target = 6`
|
||||||
|
- Output: `[1,2]`
|
||||||
|
|
||||||
|
### Example 3:
|
||||||
|
- Input: `nums = [3,3], target = 6`
|
||||||
|
- Output: `[0,1]`
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `2 <= nums.length <= 10^4`
|
||||||
|
- `-10^9 <= nums[i] <= 10^9`
|
||||||
|
- `-10^9 <= target <= 10^9`
|
||||||
|
- Only one valid answer exists.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
S(["twoSum(nums, target)"]) --> I["seen = {} — value to index"]
|
||||||
|
I --> L{"more (i, num) in nums?"}
|
||||||
|
L -- no --> E(["return []"])
|
||||||
|
L -- yes --> C["complement = target - num"]
|
||||||
|
C --> H{"complement in seen?"}
|
||||||
|
H -- yes --> R(["return [seen[complement], i]"])
|
||||||
|
H -- no --> W["seen[num] = i"]
|
||||||
|
W --> L
|
||||||
|
```
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def twoSum(self, nums: List[int], target: int) -> List[int]:
|
||||||
|
seen = {}
|
||||||
|
for i, num in enumerate(nums):
|
||||||
|
complement = target - num
|
||||||
|
if complement in seen:
|
||||||
|
return [seen[complement], i]
|
||||||
|
seen[num] = i
|
||||||
|
return []
|
||||||
|
```
|
||||||
+7
@@ -6,6 +6,13 @@ topics:
|
|||||||
- { name: "Hash Table", slug: "hash-table" }
|
- { name: "Hash Table", slug: "hash-table" }
|
||||||
- { name: "String", slug: "string" }
|
- { name: "String", slug: "string" }
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/33"
|
issue: "https://github.com/prdlk/leetcode/issues/33"
|
||||||
|
companies:
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 36.7 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 54 }
|
||||||
|
- { name: "tcs", period: "Three Months", frequency: 47.7 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 33.4 }
|
||||||
|
- { name: "Meta", period: "Six Months", frequency: 24.5 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Isomorphic Strings'
|
label: 'Isomorphic Strings'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
+6
@@ -8,6 +8,12 @@ topics:
|
|||||||
- { name: "Sorting", slug: "sorting" }
|
- { name: "Sorting", slug: "sorting" }
|
||||||
video: "3OamzN90kPg"
|
video: "3OamzN90kPg"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/27"
|
issue: "https://github.com/prdlk/leetcode/issues/27"
|
||||||
|
companies:
|
||||||
|
- { name: "Meta", period: "Thirty Days", frequency: 53.8 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 50.2 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 48.9 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 48.3 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 36.8 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Contains Duplicate'
|
label: 'Contains Duplicate'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
+10
@@ -8,6 +8,16 @@ topics:
|
|||||||
- { name: "Sorting", slug: "sorting" }
|
- { name: "Sorting", slug: "sorting" }
|
||||||
video: "9UtInBqnCgA"
|
video: "9UtInBqnCgA"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/28"
|
issue: "https://github.com/prdlk/leetcode/issues/28"
|
||||||
|
companies:
|
||||||
|
- { name: "Microsoft", period: "Thirty Days", frequency: 67.9 }
|
||||||
|
- { name: "tcs", period: "Thirty Days", frequency: 58.2 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 53.2 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 48.9 }
|
||||||
|
- { name: "IBM", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Infosys", period: "Three Months", frequency: 66 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 64.4 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 58.9 }
|
||||||
|
- { name: "Deloitte", period: "Six Months", frequency: 81 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Valid Anagram'
|
label: 'Valid Anagram'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
+4
@@ -6,6 +6,10 @@ topics:
|
|||||||
- { name: "Hash Table", slug: "hash-table" }
|
- { name: "Hash Table", slug: "hash-table" }
|
||||||
- { name: "String", slug: "string" }
|
- { name: "String", slug: "string" }
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/35"
|
issue: "https://github.com/prdlk/leetcode/issues/35"
|
||||||
|
companies:
|
||||||
|
- { name: "Google", period: "Three Months", frequency: 17.3 }
|
||||||
|
- { name: "Microsoft", period: "Six Months", frequency: 24.1 }
|
||||||
|
- { name: "Amazon", period: "Six Months", frequency: 20.9 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Word Pattern'
|
label: 'Word Pattern'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
+17
@@ -13,6 +13,23 @@ topics:
|
|||||||
- { name: "Quickselect", slug: "quickselect" }
|
- { name: "Quickselect", slug: "quickselect" }
|
||||||
video: "YPTqKIgVk-k"
|
video: "YPTqKIgVk-k"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/31"
|
issue: "https://github.com/prdlk/leetcode/issues/31"
|
||||||
|
companies:
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 72.9 }
|
||||||
|
- { name: "Microsoft", period: "Thirty Days", frequency: 67.9 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 42.3 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 54 }
|
||||||
|
- { name: "Snap", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "SoFi", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "PayPal", period: "Six Months", frequency: 88.4 }
|
||||||
|
- { name: "Walmart Labs", period: "Six Months", frequency: 81.4 }
|
||||||
|
- { name: "Nvidia", period: "Six Months", frequency: 78.7 }
|
||||||
|
- { name: "eBay", period: "Six Months", frequency: 75.2 }
|
||||||
|
- { name: "Visa", period: "Six Months", frequency: 69.7 }
|
||||||
|
- { name: "Snowflake", period: "Six Months", frequency: 58.7 }
|
||||||
|
- { name: "TikTok", period: "Six Months", frequency: 55.2 }
|
||||||
|
- { name: "Meta", period: "Six Months", frequency: 50.6 }
|
||||||
|
- { name: "Apple", period: "Six Months", frequency: 48.1 }
|
||||||
|
- { name: "tcs", period: "Six Months", frequency: 43.5 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Top K Frequent Elements'
|
label: 'Top K Frequent Elements'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+10
@@ -8,6 +8,16 @@ topics:
|
|||||||
- { name: "Queue", slug: "queue" }
|
- { name: "Queue", slug: "queue" }
|
||||||
- { name: "Counting", slug: "counting" }
|
- { name: "Counting", slug: "counting" }
|
||||||
video: "rBENYgWy3xU"
|
video: "rBENYgWy3xU"
|
||||||
|
companies:
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 48.9 }
|
||||||
|
- { name: "Yandex", period: "Three Months", frequency: 76.1 }
|
||||||
|
- { name: "tcs", period: "Three Months", frequency: 73.7 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 46.8 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 43.3 }
|
||||||
|
- { name: "Google", period: "Three Months", frequency: 23.9 }
|
||||||
|
- { name: "EPAM Systems", period: "Six Months", frequency: 76.2 }
|
||||||
|
- { name: "Goldman Sachs", period: "Six Months", frequency: 59 }
|
||||||
|
- { name: "Microsoft", period: "Six Months", frequency: 32 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'First Unique Character in a String'
|
label: 'First Unique Character in a String'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
+6
@@ -10,6 +10,12 @@ topics:
|
|||||||
- { name: "Bucket Sort", slug: "bucket-sort" }
|
- { name: "Bucket Sort", slug: "bucket-sort" }
|
||||||
- { name: "Counting", slug: "counting" }
|
- { name: "Counting", slug: "counting" }
|
||||||
video: "OXdXc9HTrIg"
|
video: "OXdXc9HTrIg"
|
||||||
|
companies:
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 49.8 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 48.3 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 46.8 }
|
||||||
|
- { name: "Google", period: "Three Months", frequency: 17.3 }
|
||||||
|
- { name: "Amazon", period: "Six Months", frequency: 20.9 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Sort Characters By Frequency'
|
label: 'Sort Characters By Frequency'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+25
@@ -9,6 +9,31 @@ topics:
|
|||||||
- { name: "Sorting", slug: "sorting" }
|
- { name: "Sorting", slug: "sorting" }
|
||||||
video: "vzdNOK2oB2E"
|
video: "vzdNOK2oB2E"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/30"
|
issue: "https://github.com/prdlk/leetcode/issues/30"
|
||||||
|
companies:
|
||||||
|
- { name: "Apple", period: "Thirty Days", frequency: 100 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 78.4 }
|
||||||
|
- { name: "Ola Cabs", period: "Thirty Days", frequency: 76.7 }
|
||||||
|
- { name: "Bloomberg", period: "Thirty Days", frequency: 69.8 }
|
||||||
|
- { name: "tcs", period: "Thirty Days", frequency: 58.2 }
|
||||||
|
- { name: "Meta", period: "Thirty Days", frequency: 53.8 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 50.2 }
|
||||||
|
- { name: "CrowdStrike", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Deloitte", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "HashedIn", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Smartsheet", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Walmart Labs", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 33.4 }
|
||||||
|
- { name: "Expedia", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Wipro", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Adobe", period: "Six Months", frequency: 93.7 }
|
||||||
|
- { name: "Zoho", period: "Six Months", frequency: 79.6 }
|
||||||
|
- { name: "Nvidia", period: "Six Months", frequency: 78.7 }
|
||||||
|
- { name: "Oracle", period: "Six Months", frequency: 78.7 }
|
||||||
|
- { name: "EPAM Systems", period: "Six Months", frequency: 76.2 }
|
||||||
|
- { name: "Palo Alto Networks", period: "Six Months", frequency: 75.9 }
|
||||||
|
- { name: "Visa", period: "Six Months", frequency: 69.7 }
|
||||||
|
- { name: "Infosys", period: "Six Months", frequency: 67.9 }
|
||||||
|
- { name: "Goldman Sachs", period: "Six Months", frequency: 53.1 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Group Anagrams'
|
label: 'Group Anagrams'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+5
-3
@@ -13,7 +13,7 @@ Data is stored in a fix-sized array, where each data value has an unique index.
|
|||||||
|
|
||||||
<Frame caption="Hash Table Operations">
|
<Frame caption="Hash Table Operations">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
@@ -39,10 +39,12 @@ def get_counter(arr: list[int]) -> dict[int, int]:
|
|||||||
return counter
|
return counter
|
||||||
```
|
```
|
||||||
|
|
||||||
::: tip[Python provides a built-in `collections.Counter` class]
|
:::tip
|
||||||
|
Python provides a built-in [`collections.Counter`](https://docs.python.org/3/library/collections.html#collections.Counter) class that does this for us.
|
||||||
|
:::
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
fq = Counter(arr)
|
fq = Counter(arr)
|
||||||
```
|
```
|
||||||
:::
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({ title: "Hash Table", order: 1, icon: "hash", display: "group" });
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({ title: "Linear Structures", order: 1 });
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
title: '496. Next Greater Element I'
|
||||||
|
description: The next greater element of some element x in an array is the first greater element that is to the right of x in the same array
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Array", slug: "array" }
|
||||||
|
- { name: "Hash Table", slug: "hash-table" }
|
||||||
|
- { name: "Stack", slug: "stack" }
|
||||||
|
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/74"
|
||||||
|
companies:
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 46.6 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 59.7 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 43.3 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 33.4 }
|
||||||
|
- { name: "tcs", period: "Six Months", frequency: 43.5 }
|
||||||
|
sidebar:
|
||||||
|
label: 'Next Greater Element I'
|
||||||
|
badge: 'Easy'
|
||||||
|
---
|
||||||
|
|
||||||
|
::::warning
|
||||||
|
Could you find an O(nums1.length + nums2.length) solution?
|
||||||
|
::::
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `nums1 = [4,1,2], nums2 = [1,3,4,2]`
|
||||||
|
- Output: `[-1,3,-1]`
|
||||||
|
- Explanation:
|
||||||
|
- The next greater element for each value of `nums1` is as follows:
|
||||||
|
- `4` is underlined in `nums2 = [1,3,4,2]`. There is no next greater element, so the answer is `-1`.
|
||||||
|
- `1` is underlined in `nums2 = [1,3,4,2]`. The next greater element is `3`.
|
||||||
|
- `2` is underlined in `nums2 = [1,3,4,2]`. There is no next greater element, so the answer is `-1`.
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `nums1 = [2,4], nums2 = [1,2,3,4]`
|
||||||
|
- Output: `[3,-1]`
|
||||||
|
- Explanation:
|
||||||
|
- The next greater element for each value of `nums1` is as follows:
|
||||||
|
- `2` is underlined in `nums2 = [1,2,3,4]`. The next greater element is `3`.
|
||||||
|
- `4` is underlined in `nums2 = [1,2,3,4]`. There is no next greater element, so the answer is `-1`.
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `1 <= nums1.length <= nums2.length <= 1000`
|
||||||
|
- `0 <= nums1[i], nums2[i] <= 10^4`
|
||||||
|
- All integers in `nums1` and `nums2` are unique.
|
||||||
|
- All the integers of `nums1` also appear in nums2.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def nextGreaterElement(self, nums1: list[int], nums2: list[int]) -> list[int]:
|
||||||
|
mono_stack = []
|
||||||
|
next_greater = {}
|
||||||
|
|
||||||
|
for cur in nums2:
|
||||||
|
while mono_stack and cur > mono_stack[-1]:
|
||||||
|
val = mono_stack.pop()
|
||||||
|
next_greater[val] = cur
|
||||||
|
mono_stack.append(cur)
|
||||||
|
|
||||||
|
return [next_greater.get(x, -1) for x in nums1]
|
||||||
|
```
|
||||||
+10
@@ -8,6 +8,16 @@ topics:
|
|||||||
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
||||||
video: "cTBiBSnjO3c"
|
video: "cTBiBSnjO3c"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/72"
|
issue: "https://github.com/prdlk/leetcode/issues/72"
|
||||||
|
companies:
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 29 }
|
||||||
|
- { name: "Chime", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 59.7 }
|
||||||
|
- { name: "Amazon", period: "Three Months", frequency: 42.8 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 34.5 }
|
||||||
|
- { name: "Anduril", period: "Six Months", frequency: 92.8 }
|
||||||
|
- { name: "Goldman Sachs", period: "Six Months", frequency: 44.8 }
|
||||||
|
- { name: "tcs", period: "Six Months", frequency: 43.5 }
|
||||||
|
- { name: "Microsoft", period: "Six Months", frequency: 42.5 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Daily Temperatures'
|
label: 'Daily Temperatures'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
title: '84. Largest Rectangle in Histogram'
|
||||||
|
description: Given an array of integers heights representing the histogram's bar height where the width of each bar is 1, return the area of the largest rectangle in the histogram
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Array", slug: "array" }
|
||||||
|
- { name: "Stack", slug: "stack" }
|
||||||
|
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
||||||
|
- { name: "Range Minimum/Maximum Query", slug: "range-minimum-maximum-query" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/76"
|
||||||
|
companies:
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 29 }
|
||||||
|
- { name: "Accenture", period: "Three Months", frequency: 88.6 }
|
||||||
|
- { name: "tcs", period: "Three Months", frequency: 73.7 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 42.1 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 36.8 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 34.5 }
|
||||||
|
sidebar:
|
||||||
|
label: 'Largest Rectangle in Histogram'
|
||||||
|
badge: 'Hard'
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `heights = [2,1,5,6,2,3]`
|
||||||
|
- Output: `10`
|
||||||
|
- Explanation: The above is a histogram where width of each bar is `1`. The largest rectangle is shown in the red area, which has an area = `10` units.
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `heights = [2,4]`
|
||||||
|
- Output: `4`
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `1 <= heights.length <= 10^5`
|
||||||
|
- `0 <= heights[i] <= 10^4`
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def largestRectangleArea(self, heights: list[int]) -> int:
|
||||||
|
maxArea = 0
|
||||||
|
stack = []
|
||||||
|
|
||||||
|
for i, h in enumerate(heights):
|
||||||
|
start = i
|
||||||
|
while stack and stack[-1][1] > h:
|
||||||
|
index, height = stack.pop()
|
||||||
|
width = i - index
|
||||||
|
maxArea = max(maxArea, height * width)
|
||||||
|
start = index
|
||||||
|
stack.append((start, h))
|
||||||
|
|
||||||
|
for i, h in stack:
|
||||||
|
maxArea = max(maxArea, h * (len(heights) - i))
|
||||||
|
|
||||||
|
return maxArea
|
||||||
|
```
|
||||||
+3
@@ -9,6 +9,9 @@ topics:
|
|||||||
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
||||||
video: "Pr6T-3yB9RM"
|
video: "Pr6T-3yB9RM"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/73"
|
issue: "https://github.com/prdlk/leetcode/issues/73"
|
||||||
|
companies:
|
||||||
|
- { name: "Google", period: "Three Months", frequency: 17.3 }
|
||||||
|
- { name: "Amazon", period: "Six Months", frequency: 26 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Car Fleet'
|
label: 'Car Fleet'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
title: '901. Online Stock Span'
|
||||||
|
description: Design an algorithm that collects daily price quotes for some stock and returns the span of that stock's price for the current day
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Stack", slug: "stack" }
|
||||||
|
- { name: "Design", slug: "design" }
|
||||||
|
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
||||||
|
- { name: "Data Stream", slug: "data-stream" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/75"
|
||||||
|
companies:
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 34.5 }
|
||||||
|
- { name: "Google", period: "Three Months", frequency: 17.3 }
|
||||||
|
- { name: "Amazon", period: "Six Months", frequency: 26 }
|
||||||
|
- { name: "Bloomberg", period: "Six Months", frequency: 25.5 }
|
||||||
|
sidebar:
|
||||||
|
label: 'Online Stock Span'
|
||||||
|
badge: 'Medium'
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: ``
|
||||||
|
- Output: ``
|
||||||
|
- Explanation: Input ["StockSpanner", "next", "next", "next", "next", "next", "next", "next"] `[[]`, `[100]`, `[80]`, `[60]`, `[70]`, `[60]`, `[75]`, [85]] Output [null, `1`, `1`, `1`, `2`, `1`, `4`, 6] Explanation StockSpanner stockSpanner = new StockSpanner(); stockSpanner.next(100); // return `1` stockSpanner.next(80); // return `1` stockSpanner.next(60); // return `1` stockSpanner.next(70); // return `2` stockSpanner.next(60); // return `1` stockSpanner.next(75); // return `4`, because the last `4` prices (including today's `price` of `75`) were less than or equal to today's `price`. stockSpanner.next(85); // return `6`
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `1 <= price <= 10^5`
|
||||||
|
- At most 10^4 calls will be made to next.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class StockSpanner:
|
||||||
|
def __init__(self):
|
||||||
|
self.stack = []
|
||||||
|
|
||||||
|
def next(self, price: int) -> int:
|
||||||
|
ans = 1
|
||||||
|
while self.stack and self.stack[-1][0] <= price:
|
||||||
|
ans += self.stack.pop()[1]
|
||||||
|
|
||||||
|
self.stack.append([price, ans])
|
||||||
|
return ans
|
||||||
|
|
||||||
|
|
||||||
|
# Your StockSpanner object will be instantiated and called as such:
|
||||||
|
# obj = StockSpanner()
|
||||||
|
# param_1 = obj.next(price)
|
||||||
|
```
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
title: Monotonic Stack
|
||||||
|
description: 'A stack keeps its items in sorted order, which finds the next larger or smaller item in one pass.'
|
||||||
|
hidden: true
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
<YouTube id="Dq_ObZwTY_Q" title="Monotonic Stack" />
|
||||||
|
|
||||||
|
A monotonic stack keeps its values in sorted order as you push elements. The order can be increasing or decreasing, depending on the problem.
|
||||||
|
|
||||||
|
A decreasing monotonic stack is the most common version. Before pushing a new value `x`, we pop from the top while the top is smaller than or equal to `x`. After those pops, pushing `x` keeps the stack decreasing from bottom to top.
|
||||||
|
|
||||||
|
This push rule is the whole idea. A monotonic deque uses the same rule, but a deque also lets you remove old elements from the front, which is useful for sliding window problems.
|
||||||
|
|
||||||
|
## Example Implementation
|
||||||
|
|
||||||
|
This implementation maintains a decreasing monotonic stack.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def mono_stack(insert_entries):
|
||||||
|
stack = []
|
||||||
|
for entry in insert_entries:
|
||||||
|
# Pop smaller (or equal) values so stack stays decreasing.
|
||||||
|
while stack and stack[-1] <= entry:
|
||||||
|
stack.pop()
|
||||||
|
# Do something with the popped item here
|
||||||
|
stack.append(entry)
|
||||||
|
```
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
In interview problems, the stack usually stores indices instead of values. You can read the value from the original array when needed, and the index gives you distance information such as **"how many steps away is the next greater element."**
|
||||||
|
:::
|
||||||
|
|
||||||
|
For an array `nums`, if index `j` pops when you process index `i`, then `i` is the first index to the right that satisfies the stack condition for `j`. In a next-greater-element problem, that means `nums[i]` is the next greater value for `nums[j]`.
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({ title: "Monotonic Stack", order: 7, icon: "trending-up", display: "group" });
|
||||||
+8
@@ -7,6 +7,14 @@ topics:
|
|||||||
- { name: "Prefix Sum", slug: "prefix-sum" }
|
- { name: "Prefix Sum", slug: "prefix-sum" }
|
||||||
video: "bNvIQI2wAjk"
|
video: "bNvIQI2wAjk"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/37"
|
issue: "https://github.com/prdlk/leetcode/issues/37"
|
||||||
|
companies:
|
||||||
|
- { name: "Meta", period: "Thirty Days", frequency: 70.8 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 61.3 }
|
||||||
|
- { name: "Microsoft", period: "Thirty Days", frequency: 57.2 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 42.3 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 59.7 }
|
||||||
|
- { name: "Asana", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "tcs", period: "Six Months", frequency: 43.5 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Product of Array Except Self'
|
label: 'Product of Array Except Self'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+5
@@ -8,6 +8,11 @@ topics:
|
|||||||
- { name: "Prefix Sum", slug: "prefix-sum" }
|
- { name: "Prefix Sum", slug: "prefix-sum" }
|
||||||
video: "2pndAmo_sMA"
|
video: "2pndAmo_sMA"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/36"
|
issue: "https://github.com/prdlk/leetcode/issues/36"
|
||||||
|
companies:
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 42.3 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||||
|
- { name: "Meta", period: "Six Months", frequency: 24.5 }
|
||||||
|
- { name: "Microsoft", period: "Six Months", frequency: 24.1 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Range Sum Query - Immutable'
|
label: 'Range Sum Query - Immutable'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
+8
@@ -7,6 +7,14 @@ topics:
|
|||||||
- { name: "Hash Table", slug: "hash-table" }
|
- { name: "Hash Table", slug: "hash-table" }
|
||||||
- { name: "Prefix Sum", slug: "prefix-sum" }
|
- { name: "Prefix Sum", slug: "prefix-sum" }
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/39"
|
issue: "https://github.com/prdlk/leetcode/issues/39"
|
||||||
|
companies:
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||||
|
- { name: "Cisco", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 49.8 }
|
||||||
|
- { name: "Google", period: "Three Months", frequency: 17.3 }
|
||||||
|
- { name: "Akamai", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Bloomberg", period: "Six Months", frequency: 40.6 }
|
||||||
|
- { name: "Microsoft", period: "Six Months", frequency: 32 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Contiguous Array'
|
label: 'Contiguous Array'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+15
@@ -8,6 +8,21 @@ topics:
|
|||||||
- { name: "Prefix Sum", slug: "prefix-sum" }
|
- { name: "Prefix Sum", slug: "prefix-sum" }
|
||||||
video: "fFVZt-6sgyo"
|
video: "fFVZt-6sgyo"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/38"
|
issue: "https://github.com/prdlk/leetcode/issues/38"
|
||||||
|
companies:
|
||||||
|
- { name: "Meta", period: "Thirty Days", frequency: 63.8 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 50.2 }
|
||||||
|
- { name: "Visa", period: "Three Months", frequency: 80.9 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 74.8 }
|
||||||
|
- { name: "Salesforce", period: "Three Months", frequency: 64.7 }
|
||||||
|
- { name: "Amazon", period: "Three Months", frequency: 57.1 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 48.3 }
|
||||||
|
- { name: "Accenture", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "IBM", period: "Six Months", frequency: 94.5 }
|
||||||
|
- { name: "Infosys", period: "Six Months", frequency: 76.3 }
|
||||||
|
- { name: "Apple", period: "Six Months", frequency: 74.7 }
|
||||||
|
- { name: "josh technology", period: "Six Months", frequency: 74 }
|
||||||
|
- { name: "Walmart Labs", period: "Six Months", frequency: 70.5 }
|
||||||
|
- { name: "Yandex", period: "Six Months", frequency: 69.2 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Subarray Sum Equals K'
|
label: 'Subarray Sum Equals K'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+6
@@ -7,6 +7,12 @@ topics:
|
|||||||
- { name: "Hash Table", slug: "hash-table" }
|
- { name: "Hash Table", slug: "hash-table" }
|
||||||
- { name: "Prefix Sum", slug: "prefix-sum" }
|
- { name: "Prefix Sum", slug: "prefix-sum" }
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/40"
|
issue: "https://github.com/prdlk/leetcode/issues/40"
|
||||||
|
companies:
|
||||||
|
- { name: "Meta", period: "Thirty Days", frequency: 53.8 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 29 }
|
||||||
|
- { name: "TikTok", period: "Three Months", frequency: 70.9 }
|
||||||
|
- { name: "Amazon", period: "Three Months", frequency: 21.2 }
|
||||||
|
- { name: "Microsoft", period: "Six Months", frequency: 24.1 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Subarray Sums Divisible by K'
|
label: 'Subarray Sums Divisible by K'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+1
-1
@@ -27,7 +27,7 @@ The leading `prefix[0] = 0` is the sum of zero elements. That extra slot is what
|
|||||||
|
|
||||||
<Frame caption="Each bar is the running total up to that index. Every step keeps the previous total (blue) and adds one new element (amber)" hint="">
|
<Frame caption="Each bar is the running total up to that index. Every step keeps the previous total (blue) and adds one new element (amber)" hint="">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
</Frame>
|
</Frame>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({ title: "Prefix Sum", order: 2, icon: "sigma", display: "group" });
|
||||||
+11
@@ -8,6 +8,17 @@ topics:
|
|||||||
- { name: "Sliding Window", slug: "sliding-window" }
|
- { name: "Sliding Window", slug: "sliding-window" }
|
||||||
- { name: "Prefix Sum", slug: "prefix-sum" }
|
- { name: "Prefix Sum", slug: "prefix-sum" }
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/56"
|
issue: "https://github.com/prdlk/leetcode/issues/56"
|
||||||
|
companies:
|
||||||
|
- { name: "EPAM Systems", period: "Thirty Days", frequency: 89.8 }
|
||||||
|
- { name: "Meta", period: "Thirty Days", frequency: 53.8 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||||
|
- { name: "Tekion", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "DE Shaw", period: "Three Months", frequency: 80.2 }
|
||||||
|
- { name: "Google", period: "Three Months", frequency: 38.7 }
|
||||||
|
- { name: "Infosys", period: "Six Months", frequency: 67.9 }
|
||||||
|
- { name: "Yandex", period: "Six Months", frequency: 59.6 }
|
||||||
|
- { name: "LinkedIn", period: "Six Months", frequency: 54.1 }
|
||||||
|
- { name: "Microsoft", period: "Six Months", frequency: 42.5 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Max Consecutive Ones III'
|
label: 'Max Consecutive Ones III'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+20
@@ -7,6 +7,26 @@ topics:
|
|||||||
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
||||||
video: "1pkOgXD63yU"
|
video: "1pkOgXD63yU"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/50"
|
issue: "https://github.com/prdlk/leetcode/issues/50"
|
||||||
|
companies:
|
||||||
|
- { name: "Bloomberg", period: "Thirty Days", frequency: 83.1 }
|
||||||
|
- { name: "Infosys", period: "Thirty Days", frequency: 80.2 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 69.6 }
|
||||||
|
- { name: "Ola Cabs", period: "Thirty Days", frequency: 65.6 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 65.3 }
|
||||||
|
- { name: "tcs", period: "Thirty Days", frequency: 58.2 }
|
||||||
|
- { name: "Atlassian", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Media.net", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Nvidia", period: "Three Months", frequency: 87.7 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 70.4 }
|
||||||
|
- { name: "Apple", period: "Three Months", frequency: 69 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 60.5 }
|
||||||
|
- { name: "Mastercard", period: "Six Months", frequency: 79 }
|
||||||
|
- { name: "Accenture", period: "Six Months", frequency: 75 }
|
||||||
|
- { name: "Adobe", period: "Six Months", frequency: 74.3 }
|
||||||
|
- { name: "Walmart Labs", period: "Six Months", frequency: 70.5 }
|
||||||
|
- { name: "IBM", period: "Six Months", frequency: 70.1 }
|
||||||
|
- { name: "TikTok", period: "Six Months", frequency: 55.2 }
|
||||||
|
- { name: "Goldman Sachs", period: "Six Months", frequency: 53.1 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Best Time to Buy and Sell Stock'
|
label: 'Best Time to Buy and Sell Stock'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
+6
@@ -8,6 +8,12 @@ topics:
|
|||||||
- { name: "Sliding Window", slug: "sliding-window" }
|
- { name: "Sliding Window", slug: "sliding-window" }
|
||||||
- { name: "Prefix Sum", slug: "prefix-sum" }
|
- { name: "Prefix Sum", slug: "prefix-sum" }
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/54"
|
issue: "https://github.com/prdlk/leetcode/issues/54"
|
||||||
|
companies:
|
||||||
|
- { name: "Microsoft", period: "Thirty Days", frequency: 67.9 }
|
||||||
|
- { name: "Meta", period: "Thirty Days", frequency: 53.8 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 46.8 }
|
||||||
|
- { name: "Google", period: "Three Months", frequency: 45 }
|
||||||
|
- { name: "Amazon", period: "Three Months", frequency: 28.9 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Minimum Size Subarray Sum'
|
label: 'Minimum Size Subarray Sum'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+29
@@ -8,6 +8,35 @@ topics:
|
|||||||
- { name: "Sliding Window", slug: "sliding-window" }
|
- { name: "Sliding Window", slug: "sliding-window" }
|
||||||
video: "wiGpQwVHdE0"
|
video: "wiGpQwVHdE0"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/51"
|
issue: "https://github.com/prdlk/leetcode/issues/51"
|
||||||
|
companies:
|
||||||
|
- { name: "Ola Cabs", period: "Thirty Days", frequency: 100 }
|
||||||
|
- { name: "Visa", period: "Thirty Days", frequency: 100 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 84.9 }
|
||||||
|
- { name: "Microsoft", period: "Thirty Days", frequency: 75.6 }
|
||||||
|
- { name: "Bloomberg", period: "Thirty Days", frequency: 69.8 }
|
||||||
|
- { name: "Meta", period: "Thirty Days", frequency: 63.8 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 63.7 }
|
||||||
|
- { name: "tcs", period: "Thirty Days", frequency: 58.2 }
|
||||||
|
- { name: "Cognizant", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Optum", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Spotify", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Deloitte", period: "Three Months", frequency: 89.1 }
|
||||||
|
- { name: "Infosys", period: "Three Months", frequency: 87.4 }
|
||||||
|
- { name: "Apple", period: "Three Months", frequency: 69 }
|
||||||
|
- { name: "Goldman Sachs", period: "Three Months", frequency: 66.3 }
|
||||||
|
- { name: "Media.net", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Wipro", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Wissen Technology", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Zoho", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Qualcomm", period: "Six Months", frequency: 92 }
|
||||||
|
- { name: "Turing", period: "Six Months", frequency: 89 }
|
||||||
|
- { name: "EPAM Systems", period: "Six Months", frequency: 86.7 }
|
||||||
|
- { name: "Netflix", period: "Six Months", frequency: 83.1 }
|
||||||
|
- { name: "Tesla", period: "Six Months", frequency: 80.9 }
|
||||||
|
- { name: "Yandex", period: "Six Months", frequency: 69.2 }
|
||||||
|
- { name: "Agoda", period: "Six Months", frequency: 65 }
|
||||||
|
- { name: "IBM", period: "Six Months", frequency: 60.1 }
|
||||||
|
- { name: "LinkedIn", period: "Six Months", frequency: 54.1 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Longest Substring Without Repeating Characters'
|
label: 'Longest Substring Without Repeating Characters'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+9
@@ -8,6 +8,15 @@ topics:
|
|||||||
- { name: "Sliding Window", slug: "sliding-window" }
|
- { name: "Sliding Window", slug: "sliding-window" }
|
||||||
video: "gqXU1UyA8pk"
|
video: "gqXU1UyA8pk"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/52"
|
issue: "https://github.com/prdlk/leetcode/issues/52"
|
||||||
|
companies:
|
||||||
|
- { name: "Zoho", period: "Thirty Days", frequency: 100 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 46.6 }
|
||||||
|
- { name: "DE Shaw", period: "Three Months", frequency: 91.8 }
|
||||||
|
- { name: "Infosys", period: "Three Months", frequency: 66 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 46.8 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 42.1 }
|
||||||
|
- { name: "Amazon", period: "Three Months", frequency: 34.6 }
|
||||||
|
- { name: "Meta", period: "Six Months", frequency: 32.7 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Longest Repeating Character Replacement'
|
label: 'Longest Repeating Character Replacement'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+8
@@ -9,6 +9,14 @@ topics:
|
|||||||
- { name: "Sliding Window", slug: "sliding-window" }
|
- { name: "Sliding Window", slug: "sliding-window" }
|
||||||
video: "UbyhOgBN834"
|
video: "UbyhOgBN834"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/53"
|
issue: "https://github.com/prdlk/leetcode/issues/53"
|
||||||
|
companies:
|
||||||
|
- { name: "Meta", period: "Thirty Days", frequency: 63.8 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 48.9 }
|
||||||
|
- { name: "tcs", period: "Three Months", frequency: 47.7 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 46.8 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 33.4 }
|
||||||
|
- { name: "Google", period: "Three Months", frequency: 17.3 }
|
||||||
|
- { name: "Apple", period: "Six Months", frequency: 48.1 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Permutation in String'
|
label: 'Permutation in String'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+7
@@ -6,6 +6,13 @@ topics:
|
|||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Sliding Window", slug: "sliding-window" }
|
- { name: "Sliding Window", slug: "sliding-window" }
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/55"
|
issue: "https://github.com/prdlk/leetcode/issues/55"
|
||||||
|
companies:
|
||||||
|
- { name: "Bloomberg", period: "Thirty Days", frequency: 83.1 }
|
||||||
|
- { name: "Microsoft", period: "Thirty Days", frequency: 67.9 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 55.9 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 36.7 }
|
||||||
|
- { name: "tcs", period: "Three Months", frequency: 57.2 }
|
||||||
|
- { name: "Meta", period: "Six Months", frequency: 32.7 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Maximum Average Subarray I'
|
label: 'Maximum Average Subarray I'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
title: '76. Minimum Window Substring'
|
||||||
|
description: Given two strings s and t of lengths m and n respectively, return the minimum window substring of s such that every character in t (including duplicates) is included in the window. If there is no such substring, return the empty string ""
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Hash Table", slug: "hash-table" }
|
||||||
|
- { name: "String", slug: "string" }
|
||||||
|
- { name: "Sliding Window", slug: "sliding-window" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/57"
|
||||||
|
companies:
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 55.9 }
|
||||||
|
- { name: "Lyft", period: "Three Months", frequency: 93.3 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 48.3 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 34.5 }
|
||||||
|
- { name: "Google", period: "Three Months", frequency: 23.9 }
|
||||||
|
- { name: "Oracle", period: "Six Months", frequency: 78.7 }
|
||||||
|
- { name: "Goldman Sachs", period: "Six Months", frequency: 44.8 }
|
||||||
|
- { name: "Bloomberg", period: "Six Months", frequency: 25.5 }
|
||||||
|
sidebar:
|
||||||
|
label: 'Minimum Window Substring'
|
||||||
|
badge: 'Hard'
|
||||||
|
---
|
||||||
|
|
||||||
|
::::warning
|
||||||
|
Could you find an algorithm that runs in O(m + n) time?
|
||||||
|
::::
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `s = "ADOBECODEBANC", t = "ABC"`
|
||||||
|
- Output: `"BANC"`
|
||||||
|
- Explanation: The minimum window substring "BANC" includes 'A', 'B', and 'C' from string `t`.
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `s = "a", t = "a"`
|
||||||
|
- Output: `"a"`
|
||||||
|
- Explanation: The entire string `s` is the minimum window.
|
||||||
|
|
||||||
|
### Example 3:
|
||||||
|
- Input: `s = "a", t = "aa"`
|
||||||
|
- Output: `""`
|
||||||
|
- Explanation: Both 'a's from `t` must be included in the window. Since the largest window of `s` only has one 'a', return empty string.
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `m == s.length`
|
||||||
|
- `n == t.length`
|
||||||
|
- `1 <= m, n <= 10^5`
|
||||||
|
- `s` and `t` consist of uppercase and lowercase English letters.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def minWindow(self, s: str, t: str) -> str:
|
||||||
|
```
|
||||||
+3
-3
@@ -42,7 +42,7 @@ The window size never changes. The main task is updating the window state effici
|
|||||||
|
|
||||||
<Frame caption="Sliding Window Fixed" hint="">
|
<Frame caption="Sliding Window Fixed" hint="">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ Here the window expands until invalid, then shrinks until valid again.
|
|||||||
|
|
||||||
<Frame caption="Sliding Window Flexible" hint="">
|
<Frame caption="Sliding Window Flexible" hint="">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ The difficulty comes from keeping the bookkeeping correct as characters enter an
|
|||||||
|
|
||||||
<Frame caption="Frequency-tracking windows" hint="">
|
<Frame caption="Frequency-tracking windows" hint="">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({ title: "Sliding Window", order: 4, icon: "app-window", display: "group" });
|
||||||
+6
@@ -8,6 +8,12 @@ topics:
|
|||||||
- { name: "Stack", slug: "stack" }
|
- { name: "Stack", slug: "stack" }
|
||||||
video: "iu0082c4HDE"
|
video: "iu0082c4HDE"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/69"
|
issue: "https://github.com/prdlk/leetcode/issues/69"
|
||||||
|
companies:
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||||
|
- { name: "Google", period: "Three Months", frequency: 17.3 }
|
||||||
|
- { name: "Yandex", period: "Six Months", frequency: 59.6 }
|
||||||
|
- { name: "Meta", period: "Six Months", frequency: 38.7 }
|
||||||
|
- { name: "Bloomberg", period: "Six Months", frequency: 25.5 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Evaluate Reverse Polish Notation'
|
label: 'Evaluate Reverse Polish Notation'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+9
@@ -7,6 +7,15 @@ topics:
|
|||||||
- { name: "Design", slug: "design" }
|
- { name: "Design", slug: "design" }
|
||||||
video: "qkLl7nAwDPo"
|
video: "qkLl7nAwDPo"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/68"
|
issue: "https://github.com/prdlk/leetcode/issues/68"
|
||||||
|
companies:
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 42.1 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 34.5 }
|
||||||
|
- { name: "Amazon", period: "Three Months", frequency: 28.9 }
|
||||||
|
- { name: "Google", period: "Three Months", frequency: 28.8 }
|
||||||
|
- { name: "Odoo", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "UiPath", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Infosys", period: "Six Months", frequency: 67.9 }
|
||||||
|
- { name: "Bloomberg", period: "Six Months", frequency: 45.6 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Min Stack'
|
label: 'Min Stack'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+18
@@ -8,6 +8,24 @@ topics:
|
|||||||
- { name: "Bracket Sequences", slug: "bracket-sequences" }
|
- { name: "Bracket Sequences", slug: "bracket-sequences" }
|
||||||
video: "WTzjTskDFMg"
|
video: "WTzjTskDFMg"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/67"
|
issue: "https://github.com/prdlk/leetcode/issues/67"
|
||||||
|
companies:
|
||||||
|
- { name: "BlackRock", period: "Thirty Days", frequency: 100 }
|
||||||
|
- { name: "Microsoft", period: "Thirty Days", frequency: 67.9 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 61.3 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 42.3 }
|
||||||
|
- { name: "Accolite", period: "Three Months", frequency: 100 }
|
||||||
|
- { name: "Intuit", period: "Three Months", frequency: 73.2 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 68.3 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 65.3 }
|
||||||
|
- { name: "tcs", period: "Three Months", frequency: 57.2 }
|
||||||
|
- { name: "Autodesk", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Criteo", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Odoo", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Tripadvisor", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Virtu Financial", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "IBM", period: "Six Months", frequency: 91.1 }
|
||||||
|
- { name: "Epic Systems", period: "Six Months", frequency: 89.6 }
|
||||||
|
- { name: "LinkedIn", period: "Six Months", frequency: 85.6 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Valid Parentheses'
|
label: 'Valid Parentheses'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
title: '224. Basic Calculator'
|
||||||
|
description: Given a string s representing a valid expression, implement a basic calculator to evaluate it, and return the result of the evaluation
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Math", slug: "math" }
|
||||||
|
- { name: "String", slug: "string" }
|
||||||
|
- { name: "Stack", slug: "stack" }
|
||||||
|
- { name: "Recursion", slug: "recursion" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/71"
|
||||||
|
companies:
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 54 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 42.1 }
|
||||||
|
- { name: "Amazon", period: "Three Months", frequency: 28.9 }
|
||||||
|
- { name: "Google", period: "Three Months", frequency: 23.9 }
|
||||||
|
- { name: "Meta", period: "Six Months", frequency: 24.5 }
|
||||||
|
sidebar:
|
||||||
|
label: 'Basic Calculator'
|
||||||
|
badge: 'Hard'
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `s = "1 + 1"`
|
||||||
|
- Output: `2`
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `s = " 2-1 + 2 "`
|
||||||
|
- Output: `3`
|
||||||
|
|
||||||
|
### Example 3:
|
||||||
|
- Input: `s = "(1+(4+5+2)-3)+(6+8)"`
|
||||||
|
- Output: `23`
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `1 <= s.length <= 3 * 10^5`
|
||||||
|
- `s` consists of digits, '+', '-', '(', ')', and ' '.
|
||||||
|
- `s` represents a valid expression.
|
||||||
|
- '+' is not used as a unary operation (i.e., "+1" and "+(2 + 3)" is invalid).
|
||||||
|
- '-' could be used as a unary operation (i.e., "-1" and "-(2 + 3)" is valid).
|
||||||
|
- There will be no two consecutive operators in the input.
|
||||||
|
- Every number and running calculation will fit in a signed 32-bit integer.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def calculate(self, s: str) -> int:
|
||||||
|
```
|
||||||
+7
@@ -6,6 +6,13 @@ topics:
|
|||||||
- { name: "String", slug: "string" }
|
- { name: "String", slug: "string" }
|
||||||
- { name: "Stack", slug: "stack" }
|
- { name: "Stack", slug: "stack" }
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/70"
|
issue: "https://github.com/prdlk/leetcode/issues/70"
|
||||||
|
companies:
|
||||||
|
- { name: "Bloomberg", period: "Thirty Days", frequency: 69.8 }
|
||||||
|
- { name: "Google", period: "Three Months", frequency: 28.8 }
|
||||||
|
- { name: "Amazon", period: "Three Months", frequency: 21.2 }
|
||||||
|
- { name: "Snowflake", period: "Six Months", frequency: 58.7 }
|
||||||
|
- { name: "Meta", period: "Six Months", frequency: 50.6 }
|
||||||
|
- { name: "Microsoft", period: "Six Months", frequency: 24.1 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Simplify Path'
|
label: 'Simplify Path'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
title: Stack
|
||||||
|
description: 'Items are added and removed from one end, so the last item added is the first item removed.'
|
||||||
|
hidden: true
|
||||||
|
---
|
||||||
|
|
||||||
|
A stack works like a stack of plates in a cafeteria. You add new plates on top. When you need a plate, you take from the top. The last plate you added is the first one you remove.
|
||||||
|
|
||||||
|
This is **Last-In-First-Out (LIFO)**. The most recent item goes out first.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Core Stack Operations
|
||||||
|
|
||||||
|
A stack has two fundamental operations:
|
||||||
|
|
||||||
|
- **Push**: add an item to the top of the stack
|
||||||
|
- **Pop**: remove the item at the top of the stack
|
||||||
|
|
||||||
|
Some stacks have additional operations:
|
||||||
|
|
||||||
|
- **Peek**: get the item at the top of the stack without removing it
|
||||||
|
- **IsEmpty**: check if the stack is empty
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```python
|
||||||
|
# Using Python list as a stack
|
||||||
|
stack = []
|
||||||
|
|
||||||
|
# Push elements
|
||||||
|
stack.append(10)
|
||||||
|
stack.append(20)
|
||||||
|
stack.append(30)
|
||||||
|
# Stack: [10, 20, 30] (30 is on top)
|
||||||
|
|
||||||
|
# Pop element
|
||||||
|
top = stack.pop() # Returns 30
|
||||||
|
# Stack: [10, 20]
|
||||||
|
|
||||||
|
# Peek at top
|
||||||
|
if stack:
|
||||||
|
top_element = stack[-1] # Returns 20 without removing
|
||||||
|
```
|
||||||
|
|
||||||
|
Python lists provide `append()` for pushing and `pop()` for popping. The `peek()` method is not built-in, but you can implement it yourself.
|
||||||
|
|
||||||
|
## Last in, First out
|
||||||
|
|
||||||
|
The defining characteristic of a stack is LIFO order. Whatever you put in last comes out first. This is different from a queue, which is First-In-First-Out (FIFO).
|
||||||
|
|
||||||
|
Add items: A, B, C
|
||||||
|
|
||||||
|
- Stack: [A, B, C] (C on top)
|
||||||
|
|
||||||
|
Remove items:
|
||||||
|
|
||||||
|
- First pop: C
|
||||||
|
- Second pop: B
|
||||||
|
- Third pop: A
|
||||||
|
|
||||||
|
The order reverses. Items come out in the opposite order they went in.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({ title: "Stack", order: 6, icon: "layers", display: "group" });
|
||||||
+12
@@ -8,6 +8,18 @@ topics:
|
|||||||
- { name: "Greedy", slug: "greedy" }
|
- { name: "Greedy", slug: "greedy" }
|
||||||
video: "UuiTKBwPgAo"
|
video: "UuiTKBwPgAo"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/44"
|
issue: "https://github.com/prdlk/leetcode/issues/44"
|
||||||
|
companies:
|
||||||
|
- { name: "Bloomberg", period: "Thirty Days", frequency: 83.1 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 75.8 }
|
||||||
|
- { name: "Infosys", period: "Thirty Days", frequency: 68.6 }
|
||||||
|
- { name: "Microsoft", period: "Thirty Days", frequency: 67.9 }
|
||||||
|
- { name: "tcs", period: "Thirty Days", frequency: 67.6 }
|
||||||
|
- { name: "Ola Cabs", period: "Thirty Days", frequency: 65.6 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 62 }
|
||||||
|
- { name: "Meta", period: "Thirty Days", frequency: 53.8 }
|
||||||
|
- { name: "Goldman Sachs", period: "Three Months", frequency: 57 }
|
||||||
|
- { name: "Walmart Labs", period: "Six Months", frequency: 70.5 }
|
||||||
|
- { name: "IBM", period: "Six Months", frequency: 60.1 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Container With Most Water'
|
label: 'Container With Most Water'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+12
@@ -7,6 +7,18 @@ topics:
|
|||||||
- { name: "String", slug: "string" }
|
- { name: "String", slug: "string" }
|
||||||
video: "jJXJ16kPFWg"
|
video: "jJXJ16kPFWg"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/41"
|
issue: "https://github.com/prdlk/leetcode/issues/41"
|
||||||
|
companies:
|
||||||
|
- { name: "tcs", period: "Thirty Days", frequency: 58.2 }
|
||||||
|
- { name: "Meta", period: "Thirty Days", frequency: 53.8 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 29 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 79.9 }
|
||||||
|
- { name: "Infosys", period: "Three Months", frequency: 66 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 42.1 }
|
||||||
|
- { name: "Deloitte", period: "Six Months", frequency: 81 }
|
||||||
|
- { name: "Cognizant", period: "Six Months", frequency: 76.3 }
|
||||||
|
- { name: "Axon", period: "Six Months", frequency: 75.4 }
|
||||||
|
- { name: "Apple", period: "Six Months", frequency: 64.8 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Valid Palindrome'
|
label: 'Valid Palindrome'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
+16
@@ -8,6 +8,22 @@ topics:
|
|||||||
- { name: "Sorting", slug: "sorting" }
|
- { name: "Sorting", slug: "sorting" }
|
||||||
video: "jzZsG8n2R9A"
|
video: "jzZsG8n2R9A"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/43"
|
issue: "https://github.com/prdlk/leetcode/issues/43"
|
||||||
|
companies:
|
||||||
|
- { name: "Bloomberg", period: "Thirty Days", frequency: 69.8 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 69.6 }
|
||||||
|
- { name: "Microsoft", period: "Thirty Days", frequency: 67.9 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 66.8 }
|
||||||
|
- { name: "Ola Cabs", period: "Thirty Days", frequency: 65.6 }
|
||||||
|
- { name: "Meta", period: "Thirty Days", frequency: 63.8 }
|
||||||
|
- { name: "Apple", period: "Three Months", frequency: 80.4 }
|
||||||
|
- { name: "Infosys", period: "Three Months", frequency: 66 }
|
||||||
|
- { name: "tcs", period: "Three Months", frequency: 57.2 }
|
||||||
|
- { name: "Cisco", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Wissen Technology", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "eBay", period: "Six Months", frequency: 75.2 }
|
||||||
|
- { name: "Accenture", period: "Six Months", frequency: 75 }
|
||||||
|
- { name: "Visa", period: "Six Months", frequency: 69.7 }
|
||||||
|
- { name: "Goldman Sachs", period: "Six Months", frequency: 53.1 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: '3Sum'
|
label: '3Sum'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+8
@@ -8,6 +8,14 @@ topics:
|
|||||||
- { name: "Binary Search", slug: "binary-search" }
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
video: "cQ1Oz4ckceM"
|
video: "cQ1Oz4ckceM"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/42"
|
issue: "https://github.com/prdlk/leetcode/issues/42"
|
||||||
|
companies:
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 65.8 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 46.6 }
|
||||||
|
- { name: "tcs", period: "Three Months", frequency: 57.2 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 54.8 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 33.4 }
|
||||||
|
- { name: "Oracle", period: "Six Months", frequency: 78.7 }
|
||||||
|
- { name: "Bloomberg", period: "Six Months", frequency: 40.6 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Two Sum II - Input Array Is Sorted'
|
label: 'Two Sum II - Input Array Is Sorted'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+11
@@ -7,6 +7,17 @@ topics:
|
|||||||
- { name: "Math", slug: "math" }
|
- { name: "Math", slug: "math" }
|
||||||
- { name: "Two Pointers", slug: "two-pointers" }
|
- { name: "Two Pointers", slug: "two-pointers" }
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/47"
|
issue: "https://github.com/prdlk/leetcode/issues/47"
|
||||||
|
companies:
|
||||||
|
- { name: "Bloomberg", period: "Thirty Days", frequency: 69.8 }
|
||||||
|
- { name: "Microsoft", period: "Thirty Days", frequency: 67.9 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 48.9 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 46.6 }
|
||||||
|
- { name: "Infosys", period: "Three Months", frequency: 66 }
|
||||||
|
- { name: "tcs", period: "Three Months", frequency: 57.2 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 54.8 }
|
||||||
|
- { name: "Box", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "TikTok", period: "Six Months", frequency: 55.2 }
|
||||||
|
- { name: "Apple", period: "Six Months", frequency: 48.1 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Rotate Array'
|
label: 'Rotate Array'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
+8
@@ -6,6 +6,14 @@ topics:
|
|||||||
- { name: "Two Pointers", slug: "two-pointers" }
|
- { name: "Two Pointers", slug: "two-pointers" }
|
||||||
- { name: "String", slug: "string" }
|
- { name: "String", slug: "string" }
|
||||||
video: "_d0T_2Lk2qA"
|
video: "_d0T_2Lk2qA"
|
||||||
|
companies:
|
||||||
|
- { name: "tcs", period: "Thirty Days", frequency: 58.2 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 50.2 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 48.3 }
|
||||||
|
- { name: "Bloomberg", period: "Three Months", frequency: 46.8 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 34.5 }
|
||||||
|
- { name: "Qualcomm", period: "Six Months", frequency: 80.7 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Reverse String'
|
label: 'Reverse String'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
+6
@@ -8,6 +8,12 @@ topics:
|
|||||||
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
||||||
video: "99RVfqklbCE"
|
video: "99RVfqklbCE"
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/45"
|
issue: "https://github.com/prdlk/leetcode/issues/45"
|
||||||
|
companies:
|
||||||
|
- { name: "Amazon", period: "Three Months", frequency: 34.6 }
|
||||||
|
- { name: "Bloomberg", period: "Six Months", frequency: 25.5 }
|
||||||
|
- { name: "Google", period: "Six Months", frequency: 25.4 }
|
||||||
|
- { name: "Meta", period: "Six Months", frequency: 24.5 }
|
||||||
|
- { name: "Microsoft", period: "Six Months", frequency: 24.1 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Is Subsequence'
|
label: 'Is Subsequence'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
+14
@@ -9,6 +9,20 @@ topics:
|
|||||||
- { name: "Stack", slug: "stack" }
|
- { name: "Stack", slug: "stack" }
|
||||||
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
||||||
issue: "https://github.com/prdlk/leetcode/issues/211"
|
issue: "https://github.com/prdlk/leetcode/issues/211"
|
||||||
|
companies:
|
||||||
|
- { name: "Ola Cabs", period: "Thirty Days", frequency: 90.7 }
|
||||||
|
- { name: "Amazon", period: "Thirty Days", frequency: 80.7 }
|
||||||
|
- { name: "Goldman Sachs", period: "Thirty Days", frequency: 71.7 }
|
||||||
|
- { name: "Bloomberg", period: "Thirty Days", frequency: 69.8 }
|
||||||
|
- { name: "Infosys", period: "Thirty Days", frequency: 68.6 }
|
||||||
|
- { name: "Google", period: "Thirty Days", frequency: 63.7 }
|
||||||
|
- { name: "Snowflake", period: "Three Months", frequency: 90.3 }
|
||||||
|
- { name: "Meta", period: "Three Months", frequency: 58.9 }
|
||||||
|
- { name: "Microsoft", period: "Three Months", frequency: 57.2 }
|
||||||
|
- { name: "tcs", period: "Three Months", frequency: 47.7 }
|
||||||
|
- { name: "Docusign", period: "Six Months", frequency: 100 }
|
||||||
|
- { name: "Qualcomm", period: "Six Months", frequency: 80.7 }
|
||||||
|
- { name: "Nvidia", period: "Six Months", frequency: 78.7 }
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Trapping Rain Water'
|
label: 'Trapping Rain Water'
|
||||||
badge: 'Hard'
|
badge: 'Hard'
|
||||||
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user