mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9899813281 | ||
|
|
a19fd95cc4 | ||
|
|
799dc2bb80 | ||
|
|
978589b53e | ||
|
|
876868f2a4 | ||
|
|
16317c8645 | ||
|
|
fdd54e4a1c | ||
|
|
24ef28d8b1 | ||
|
|
b461d9cab7 | ||
|
|
fd57f53d34 | ||
|
|
4b8496c113 | ||
|
|
7f6a7d4776 | ||
|
|
45bd3f771d | ||
|
|
c3c482d954 | ||
|
|
65f5965a12 | ||
|
|
a20be5182f | ||
|
|
c05e7f0ef2 | ||
|
|
76c2a64fc6 | ||
|
|
eb1002727b | ||
|
|
41c8c2036e | ||
|
|
31054c01a7 | ||
|
|
239c7013f8 | ||
|
|
7e9ea8d7c2 | ||
|
|
4548289ecf | ||
|
|
d6b483f84e | ||
|
|
a64a6c2379 | ||
|
|
ffa8a11f51 | ||
|
|
d722feda1c | ||
|
|
9c7d48aad8 | ||
|
|
1d77c5a8b5 | ||
|
|
b2dffa4615 | ||
|
|
f8ff12b55e | ||
|
|
c791974271 |
No files matched your search
@@ -62,13 +62,17 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
bun apps/cli/sync-project-fields.ts
|
bun apps/cli/sync-project-fields.ts
|
||||||
|
|
||||||
# One timever release per finished topic (every core sub-issue closed),
|
# One timever release per finished topic (every core sub-issue closed) and
|
||||||
# tagged at the instant the last core problem closed. It reads the same rule
|
# per finished `Review <N> — <Phase>` issue (every sub-issue closed), tagged
|
||||||
|
# at the instant the last required problem closed. It reads the same rules
|
||||||
# close-topics uses (apps/cli/topics.ts) straight off the sub-issues, so the
|
# close-topics uses (apps/cli/topics.ts) straight off the sub-issues, so the
|
||||||
# push that finishes a topic releases it in this run — no waiting for Close
|
# push that finishes a topic or review releases it in this run — no waiting
|
||||||
# Topics, which chains after this workflow. Reconciling: a run releases
|
# for Close Topics, which chains after this workflow. A problem re-parented
|
||||||
# every finished topic still missing one, so a manual dispatch backfills and
|
# into a Review issue still belongs to its topic (apps/api/src/reviews.ts),
|
||||||
# a problem closed by hand in the UI is picked up by the next run.
|
# so moving it never rewrites a topic's release. Reconciling: a run releases
|
||||||
|
# everything finished and still missing a release, so a manual dispatch
|
||||||
|
# backfills and a problem closed by hand in the UI is picked up by the next
|
||||||
|
# run. The docs changelog (deploy.yml) tags each release Topic or Review.
|
||||||
release:
|
release:
|
||||||
needs: close
|
needs: close
|
||||||
# Like close-topics: a partially failed close still closed some issues,
|
# Like close-topics: a partially failed close still closed some issues,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
name: Close Topics
|
name: Close Topics
|
||||||
|
|
||||||
# No D1 push here, unlike Close Solved: D1 stores no topic completion column.
|
# Closes finished topics AND finished `Review <N> — <Phase>` issues (every
|
||||||
# Phase progress, the gate pools and the charts all derive from the problem
|
# sub-issue closed). Problems moved from a topic into a Review issue still
|
||||||
# rows, which close-solved already logs — a topic is finished exactly when its
|
# count toward that topic (apps/cli/topics.ts).
|
||||||
# core problems are, so there is nothing left to reconcile.
|
#
|
||||||
|
# No D1 push here, unlike Close Solved: D1 stores no topic or review
|
||||||
|
# completion column. Phase progress, the gate pools and the charts all derive
|
||||||
|
# from the problem rows, which close-solved already logs — a topic or review
|
||||||
|
# is finished exactly when its problems are, so there is nothing left to
|
||||||
|
# reconcile.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Close Solved closes problem issues with GITHUB_TOKEN, and token-driven
|
# Close Solved closes problem issues with GITHUB_TOKEN, and token-driven
|
||||||
@@ -19,7 +24,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
dry_run:
|
dry_run:
|
||||||
description: Report finished topics without closing anything
|
description: Report finished topics and reviews without closing anything
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
@@ -34,7 +39,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
close:
|
close:
|
||||||
# The `issues` trigger fires for every issue in the repo; only closing a
|
# The `issues` trigger fires for every issue in the repo; only closing a
|
||||||
# problem sub-issue can ever complete a topic's core set.
|
# problem sub-issue can ever complete a topic's core set or a review.
|
||||||
if: github.event_name != 'issues' || contains(github.event.issue.labels.*.name, 'problem')
|
if: github.event_name != 'issues' || contains(github.event.issue.labels.*.name, 'problem')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ jobs:
|
|||||||
- uses: oven-sh/setup-bun@v2 # skip if you use npm/pnpm
|
- uses: oven-sh/setup-bun@v2 # skip if you use npm/pnpm
|
||||||
|
|
||||||
- run: bun install --frozen-lockfile
|
- run: bun install --frozen-lockfile
|
||||||
# blume.config.ts pulls the /changelog pages from this repo's GitHub
|
# pages/changelog.astro reads this repo's GitHub releases at build time
|
||||||
# releases (githubReleases source) at build time. The token authenticates
|
# (apps/docs/lib/releases.ts). The token authenticates that fetch —
|
||||||
# that fetch — required if the repo is private, and it lifts the shared
|
# required if the repo is private, and it lifts the shared runner's
|
||||||
# runner's unauthenticated API rate limit. `contents: read` covers it.
|
# unauthenticated API rate limit. `contents: read` covers it.
|
||||||
- run: bun run build
|
- run: bun run build
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
|
LeetCode interview-prep campaign (2026-08-17 → Nov 3: eight weeks of topics, then the Review-issue phase; the end is the last date in `apps/api/data/schedule.json` — `CAMPAIGN_END`/`CAMPAIGN_DAYS` in `apps/api/src/srs.ts` are derived from it, never declared) run as a repo: solution files under `work/`, 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
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@ flowchart LR
|
|||||||
work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
|
work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
|
||||||
work -->|close-solved.yml| PI[problem issues]
|
work -->|close-solved.yml| PI[problem issues]
|
||||||
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 issues]
|
PI -->|close-topics.yml| TI[topic + Review N issues]
|
||||||
PI -->|close-solved.yml release job| Rel[timever release per finished topic]
|
PI -->|close-solved.yml release job| Rel[timever release per finished topic / Review N]
|
||||||
GH[GitHub issues = catalog] -->|nightly reconcile| D1
|
GH[GitHub issues = catalog] -->|nightly reconcile| D1
|
||||||
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
|
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
|
||||||
Mail -->|GET /log| D1
|
Mail -->|GET /log| D1
|
||||||
@@ -38,7 +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 — Blume's changelog makes it the page's meta description — then closed on the finish day / earlier / open at release, with solution links at the tag). 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 these releases (`githubReleases` in `apps/docs/blume.config.ts`, fetched at build with `GITHUB_TOKEN`), dated by the timever tag, not `published_at` (which the API cannot backdate): `releasesDatedByTag` rewrites each timever release's `published_at` before Blume maps it, via `custom(githubReleasesSource(…))` because the config-level `githubReleases()` takes no `fetchImpl`. Changing the tag format breaks the changelog dates.
|
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first, then the problems closed on the finish day / earlier, with solution links at the tag — problems still open at the finish are left out). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs `/changelog` is ONE custom page, `apps/docs/pages/changelog.astro` (a NeonUI-style timeline: date left, a card per release right, no per-release subpages, so nothing of it enters the sidebar or prev/next), linked from the header by `navigation.actions` (not a tab). `apps/docs/lib/releases.ts` fetches the releases at build (`GITHUB_TOKEN` in `deploy.yml`; a failed fetch fails the build), dates each by its timever tag, not `published_at` (which the API cannot backdate), tags it `Topic` or `Review` from the release name, and lists the rows of its `### Closed …` tables — so the tag format and that table shape are the page's contract with `render()`. There is no changelog RSS feed.
|
||||||
|
- **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).
|
||||||
|
|
||||||
## Key Directories
|
## Key Directories
|
||||||
|
|
||||||
@@ -46,8 +48,8 @@ flowchart LR
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
||||||
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
|
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
|
||||||
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/<num>-<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync (a phase's once, then hand-owned): the phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment (`/tree-dfs/invert-binary-tree`), the `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order with each topic a collapsible group. A problem page's badge row (between the description and the body) is rendered, not written: the MDX `wrapper` (`components/ProblemBadges.astro`, registered as `mdx.wrapper` in `components.ts` — Blume's page template renders the title and description itself with no slot before the body, so the wrapper, which MDX hands no route, reads it off `Astro.url`) reads the page's `topics:` (LeetCode's topic tags, from sync) and `video:` (NeetCode YouTube id, from `bun run videos`) frontmatter — both declared via `frontmatter.extend` in `blume.config.ts`, hand-rolled Standard Schemas, no zod — and links a tag the campaign teaches to its topic landing page (filled), any other tag to its guide page `/guides/<slug>` (outlined; LeetCode's problem list if the guide is gone, gray and iconless), and the video as a red `Explanation` badge. Every badge in the row is a pill. A tag badge carries the icon its target shows in the sidebar (read off `data.navigation`: the topic `meta.ts` icon, the guide's `icon:`) and the tag's own colour from `lib/tag-colors.ts` — unique per tag site-wide, assigned greedily over every tag with a page so tags that share problem pages sit furthest apart in OKLab, in a lightness band that keeps ≥ 3:1 contrast in both themes (Blume's Badge takes one hex for both). The reverse direction is the `PageFooter` slot (`components/BacklinksList.astro`): an Obsidian-style `<num> <name>` list under every topic landing and guide page of the problems whose badges link there, plus a topic's own folder members (so a topic no tag names, `hybrid-structures`, still lists its problems), in a native `<details>` open on load whose top-right chevron collapses it (no JS); problem pages are recognised by their `<num>. <name>` title and render no list. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), and where a badge points is ONE function beside it, `tagTarget()` — read by both components (so badges and backlinks cannot disagree) and the map by sync. `content/guides/` (the `Guides` header tab) is one page per tag outside that map, filed by hand into `(group)` categories — `(1-data-structures)/`, `(2-algorithms)/`, `(3-techniques)/` (hand-written `meta.ts` each; no route segment, so every guide stays `/guides/<slug>`). Sync finds a guide by slug anywhere under `guides/` and creates a placeholder only when none exists or it is empty — at the `guides/` root, for you to file into a category and give a Lucide `icon:` — the one every badge linking to it shows — that no topic and no other guide uses (sync warns while it is missing, a `TOPIC_ICONS` value, or another guide's) — and after that owns exactly one key of it: `sidebar.badge`, the sidebar count, rewritten every run to the guide's backlink count (problem pages carrying the tag — equal to the `BacklinksList` rows, since a guide's tag has no landing; dropped at zero). Everything else is hand-owned (a guide whose tag no page carries any more is left alone, badge dropped), with frontmatter `list:` — the tag's LeetCode problem list, rendered by the same wrapper as an orange `LeetCode list` badge; sync also writes `guides/meta.ts`. A hand-written `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is that topic's landing page: Blume ≥ 1.7.3 links the sidebar group row to it (`sliding-window/index.mdx` → `/sliding-window`) and the hidden flag drops the duplicate row. Two kinds of custom MDX component, both import-free on every page: `islands/<Name>.tsx` is hydrated React (auto-discovered; the five `/progress` cards `SolvedRing`/`SubmissionHeatmap`/`SubmissionChart`/`TopicBubbles`/`RecentSolves`, and `DependencySpine` — the README topic map as data, zoom/pan, nodes link to whatever route Blume's `/api/docs/navigation.json` reports for the topic folder, inert until a topic has pages), `components/<Name>.astro` registered in `components.ts` is static markup plus a custom element for behaviour, the way Blume's own `Frame` is built (`Carousel`/`CarouselImage`: `src` takes a URL, `/file.png`, or the page-relative `../../public/file.png`) |
|
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/<num>-<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync (a phase's once, then hand-owned): the phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment (`/tree-dfs/invert-binary-tree`), the `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order with each topic a collapsible group. A problem page's badge row (between the description and the body) is rendered, not written: the MDX `wrapper` (`components/ProblemBadges.astro`, registered as `mdx.wrapper` in `components.ts` — Blume's page template renders the title and description itself with no slot before the body, so the wrapper, which MDX hands no route, reads it off `Astro.url`) reads the page's `topics:` (LeetCode's topic tags, from sync) and `video:` (NeetCode YouTube id, from `bun run videos`) frontmatter — both declared via `frontmatter.extend` in `blume.config.ts`, hand-rolled Standard Schemas, no zod — and links a tag the campaign teaches to its topic landing page (filled), any other tag to its Appendix guide page `/appendix/<slug>` (outlined; LeetCode's problem list if the guide is gone, gray and iconless), and the video as a red `Explanation` badge. Every badge in the row is a pill. A tag badge carries the icon its target shows in the sidebar (read off `data.navigation`: the topic `meta.ts` icon, the guide's `icon:`) and the tag's own colour from `lib/tag-colors.ts` — unique per tag site-wide, assigned greedily over every tag with a page so tags that share problem pages sit furthest apart in OKLab, in a lightness band that keeps ≥ 3:1 contrast in both themes (Blume's Badge takes one hex for both). The reverse direction is the `PageFooter` slot (`components/BacklinksList.astro`): an Obsidian-style `<num> <name>` list under every topic landing and guide page of the problems whose badges link there, plus a topic's own folder members (so a topic no tag names, `hybrid-structures`, still lists its problems), in a native `<details>` open on load whose top-right chevron collapses it (no JS); problem pages are recognised by their `<num>. <name>` title and render no list. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), and where a badge points is ONE function beside it, `tagTarget()` — read by both components (so badges and backlinks cannot disagree) and the map by sync. `content/appendix/` (the sidebar's `Appendix` row, right under Progress: `order: 0` ahead of the phase groups, `display: "page"` so it drills into its own panel — no header tab) is one page per tag outside that map, filed by hand into `(group)` categories — `(1-data-structures)/`, `(2-algorithms)/`, `(3-techniques)/` (hand-written `meta.ts` each; no route segment, so every guide stays `/appendix/<slug>`). Sync finds a guide by slug anywhere under `appendix/` and creates a placeholder only when none exists or it is empty — at the `appendix/` root, for you to file into a category and give a Lucide `icon:` — the one every badge linking to it shows — that no topic and no other guide uses (sync warns while it is missing, a `TOPIC_ICONS` value, or another guide's) — and after that owns exactly one key of it: `sidebar.badge`, the sidebar count, rewritten every run to the guide's backlink count (problem pages carrying the tag — equal to the `BacklinksList` rows, since a guide's tag has no landing; dropped at zero). Everything else is hand-owned (a guide whose tag no page carries any more is left alone, badge dropped), with frontmatter `list:` — the tag's LeetCode problem list, rendered by the same wrapper as an orange `LeetCode list` badge; sync also writes `appendix/meta.ts`. A hand-written `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is that topic's landing page: Blume ≥ 1.7.3 links the sidebar group row to it (`sliding-window/index.mdx` → `/sliding-window`) and the hidden flag drops the duplicate row. Two kinds of custom MDX component, both import-free on every page: `islands/<Name>.tsx` is hydrated React (auto-discovered; the five `/progress` cards `SolvedRing`/`SubmissionHeatmap`/`SubmissionChart`/`TopicBubbles`/`RecentSolves`, and `DependencySpine` — the README topic map as data, zoom/pan, nodes link to whatever route Blume's `/api/docs/navigation.json` reports for the topic folder, inert until a topic has pages), `components/<Name>.astro` registered in `components.ts` is static markup plus a custom element for behaviour, the way Blume's own `Frame` is built (`Carousel`/`CarouselImage`: `src` takes a URL, `/file.png`, or the page-relative `../../public/file.png`) |
|
||||||
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
|
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | review | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
|
||||||
| `.github/workflows/` | deploy (docs → Pages with `GITHUB_TOKEN` for the releases-backed changelog, Worker → Cloudflare with the `OPENROUTER_API_KEY` Actions secret put into it first, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
|
| `.github/workflows/` | deploy (docs → Pages with `GITHUB_TOKEN` for the releases-backed changelog, Worker → Cloudflare with the `OPENROUTER_API_KEY` Actions secret put into it first, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
|
||||||
| `.githooks/` | Tracked git hooks; `bun install` points `core.hooksPath` here (root `prepare` script, no husky). `pre-commit` runs `bun run sync` and stages only what sync changed (pages, `apps/docs/redirects.ts`), so a solution, its page and its `/<num>` short link land in one commit; it refuses to run while `work/1` has anything unstaged (sync reads the tree, so the page would not match the commit), and leaves pre-existing dirt under `apps/docs/content` (in-progress `## Approach` prose) unstaged. `git commit --no-verify` for a deliberate partial commit |
|
| `.githooks/` | Tracked git hooks; `bun install` points `core.hooksPath` here (root `prepare` script, no husky). `pre-commit` runs `bun run sync` and stages only what sync changed (pages, `apps/docs/redirects.ts`), so a solution, its page and its `/<num>` short link land in one commit; it refuses to run while `work/1` has anything unstaged (sync reads the tree, so the page would not match the commit), and leaves pre-existing dirt under `apps/docs/content` (in-progress `## Approach` prose) unstaged. `git commit --no-verify` for a deliberate partial commit |
|
||||||
|
|
||||||
@@ -109,9 +111,9 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
|||||||
- `apps/api/src/srs.ts` — SRS domain: the ladder (`new → +3 → +7 → retired`; fail resets to `+3`; first-ever log enters at `+3`), `WINDOWS`/`STAGES`/`TEMPERATURES`/`DIFFICULTIES`/`SETS` (the one vocabulary shared by the charts, the `work/<n>` buckets, the review issues and the stats dashboard), ET date math, `workingDay()` (no scheduled date is ever a Sunday), `temperatureOf()` (days since a concept was last exercised → `hot`/`fresh`/`fading`/`cold`, bands derived from `WINDOWS` so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
|
- `apps/api/src/srs.ts` — SRS domain: the ladder (`new → +3 → +7 → retired`; fail resets to `+3`; first-ever log enters at `+3`), `WINDOWS`/`STAGES`/`TEMPERATURES`/`DIFFICULTIES`/`SETS` (the one vocabulary shared by the charts, the `work/<n>` buckets, the review issues and the stats dashboard), ET date math, `workingDay()` (no scheduled date is ever a Sunday), `temperatureOf()` (days since a concept was last exercised → `hot`/`fresh`/`fading`/`cold`, bands derived from `WINDOWS` so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
|
||||||
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`, 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/56) LeetCode Daily Digest`.
|
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/<CAMPAIGN_DAYS>) LeetCode Daily Digest` — 79 with the plan ending Nov 3; booking a later day in `schedule.json` lengthens it, and the heatmaps and `/api/stats` `heat` with it.
|
||||||
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
|
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
|
||||||
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) is filled once — on create, or on any run that finds it missing — never refreshed (delete the key to refetch; offline, the page is written without it and the next sync retries). `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`. The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "group"`); the phase `meta.ts` is written once when missing (`Phase N: Name`, `order: N`) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
|
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`. The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "group"`); the phase `meta.ts` is written once when missing (`Phase N: Name`, `order: N`) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
|
||||||
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
|
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
|
||||||
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` / `apps/docs/lib/stats.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs `/progress` islands and `bun run stats`, so its shape is their contract: change the Worker and both readers together (additive fields a reader ignores are safe). `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
|
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` / `apps/docs/lib/stats.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs `/progress` islands and `bun run stats`, so its shape is their contract: change the Worker and both readers together (additive fields a reader ignores are safe). `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
|
||||||
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache). The docs `/progress` page is five LeetCode-style islands — `SolvedRing` (difficulty gauge + "Attempting"), `SubmissionHeatmap` and `SubmissionChart` sharing one row (the heatmap: the last six months in month blocks, the README's colour ramp as theme.css `--heat-0..4`, its attempts / active days / max streak counted from the cells it draws; the chart: this calendar year as stacked Easy/Med./Hard bars per month or week, first solves or every attempt, via two shadcn `ToggleGroup`s), `TopicBubbles` (the campaign topics circle-packed by `d3-hierarchy`, filled by solved share), `RecentSolves` (the ten most recent, linked through `redirects.ts`) — composed from shadcn/ui (`components/ui/{card,chart,skeleton,toggle,toggle-group}.tsx`, Recharts under `chart`, Radix under the toggles) on Blume's tokens: `apps/docs/theme.css` maps shadcn's names onto `--blume-*` (never `accent`, which is Blume's brand colour — vendored components that use it, like `toggle.tsx`, are retokened to `background`/`muted`) and gives shadcn's `data-slot` parts their border colour. They share `lib/stats.ts` (`useStats()`), so the page makes ONE `/api/stats` request however many islands hydrate. shadcn sources live outside `islands/` (every `.tsx` there becomes an island) and import `lib/utils` relatively — Blume's Vite has no `@/` alias; `components.json` + `tsconfig.json` exist for the shadcn CLI and `tsc`, so after `shadcn add` rewrite `@/…` imports to relative ones — and check `package.json`: the CLI has twice installed an unrelated `cn` package for `lib/utils` and skipped real deps (`class-variance-authority`). Blume's `.prose :where(a, p, ol)` rules ignore `not-prose`, so island markup overrides them with `!` utilities, as Blume's own components do.
|
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache). The docs `/progress` page is five LeetCode-style islands — `SolvedRing` (difficulty gauge + "Attempting"), `SubmissionHeatmap` and `SubmissionChart` sharing one row (the heatmap: the last six months in month blocks, the README's colour ramp as theme.css `--heat-0..4`, its attempts / active days / max streak counted from the cells it draws; the chart: this calendar year as stacked Easy/Med./Hard bars per month or week, first solves or every attempt, via two shadcn `ToggleGroup`s), `TopicBubbles` (the campaign topics circle-packed by `d3-hierarchy`, filled by solved share), `RecentSolves` (the ten most recent, linked through `redirects.ts`) — composed from shadcn/ui (`components/ui/{card,chart,skeleton,toggle,toggle-group}.tsx`, Recharts under `chart`, Radix under the toggles) on Blume's tokens: `apps/docs/theme.css` maps shadcn's names onto `--blume-*` (never `accent`, which is Blume's brand colour — vendored components that use it, like `toggle.tsx`, are retokened to `background`/`muted`) and gives shadcn's `data-slot` parts their border colour. They share `lib/stats.ts` (`useStats()`), so the page makes ONE `/api/stats` request however many islands hydrate. shadcn sources live outside `islands/` (every `.tsx` there becomes an island) and import `lib/utils` relatively — Blume's Vite has no `@/` alias; `components.json` + `tsconfig.json` exist for the shadcn CLI and `tsc`, so after `shadcn add` rewrite `@/…` imports to relative ones — and check `package.json`: the CLI has twice installed an unrelated `cn` package for `lib/utils` and skipped real deps (`class-variance-authority`). Blume's `.prose :where(a, p, ol)` rules ignore `not-prose`, so island markup overrides them with `!` utilities, as Blume's own components do.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<sub>8-week interview-prep fight camp. Each phase is a milestone; each topic an issue; each problem a sub-issue.<br>
|
<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>
|
||||||
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>
|
||||||
|
|||||||
+6
-3
@@ -17,9 +17,12 @@ Live at `https://srs-api.prdlk.workers.dev`.
|
|||||||
- **The repo owns the plan** — `data/schedule.json`, human-edited,
|
- **The repo owns the plan** — `data/schedule.json`, human-edited,
|
||||||
bundled at deploy. Git history is its audit log. Each date is a
|
bundled at deploy. Git history is its audit log. Each date is a
|
||||||
`ScheduledDay`: `topics` (one or more topic issues), `problems` (a set day,
|
`ScheduledDay`: `topics` (one or more topic issues), `problems` (a set day,
|
||||||
fixed numbers), `gate` (Saturday's blind set, verbatim), or `sweep` (redo
|
fixed numbers), `review` (a `Review <N> — <Phase>` issue with its problems
|
||||||
every miss, nothing new). Numbers a `gate` or `problems` entry names are
|
in solve order; Mon/Tue/Thu/Fri only), `gate` (Saturday's blind set,
|
||||||
`RESERVED`: the drill and gate samplers never draw them.
|
verbatim; `[]` skips that week's checkpoint), or `sweep` (redo every miss,
|
||||||
|
nothing new). Numbers a `gate`, `problems` or `review` entry names are
|
||||||
|
`RESERVED`: the drill and gate samplers never draw them. The catalog
|
||||||
|
reconcile reports any drift between a `review` entry and its issue.
|
||||||
- The GitHub Project mirror writes `Target Date` and nothing else: it is
|
- The GitHub Project mirror writes `Target Date` and nothing else: it is
|
||||||
best-effort, so failures log warnings and never block a D1 write, and topic
|
best-effort, so failures log warnings and never block a D1 write, and topic
|
||||||
rows are never written. SRS stage and first-attempt result stay in D1 only —
|
rows are never written. SRS stage and first-attempt result stay in D1 only —
|
||||||
|
|||||||
@@ -22,7 +22,25 @@
|
|||||||
"2026-09-28": { "topics": [24] },
|
"2026-09-28": { "topics": [24] },
|
||||||
"2026-09-29": { "topics": [25] },
|
"2026-09-29": { "topics": [25] },
|
||||||
"2026-09-30": { "topics": [26] },
|
"2026-09-30": { "topics": [26] },
|
||||||
"2026-10-01": { "problems": [399, 261, 909, 77, 52] },
|
"2026-10-01": { "review": { "issue": 225, "title": "Review 1 — Linear Structures", "problems": [205, 974, 209, 34, 42] } },
|
||||||
"2026-10-02": { "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": [347, 1254, 1971, 131, 494, 986] }
|
"2026-10-03": { "gate": [] },
|
||||||
|
"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-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-10-13": { "review": { "issue": 232, "title": "Review 8 — Nodal & Grid", "problems": [61, 138] } },
|
||||||
|
"2026-10-15": { "review": { "issue": 233, "title": "Review 9 — Hierarchical", "problems": [100, 112, 530, 1046, 103, 124] } },
|
||||||
|
"2026-10-16": { "review": { "issue": 234, "title": "Review 10 — Hierarchical", "problems": [572, 543, 703, 105, 295] } },
|
||||||
|
"2026-10-19": { "review": { "issue": 235, "title": "Review 11 — Hierarchical", "problems": [101, 637, 173, 621, 502] } },
|
||||||
|
"2026-10-20": { "review": { "issue": 236, "title": "Review 12 — Hierarchical", "problems": [108, 129, 373, 648, 212] } },
|
||||||
|
"2026-10-22": { "review": { "issue": 237, "title": "Review 13 — Relational", "problems": [1971, 695, 547, 433] } },
|
||||||
|
"2026-10-23": { "review": { "issue": 238, "title": "Review 14 — Relational", "problems": [130, 261, 909, 1462] } },
|
||||||
|
"2026-10-26": { "review": { "issue": 239, "title": "Review 15 — Relational", "problems": [286, 1254, 721, 399] } },
|
||||||
|
"2026-10-27": { "review": { "issue": 240, "title": "Review 16 — Relational", "problems": [1091, 802, 269] } },
|
||||||
|
"2026-10-29": { "review": { "issue": 241, "title": "Review 17 — Decision Space", "problems": [746, 228, 17, 77, 213, 63, 122] } },
|
||||||
|
"2026-10-30": { "review": { "issue": 242, "title": "Review 18 — Decision Space", "problems": [22, 90, 139, 120, 45, 253, 72] } },
|
||||||
|
"2026-11-02": { "review": { "issue": 243, "title": "Review 19 — Decision Space", "problems": [79, 131, 91, 494, 763, 986, 920] } },
|
||||||
|
"2026-11-03": { "review": { "issue": 244, "title": "Review 20 — Decision Space", "problems": [300, 647, 97, 221, 918, 452, 52] } }
|
||||||
}
|
}
|
||||||
+53
-7
@@ -9,9 +9,16 @@
|
|||||||
* issue title — the title carries only the LC number and the problem's name,
|
* issue title — the title carries only the LC number and the problem's name,
|
||||||
* so relabelling a problem is enough to move it and titles stay readable.
|
* so relabelling a problem is enough to move it and titles stay readable.
|
||||||
* The `problem` label is what marks a sub-issue as curriculum.
|
* The `problem` label is what marks a sub-issue as curriculum.
|
||||||
|
*
|
||||||
|
* A topic's problems are its sub-issues PLUS those re-parented into a
|
||||||
|
* `Review <N> — <Phase>` issue (reviews.ts recovers which topic each came
|
||||||
|
* from). Walking topic sub-issues alone would silently freeze a moved
|
||||||
|
* problem's row: upserts only, so nothing breaks — its labels just stop
|
||||||
|
* syncing.
|
||||||
*/
|
*/
|
||||||
import { DIFFICULTIES, SETS, workingDay } from "./srs.ts";
|
import { DIFFICULTIES, SCHEDULE, SETS, workingDay } from "./srs.ts";
|
||||||
import type { GitHub } from "./github.ts";
|
import type { GitHub } from "./github.ts";
|
||||||
|
import { movedFromTopics, readReviews, type Review } from "./reviews.ts";
|
||||||
|
|
||||||
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
||||||
|
|
||||||
@@ -21,6 +28,37 @@ const DEFER_FROM = "2026-09-28";
|
|||||||
export interface ReconcileReport {
|
export interface ReconcileReport {
|
||||||
topics: number;
|
topics: number;
|
||||||
problems: number;
|
problems: number;
|
||||||
|
/** Where schedule.json's review days disagree with the Review issues; empty when in step. */
|
||||||
|
reviewDrift: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* schedule.json copies each review day's issue, title and problems so the
|
||||||
|
* digest and RESERVED need no GitHub call; the issues stay the truth. Compared
|
||||||
|
* here because this is the one place that already holds both.
|
||||||
|
*/
|
||||||
|
function reviewDrift(reviews: Review[]): string[] {
|
||||||
|
const drift: string[] = [];
|
||||||
|
const booked = new Map<number, string>();
|
||||||
|
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||||
|
if (!day.review) continue;
|
||||||
|
const { issue, title, problems } = day.review;
|
||||||
|
booked.set(issue, date);
|
||||||
|
const review = reviews.find((r) => r.number === issue);
|
||||||
|
if (!review) {
|
||||||
|
drift.push(`${date}: #${issue} is not a "Review <N> — <Phase>" issue`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (review.title !== title) drift.push(`${date}: #${issue} is titled "${review.title}", schedule says "${title}"`);
|
||||||
|
const actual = review.problems.map((p) => Number(p.title.match(TITLE_RE)?.[1]));
|
||||||
|
if (actual.join(",") !== problems.join(",")) {
|
||||||
|
drift.push(`${date}: #${issue} holds LC ${actual.join(", ")}, schedule says LC ${problems.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const review of reviews) {
|
||||||
|
if (!booked.has(review.number)) drift.push(`#${review.number} "${review.title}" has no day in schedule.json`);
|
||||||
|
}
|
||||||
|
return drift;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Label names on one issue payload, narrowed with guards rather than cast. */
|
/** Label names on one issue payload, narrowed with guards rather than cast. */
|
||||||
@@ -72,24 +110,32 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reviews = await readReviews(gh);
|
||||||
|
const drift = reviewDrift(reviews);
|
||||||
|
for (const line of drift) console.warn(`catalog: review drift — ${line}`);
|
||||||
|
const moved = movedFromTopics(reviews);
|
||||||
let problems = 0;
|
let problems = 0;
|
||||||
let deferredSeen = 0;
|
let deferredSeen = 0;
|
||||||
for (const t of topics) {
|
for (const t of topics) {
|
||||||
|
const members: { number: number; title: string; labels: string[] }[] = [];
|
||||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) {
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) {
|
||||||
if (!raw || typeof raw !== "object") continue;
|
if (!raw || typeof raw !== "object") continue;
|
||||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||||
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||||
const labels = labelNames(raw);
|
members.push({ number: raw.number, title: raw.title, labels: labelNames(raw) });
|
||||||
|
}
|
||||||
|
members.push(...(moved.get(t.number) ?? []));
|
||||||
|
for (const { number, title, labels } of members) {
|
||||||
if (!labels.includes("problem")) continue; // non-curriculum sub-issue
|
if (!labels.includes("problem")) continue; // non-curriculum sub-issue
|
||||||
const m = raw.title.match(TITLE_RE);
|
const m = title.match(TITLE_RE);
|
||||||
if (!m) {
|
if (!m) {
|
||||||
console.warn(`catalog: #${raw.number} is a problem but not titled "LC <n> · <name>"`);
|
console.warn(`catalog: #${number} is a problem but not titled "LC <n> · <name>"`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const difficulty = DIFFICULTIES.find((d) => labels.includes(`diff:${d}`));
|
const difficulty = DIFFICULTIES.find((d) => labels.includes(`diff:${d}`));
|
||||||
const set = SETS.find((s) => labels.includes(`set:${s}`));
|
const set = SETS.find((s) => labels.includes(`set:${s}`));
|
||||||
if (!difficulty || !set) {
|
if (!difficulty || !set) {
|
||||||
console.warn(`catalog: #${raw.number} is missing a diff:/set: label — skipped`);
|
console.warn(`catalog: #${number} is missing a diff:/set: label — skipped`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const lc = Number(m[1]);
|
const lc = Number(m[1]);
|
||||||
@@ -106,7 +152,7 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
|||||||
ON CONFLICT(lc_number) DO UPDATE SET
|
ON CONFLICT(lc_number) DO UPDATE SET
|
||||||
issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`,
|
issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`,
|
||||||
)
|
)
|
||||||
.bind(lc, raw.number, t.number, m[2]!, difficulty, set, defer),
|
.bind(lc, number, t.number, m[2]!, difficulty, set, defer),
|
||||||
);
|
);
|
||||||
problems++;
|
problems++;
|
||||||
}
|
}
|
||||||
@@ -116,5 +162,5 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
|||||||
for (let i = 0; i < statements.length; i += 50) {
|
for (let i = 0; i < statements.length; i += 50) {
|
||||||
await db.batch(statements.slice(i, i + 50));
|
await db.batch(statements.slice(i, i + 50));
|
||||||
}
|
}
|
||||||
return { topics: topics.length, problems };
|
return { topics: topics.length, problems, reviewDrift: drift };
|
||||||
}
|
}
|
||||||
+15
-2
@@ -8,8 +8,9 @@
|
|||||||
* Retrieval rules: review, drill and sweep rows carry number + difficulty
|
* Retrieval rules: review, drill and sweep rows carry number + difficulty
|
||||||
* only — never the topic, never a solution link. `DigestRow` has no field for
|
* only — never the topic, never a solution link. `DigestRow` has no field for
|
||||||
* 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) and a set
|
* learning day's topics (core, then the optional "if time" list), a set
|
||||||
* day's problems — are the only labeled, linked ones. At most REVIEW_CAP (3)
|
* day's problems, and a review day's `Review <N>` issue — are the only
|
||||||
|
* labeled, linked ones. At most REVIEW_CAP (3)
|
||||||
* 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
|
||||||
@@ -150,6 +151,18 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
|
|||||||
data.assigned = rows.map((p) => topicRow(env, p));
|
data.assigned = rows.map((p) => topicRow(env, p));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A review day: the `Review <N> — <Phase>` issue's problems, in its solve
|
||||||
|
// order, linked like a set day's and headed by the issue itself.
|
||||||
|
if (plan.review) {
|
||||||
|
const { rows, missing } = await lookupProblems(db, plan.review.problems);
|
||||||
|
if (missing.length) console.warn(`digest ${date}: review problems not in the catalog: ${missing.join(", ")}`);
|
||||||
|
data.review = {
|
||||||
|
title: plan.review.title,
|
||||||
|
url: `https://github.com/${env.REPO}/issues/${plan.review.issue}`,
|
||||||
|
rows: rows.map((p) => topicRow(env, p)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// A sweep day works the miss log: every problem still standing on a
|
// A sweep day works the miss log: every problem still standing on a
|
||||||
// failure, minus the ones today's review list already carries.
|
// failure, minus the ones today's review list already carries.
|
||||||
if (plan.sweep) {
|
if (plan.sweep) {
|
||||||
|
|||||||
+41
-8
@@ -71,6 +71,13 @@ const LAST_SEEN: Record<string, string> = {
|
|||||||
retired: "retired",
|
retired: "retired",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A review day's rules, the same ones its GitHub issue carries — one sentence
|
||||||
|
// here so the HTML and the plain-text alternative cannot word them apart.
|
||||||
|
const REVIEW_RULES =
|
||||||
|
"90 minutes, top to bottom — easy to hard; skip what is already solved. " +
|
||||||
|
"Before each submit, say the time and space complexity out loud and trace one edge case. " +
|
||||||
|
"Whatever is unfinished stays open.";
|
||||||
|
|
||||||
// ── data model ───────────────────────────────────────────────────
|
// ── data model ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/** A review or drill line: number + difficulty + one-tap links, nothing else. */
|
/** A review or drill line: number + difficulty + one-tap links, nothing else. */
|
||||||
@@ -101,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 56 · Week 2" */
|
/** "Day 9 of 79 · Week 2" — the length is CAMPAIGN_DAYS, derived from the plan. */
|
||||||
progress: string;
|
progress: string;
|
||||||
streak: number;
|
streak: number;
|
||||||
rest: boolean;
|
rest: boolean;
|
||||||
@@ -109,6 +116,8 @@ export interface DigestData {
|
|||||||
topics: TopicSection[];
|
topics: TopicSection[];
|
||||||
/** A set day's problems, assigned by number rather than by topic. */
|
/** A set day's problems, assigned by number rather than by topic. */
|
||||||
assigned?: TopicRow[];
|
assigned?: TopicRow[];
|
||||||
|
/** A review day: the `Review <N> — <Phase>` issue and its problems in solve order. */
|
||||||
|
review?: { title: string; url: string; rows: TopicRow[] };
|
||||||
reviews: DigestRow[];
|
reviews: DigestRow[];
|
||||||
/** Reviews past the daily cap; levelled onto the coming days. */
|
/** Reviews past the daily cap; levelled onto the coming days. */
|
||||||
carried: number;
|
carried: number;
|
||||||
@@ -293,11 +302,13 @@ function DigestEmail({ data }: { data: DigestData }) {
|
|||||||
const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
|
const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
|
||||||
const lead = data.topics[0]
|
const lead = data.topics[0]
|
||||||
? `, starting with ${data.topics[0].name}`
|
? `, starting with ${data.topics[0].name}`
|
||||||
: data.assigned
|
: data.review
|
||||||
? ", starting with today's set"
|
? `, starting with ${data.review.title}`
|
||||||
: data.sweep
|
: data.assigned
|
||||||
? " — sweep day, nothing new"
|
? ", starting with today's set"
|
||||||
: "";
|
: data.sweep
|
||||||
|
? " — sweep day, nothing new"
|
||||||
|
: "";
|
||||||
const preview = data.rest ? "Rest day — nothing to do but rest." : `${load} to work through today${lead}.`;
|
const preview = data.rest ? "Rest day — nothing to do but rest." : `${load} to work through today${lead}.`;
|
||||||
|
|
||||||
const passed = data.yesterday.total - data.yesterday.failed.length;
|
const passed = data.yesterday.total - data.yesterday.failed.length;
|
||||||
@@ -350,6 +361,19 @@ function DigestEmail({ data }: { data: DigestData }) {
|
|||||||
</Section>
|
</Section>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{data.review ? (
|
||||||
|
<Section style={{ marginTop: "28px" }}>
|
||||||
|
<Text style={label}>Today's review</Text>
|
||||||
|
<Text style={{ fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
||||||
|
<Link href={data.review.url} style={{ color: INK, textDecoration: "underline" }}>
|
||||||
|
{data.review.title}
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
<Text style={hint}>{REVIEW_RULES}</Text>
|
||||||
|
<TopicTable rows={data.review.rows} />
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{data.assigned ? (
|
{data.assigned ? (
|
||||||
<Section style={{ marginTop: "28px" }}>
|
<Section style={{ marginTop: "28px" }}>
|
||||||
<Text style={label}>Today's set</Text>
|
<Text style={label}>Today's set</Text>
|
||||||
@@ -407,14 +431,14 @@ function DigestEmail({ data }: { data: DigestData }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Eight weeks at a glance — the one thing here that is pure
|
{/* The whole campaign at a glance — the one thing here that is pure
|
||||||
encouragement rather than instruction. */}
|
encouragement rather than instruction. */}
|
||||||
<Section style={{ marginTop: "28px" }}>
|
<Section style={{ marginTop: "28px" }}>
|
||||||
<Text style={label}>Every day you showed up</Text>
|
<Text style={label}>Every day you showed up</Text>
|
||||||
<Link href={data.progressUrl}>
|
<Link href={data.progressUrl}>
|
||||||
<Img
|
<Img
|
||||||
src={data.heatmapUrl}
|
src={data.heatmapUrl}
|
||||||
alt="Attempt heatmap across the eight-week campaign"
|
alt="Attempt heatmap across the whole campaign"
|
||||||
width="552"
|
width="552"
|
||||||
style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }}
|
style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }}
|
||||||
/>
|
/>
|
||||||
@@ -473,6 +497,15 @@ function plainDigest(data: DigestData): string {
|
|||||||
out.push(" If time — only once the core is done inside the clock:", ...topic.ifTime.map(linked));
|
out.push(" If time — only once the core is done inside the clock:", ...topic.ifTime.map(linked));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (data.review) {
|
||||||
|
out.push(
|
||||||
|
"",
|
||||||
|
`TODAY'S REVIEW — ${data.review.title}`,
|
||||||
|
` ${data.review.url}`,
|
||||||
|
REVIEW_RULES,
|
||||||
|
...data.review.rows.map(linked),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (data.assigned) {
|
if (data.assigned) {
|
||||||
out.push(
|
out.push(
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* The gate half is a topic-blind quiz. When the plan fixes the set
|
* The gate half is a topic-blind quiz. When the plan fixes the set
|
||||||
* (`gate` in data/schedule.json) that list IS the gate, verbatim — every
|
* (`gate` in data/schedule.json) that list IS the gate, verbatim — every
|
||||||
* number must be a catalog problem, or the issue is not created, because a
|
* number must be a catalog problem, or the issue is not created, because a
|
||||||
* `/done` on an uncatalogued number can never be logged. Otherwise the set
|
* `/done` on an uncatalogued number can never be logged; an empty list skips
|
||||||
|
* the week's checkpoint outright. Otherwise the set
|
||||||
* is sampled: one unsolved optional problem per topic scheduled this week
|
* is sampled: one unsolved optional problem per topic scheduled this week
|
||||||
* (which is why daily drills only draw from earlier weeks), never one the
|
* (which is why daily drills only draw from earlier weeks), never one the
|
||||||
* plan has reserved. The recap half is generated data, deliberately NOT a
|
* plan has reserved. The recap half is generated data, deliberately NOT a
|
||||||
@@ -77,6 +78,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
|||||||
// week without topics, across every topic that still has an unsolved pool.
|
// week without topics, across every topic that still has an unsolved pool.
|
||||||
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
||||||
const planned = SCHEDULE[date]?.gate;
|
const planned = SCHEDULE[date]?.gate;
|
||||||
|
if (planned?.length === 0) return { created: false, reason: `the plan skips the week ${week} checkpoint` };
|
||||||
if (planned) {
|
if (planned) {
|
||||||
const { rows, missing } = await lookupProblems(db, planned);
|
const { rows, missing } = await lookupProblems(db, planned);
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { type GitHub, github } from "./github.ts";
|
|||||||
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
|
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
|
||||||
import { type Mirror, projectMirror } from "./mirror.ts";
|
import { type Mirror, projectMirror } from "./mirror.ts";
|
||||||
import {
|
import {
|
||||||
|
CAMPAIGN_DAYS,
|
||||||
CAMPAIGN_START,
|
CAMPAIGN_START,
|
||||||
type LogOutcome,
|
type LogOutcome,
|
||||||
type Stage,
|
type Stage,
|
||||||
@@ -333,10 +334,10 @@ export default {
|
|||||||
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
|
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
|
||||||
}
|
}
|
||||||
if (path === "/chart/heatmap.svg") {
|
if (path === "/chart/heatmap.svg") {
|
||||||
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, 56), { headers: SVG_HEADERS });
|
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, CAMPAIGN_DAYS), { headers: SVG_HEADERS });
|
||||||
}
|
}
|
||||||
if (path === "/chart/heatmap.png") {
|
if (path === "/chart/heatmap.png") {
|
||||||
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, 56), { headers: PNG_HEADERS });
|
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, CAMPAIGN_DAYS), { headers: PNG_HEADERS });
|
||||||
}
|
}
|
||||||
if (path === "/badge/gate.svg") {
|
if (path === "/badge/gate.svg") {
|
||||||
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
|
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* `Review <N> — <Phase>` issues: the transfer phase's day blocks. Problems were
|
||||||
|
* re-parented into them from their topics (`replace_parent` — a GitHub issue
|
||||||
|
* has exactly one parent), so a topic's sub-issue list no longer names every
|
||||||
|
* problem it owns. This module is the ONE recovery of that ownership: each
|
||||||
|
* moved problem's topic is its last `parent_issue_removed` event naming a
|
||||||
|
* topic, which GitHub records and no human edits.
|
||||||
|
*
|
||||||
|
* Two consumers must agree on it: the Worker's catalog reconcile
|
||||||
|
* (catalog.ts — else a moved problem's D1 row stops syncing its labels) and
|
||||||
|
* the CLI's topic rules (apps/cli/topics.ts — else close-topics and the
|
||||||
|
* release bodies change when a problem moves). Hence it lives here, the lower
|
||||||
|
* layer, the way apps/cli already imports srs.ts.
|
||||||
|
*
|
||||||
|
* Library: side-effect-free on import; the caller passes its GitHub client —
|
||||||
|
* the Worker's or the CLI's, which share the two members used here.
|
||||||
|
*/
|
||||||
|
import type { GitHub } from "./github.ts";
|
||||||
|
|
||||||
|
/** `Review 7 — Nodal & Grid`, never the Worker's weekly `Review — Week N` gate. */
|
||||||
|
const REVIEW_TITLE = /^Review \d+ — /;
|
||||||
|
const TOPIC_TITLE = /^Topic \d+ — /;
|
||||||
|
|
||||||
|
/** One problem issue as a topic or review lists it. */
|
||||||
|
export interface Problem {
|
||||||
|
number: number;
|
||||||
|
/** `LC <num> · <Name>`. */
|
||||||
|
title: string;
|
||||||
|
/** ISO instant the issue closed; null while it is open. */
|
||||||
|
closedAt: string | null;
|
||||||
|
labels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Review {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
open: boolean;
|
||||||
|
/** Markdown body: `**Scheduled:**` / `**Phase:**` / `**Load:**` lines the release reads. */
|
||||||
|
body: string;
|
||||||
|
/** Sub-issues in the order GitHub lists them (the solve order). */
|
||||||
|
problems: (Problem & { fromTopic: number | null })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every `Review <N> — <Phase>` issue, open and closed, with its sub-issues.
|
||||||
|
* One GraphQL request per 50 `review`-labelled issues (the Worker's weekly
|
||||||
|
* gate issues share the label and are dropped by title): the sub-issues and
|
||||||
|
* the timeline filter ride along, so recovering each problem's source topic
|
||||||
|
* costs no extra request. That matters in the Worker, whose nightly cron
|
||||||
|
* spends its subrequest budget on the catalog walk and the digest.
|
||||||
|
*/
|
||||||
|
export async function readReviews(gh: Pick<GitHub, "repo" | "graphql">): Promise<Review[]> {
|
||||||
|
const [owner, name] = gh.repo.split("/");
|
||||||
|
const reviews: Review[] = [];
|
||||||
|
for (let after: string | null = null; ; ) {
|
||||||
|
// Shape fixed by the query below; GraphQL errors throw in gh.graphql().
|
||||||
|
const data = (await gh.graphql(
|
||||||
|
`query($owner: String!, $name: String!, $after: String) {
|
||||||
|
repository(owner: $owner, name: $name) {
|
||||||
|
issues(labels: ["review"], first: 50, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
nodes {
|
||||||
|
number title state body
|
||||||
|
subIssues(first: 50) {
|
||||||
|
pageInfo { hasNextPage }
|
||||||
|
nodes {
|
||||||
|
number title state closedAt
|
||||||
|
labels(first: 20) { nodes { name } }
|
||||||
|
timelineItems(itemTypes: [PARENT_ISSUE_REMOVED_EVENT], last: 10) {
|
||||||
|
nodes { ... on ParentIssueRemovedEvent { parent { number title } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ owner, name, after },
|
||||||
|
)) as {
|
||||||
|
repository: {
|
||||||
|
issues: {
|
||||||
|
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
||||||
|
nodes: {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
state: "OPEN" | "CLOSED";
|
||||||
|
body: string;
|
||||||
|
subIssues: {
|
||||||
|
pageInfo: { hasNextPage: boolean };
|
||||||
|
nodes: {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
state: "OPEN" | "CLOSED";
|
||||||
|
closedAt: string | null;
|
||||||
|
labels: { nodes: { name: string }[] };
|
||||||
|
timelineItems: { nodes: { parent: { number: number; title: string } | null }[] };
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const page = data.repository.issues;
|
||||||
|
for (const issue of page.nodes) {
|
||||||
|
if (!REVIEW_TITLE.test(issue.title)) continue;
|
||||||
|
// A review is one day's handful; more than a page means a mis-linked issue.
|
||||||
|
if (issue.subIssues.pageInfo.hasNextPage) throw new Error(`review #${issue.number} has over 50 sub-issues`);
|
||||||
|
reviews.push({
|
||||||
|
number: issue.number,
|
||||||
|
title: issue.title,
|
||||||
|
open: issue.state === "OPEN",
|
||||||
|
body: issue.body,
|
||||||
|
problems: issue.subIssues.nodes.map((p) => ({
|
||||||
|
number: p.number,
|
||||||
|
title: p.title,
|
||||||
|
closedAt: p.state === "CLOSED" ? p.closedAt : null,
|
||||||
|
labels: p.labels.nodes.map((l) => l.name),
|
||||||
|
// Oldest first, so the last topic it left is its owner; a later
|
||||||
|
// review-to-review move names a review and is skipped.
|
||||||
|
fromTopic:
|
||||||
|
p.timelineItems.nodes.findLast((e) => e.parent && TOPIC_TITLE.test(e.parent.title))?.parent?.number ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!page.pageInfo.hasNextPage) break;
|
||||||
|
after = page.pageInfo.endCursor;
|
||||||
|
}
|
||||||
|
return reviews.sort((a, b) => a.number - b.number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Topic -> the problems moved out of it into Review issues, in review order. */
|
||||||
|
export function movedFromTopics(reviews: Review[]): Map<number, Problem[]> {
|
||||||
|
const moved = new Map<number, Problem[]>();
|
||||||
|
for (const review of reviews) {
|
||||||
|
for (const { fromTopic, ...problem } of review.problems) {
|
||||||
|
if (fromTopic !== null) moved.set(fromTopic, [...(moved.get(fromTopic) ?? []), problem]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
@@ -134,6 +134,8 @@ describe("the plan", () => {
|
|||||||
expect(isRestDay(date)).toBe(false);
|
expect(isRestDay(date)).toBe(false);
|
||||||
if (day.gate) expect(weekdayOf(date)).toBe(6);
|
if (day.gate) expect(weekdayOf(date)).toBe(6);
|
||||||
if (day.topics) expect(weekdayOf(date)).toBeLessThan(6);
|
if (day.topics) expect(weekdayOf(date)).toBeLessThan(6);
|
||||||
|
// Reviews run Mon/Tue and Thu/Fri; Wednesday and the weekend stay free.
|
||||||
|
if (day.review) expect([1, 2, 4, 5]).toContain(weekdayOf(date));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,6 +152,20 @@ describe("the plan", () => {
|
|||||||
expect(topicDate(999)).toBeUndefined();
|
expect(topicDate(999)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a review is booked once, and no problem sits in two reviews", () => {
|
||||||
|
const issues = new Set<number>();
|
||||||
|
const problems = new Set<number>();
|
||||||
|
for (const day of Object.values(SCHEDULE)) {
|
||||||
|
if (!day.review) continue;
|
||||||
|
expect(issues.has(day.review.issue)).toBe(false);
|
||||||
|
issues.add(day.review.issue);
|
||||||
|
for (const lc of day.review.problems) {
|
||||||
|
expect(problems.has(lc)).toBe(false);
|
||||||
|
problems.add(lc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("every gate and set problem is reserved from the samplers", () => {
|
test("every gate and set problem is reserved from the samplers", () => {
|
||||||
for (const day of Object.values(SCHEDULE)) {
|
for (const day of Object.values(SCHEDULE)) {
|
||||||
for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) {
|
for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) {
|
||||||
|
|||||||
+33
-9
@@ -22,8 +22,8 @@ import scheduleJson from "../data/schedule.json";
|
|||||||
|
|
||||||
// ── dates (America/New_York) ─────────────────────────────────────
|
// ── dates (America/New_York) ─────────────────────────────────────
|
||||||
|
|
||||||
|
/** Day 1. The last day is not a constant: see CAMPAIGN_END below the schedule. */
|
||||||
export const CAMPAIGN_START = "2026-08-17";
|
export const CAMPAIGN_START = "2026-08-17";
|
||||||
export const CAMPAIGN_DAYS = 56;
|
|
||||||
|
|
||||||
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
|
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
|
||||||
timeZone: "America/New_York",
|
timeZone: "America/New_York",
|
||||||
@@ -130,15 +130,26 @@ export function prettyDate(date: string): string {
|
|||||||
* One calendar day of the plan. Most days teach topics (one, or two when the
|
* One calendar day of the plan. Most days teach topics (one, or two when the
|
||||||
* plan doubles up); the rest are named by what they assign instead: a fixed
|
* plan doubles up); the rest are named by what they assign instead: a fixed
|
||||||
* gate set for Saturday's review issue, a fixed problem set (a "B-set" day),
|
* gate set for Saturday's review issue, a fixed problem set (a "B-set" day),
|
||||||
* or a sweep — redo every miss, nothing new. Absent dates are unplanned:
|
* a transfer-phase review day, or a sweep — redo every miss, nothing new.
|
||||||
* the ladder still runs, the digest just has nothing new to say.
|
* Absent dates are unplanned: the ladder still runs, the digest just has
|
||||||
|
* nothing new to say.
|
||||||
*/
|
*/
|
||||||
export interface ScheduledDay {
|
export interface ScheduledDay {
|
||||||
/** Topic issues taught that day, in the order the digest presents them. */
|
/** Topic issues taught that day, in the order the digest presents them. */
|
||||||
topics?: number[];
|
topics?: number[];
|
||||||
/** Problems assigned by number instead of by topic. */
|
/** Problems assigned by number instead of by topic. */
|
||||||
problems?: number[];
|
problems?: number[];
|
||||||
/** Saturday: the blind set, fixed by the plan rather than sampled. */
|
/**
|
||||||
|
* A `Review <N> — <Phase>` day (Mon/Tue/Thu/Fri only): the issue, its title
|
||||||
|
* and its problems in solve order. The issue's sub-issues are the truth; this
|
||||||
|
* copy is what the digest and RESERVED read without calling GitHub, and the
|
||||||
|
* catalog reconcile reports any drift between the two.
|
||||||
|
*/
|
||||||
|
review?: { issue: number; title: string; problems: number[] };
|
||||||
|
/**
|
||||||
|
* Saturday: the blind set, fixed by the plan rather than sampled. An empty
|
||||||
|
* list skips that week's checkpoint — no issue is created.
|
||||||
|
*/
|
||||||
gate?: number[];
|
gate?: number[];
|
||||||
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
|
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
|
||||||
sweep?: boolean;
|
sweep?: boolean;
|
||||||
@@ -146,6 +157,15 @@ export interface ScheduledDay {
|
|||||||
|
|
||||||
export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
|
export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last day the plan books anything — the campaign ends there. Derived,
|
||||||
|
* not declared: booking a later day (the review phase ran the plan past its
|
||||||
|
* original eight weeks) moves the digest's "Day N/M", the stats' `heat` and
|
||||||
|
* the heatmap charts with it, and nothing can disagree about when it ends.
|
||||||
|
*/
|
||||||
|
export const CAMPAIGN_END = Object.keys(SCHEDULE).sort().at(-1)!;
|
||||||
|
export const CAMPAIGN_DAYS = daysBetween(CAMPAIGN_START, CAMPAIGN_END) + 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)) {
|
||||||
@@ -161,13 +181,17 @@ export function topicWeek(topic: number): number | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every problem the plan names outright — a gate's blind set or a set day's
|
* Every problem the plan names outright — a gate's blind set, a set day's
|
||||||
* list. The plan owns these: the samplers (daily drills, a sampled gate)
|
* list, or a review day's. The plan owns these: the samplers (daily drills,
|
||||||
* never draw them, so a gate problem stays unseen until its Saturday and a
|
* a sampled gate) never draw them, so a gate problem stays unseen until its
|
||||||
* set day's problems wait for their day.
|
* Saturday and a set or review day's problems wait for their day.
|
||||||
*/
|
*/
|
||||||
export const RESERVED: ReadonlySet<number> = new Set(
|
export const RESERVED: ReadonlySet<number> = new Set(
|
||||||
Object.values(SCHEDULE).flatMap((day) => [...(day.gate ?? []), ...(day.problems ?? [])]),
|
Object.values(SCHEDULE).flatMap((day) => [
|
||||||
|
...(day.gate ?? []),
|
||||||
|
...(day.problems ?? []),
|
||||||
|
...(day.review?.problems ?? []),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── deterministic sampling ───────────────────────────────────────
|
// ── deterministic sampling ───────────────────────────────────────
|
||||||
|
|||||||
+61
-11
@@ -1,26 +1,34 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Close the GitHub issue for every topic whose required (core) problems are done.
|
* Close the GitHub issue for every topic whose required (core) problems are
|
||||||
|
* done, and for every `Review <N> — <Phase>` issue whose problems all are.
|
||||||
*
|
*
|
||||||
* Each `topic`-labelled issue owns its problems as GitHub sub-issues, and each
|
* Each `topic`-labelled issue owns its problems as GitHub sub-issues, and each
|
||||||
* problem carries exactly one `set:` label — `set:core` is required, while
|
* problem carries exactly one `set:` label — `set:core` is required, while
|
||||||
* `set:optional` and `set:deferred` are extra credit. A topic is finished when
|
* `set:optional` and `set:deferred` are extra credit. A topic is finished when
|
||||||
* every one of its core sub-issues is closed; optional/deferred state is
|
* every one of its core sub-issues is closed; optional/deferred state is
|
||||||
* ignored, matching the day rule in each topic body ("Core problems first").
|
* ignored, matching the day rule in each topic body ("Core problems first").
|
||||||
* The rule lives in topics.ts, shared with release-topics.ts.
|
* Problems re-parented into Review issues still count for their topic. The
|
||||||
|
* rules live in topics.ts, shared with release-topics.ts.
|
||||||
|
*
|
||||||
|
* A Review issue is a fixed day's assignment, so it closes when every one of
|
||||||
|
* its sub-issues is closed, whatever their set. Closing it fires the Worker's
|
||||||
|
* `review`-label webhook, which scores only issues with a gate record — these
|
||||||
|
* have none, so that is a no-op.
|
||||||
*
|
*
|
||||||
* Like close-solved.ts this reconciles state instead of reacting to an event
|
* Like close-solved.ts this reconciles state instead of reacting to an event
|
||||||
* payload: re-runs are no-ops, backfilling needs no special casing, and only
|
* payload: re-runs are no-ops, backfilling needs no special casing, and only
|
||||||
* open topics are touched, so reopening a problem never reopens its topic.
|
* open topics/reviews are touched, so reopening a problem never reopens either.
|
||||||
*
|
*
|
||||||
* bun apps/cli/close-topics.ts # close finished topics
|
* bun apps/cli/close-topics.ts # close finished topics and reviews
|
||||||
* bun apps/cli/close-topics.ts --dry-run # report only, touch nothing
|
* bun apps/cli/close-topics.ts --dry-run # report only, touch nothing
|
||||||
*
|
*
|
||||||
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
||||||
*/
|
*/
|
||||||
|
import { movedFromTopics, readReviews } from "../api/src/reviews.ts";
|
||||||
import { github } from "./github.ts";
|
import { github } from "./github.ts";
|
||||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
import { coreSet, readProblems } from "./topics.ts";
|
import { completion, coreSet, readProblems } from "./topics.ts";
|
||||||
|
|
||||||
const DRY =
|
const DRY =
|
||||||
process.argv.includes("--dry-run") ||
|
process.argv.includes("--dry-run") ||
|
||||||
@@ -55,13 +63,16 @@ for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=ope
|
|||||||
}
|
}
|
||||||
topics.sort((a, b) => a.number - b.number);
|
topics.sort((a, b) => a.number - b.number);
|
||||||
|
|
||||||
|
const reviews = await readReviews(gh);
|
||||||
|
const moved = movedFromTopics(reviews);
|
||||||
|
|
||||||
// ── reconcile ────────────────────────────────────────────────────
|
// ── reconcile ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const report: string[][] = [];
|
const report: string[][] = [];
|
||||||
let closed = 0;
|
let closed = 0;
|
||||||
|
|
||||||
for (const topic of topics) {
|
for (const topic of topics) {
|
||||||
const core = coreSet(await readProblems(gh, topic.number));
|
const core = coreSet(await readProblems(gh, topic.number, moved));
|
||||||
const progress = `${core.all.length - core.pending.length}/${core.all.length}`;
|
const progress = `${core.all.length - core.pending.length}/${core.all.length}`;
|
||||||
|
|
||||||
// A topic with no core sub-issues has nothing to complete: never close it,
|
// A topic with no core sub-issues has nothing to complete: never close it,
|
||||||
@@ -95,19 +106,58 @@ for (const topic of topics) {
|
|||||||
closed++;
|
closed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── reviews ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const reviewReport: string[][] = [];
|
||||||
|
let reviewsClosed = 0;
|
||||||
|
|
||||||
|
for (const review of reviews.filter((r) => r.open)) {
|
||||||
|
const set = completion(review.problems);
|
||||||
|
const progress = `${set.all.length - set.pending.length}/${set.all.length}`;
|
||||||
|
// An empty review would read as done; leave it for a human, like a topic
|
||||||
|
// with no core set.
|
||||||
|
if (set.all.length === 0) {
|
||||||
|
reviewReport.push([`#${review.number}`, review.title, progress, "no sub-issues"]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (set.pending.length > 0) {
|
||||||
|
reviewReport.push([`#${review.number}`, review.title, progress, `open: ${set.pending.map((n) => `#${n}`).join(" ")}`]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (DRY) {
|
||||||
|
reviewReport.push([`#${review.number}`, review.title, progress, "would close"]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await gh.closeIssue(
|
||||||
|
review.number,
|
||||||
|
`Review complete — all ${set.all.length} problems closed ` +
|
||||||
|
`(${set.all.map((n) => `#${n}`).join(", ")}).\n\n` +
|
||||||
|
"Closed automatically by `close-topics`.",
|
||||||
|
);
|
||||||
|
reviewReport.push([`#${review.number}`, review.title, progress, "closed"]);
|
||||||
|
reviewsClosed++;
|
||||||
|
}
|
||||||
|
|
||||||
// ── report ───────────────────────────────────────────────────────
|
// ── report ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const rows = [["topic", "title", "core", "status"], ...report];
|
const rows = [["topic", "title", "core", "status"], ...report];
|
||||||
printTable(rows);
|
printTable(rows);
|
||||||
|
const reviewRows = [["review", "title", "closed", "status"], ...reviewReport];
|
||||||
|
console.log();
|
||||||
|
printTable(reviewRows);
|
||||||
|
|
||||||
const finished = report.filter((r) => r[3] === "closed" || r[3] === "would close");
|
const finished = new Set(["closed", "would close"]);
|
||||||
|
const topicsDone = report.filter((r) => finished.has(r[3]!)).length;
|
||||||
|
const reviewsDone = reviewReport.filter((r) => finished.has(r[3]!)).length;
|
||||||
console.log(
|
console.log(
|
||||||
`\n${topics.length} open topics · ` +
|
`\n${topics.length} open topics · ${DRY ? `would close ${topicsDone}` : `closed ${closed}`}` +
|
||||||
`${DRY ? `would close ${finished.length}` : `closed ${closed}`}`,
|
`\n${reviewReport.length} open reviews · ${DRY ? `would close ${reviewsDone}` : `closed ${reviewsClosed}`}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await writeStepSummary(
|
await writeStepSummary(
|
||||||
`### close-topics${DRY ? " (dry run)" : ""}\n\n` +
|
`### close-topics${DRY ? " (dry run)" : ""}\n\n` +
|
||||||
`${topics.length} open topic issues, ${finished.length} with a complete core set.\n\n` +
|
`${topics.length} open topic issues, ${topicsDone} with a complete core set.\n\n` +
|
||||||
(report.length ? `${markdownTable(rows)}\n` : "No open topics.\n"),
|
(report.length ? `${markdownTable(rows)}\n` : "No open topics.\n") +
|
||||||
|
`\n${reviewReport.length} open review issues, ${reviewsDone} with every problem closed.\n\n` +
|
||||||
|
(reviewReport.length ? `${markdownTable(reviewRows)}\n` : "No open reviews.\n"),
|
||||||
);
|
);
|
||||||
+102
-65
@@ -1,32 +1,40 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Publish one GitHub release per finished topic, versioned by timever
|
* Publish one GitHub release per finished topic and per finished
|
||||||
|
* `Review <N> — <Phase>` issue, versioned by timever
|
||||||
* (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — https://gist.github.com/twolfson/de1b004dd22536b8e668).
|
* (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — https://gist.github.com/twolfson/de1b004dd22536b8e668).
|
||||||
*
|
*
|
||||||
* "Finished" is topics.ts's rule — every core sub-issue closed — the same one
|
* "Finished" is topics.ts's rule — a topic when every core problem is closed,
|
||||||
* close-topics.ts closes the topic issue on, and the version is the instant
|
* a review when every one of its sub-issues is — the same one close-topics.ts
|
||||||
* the LAST core problem closed. Keying off the sub-issues rather than the
|
* closes the issue on, and the version is the instant the LAST required
|
||||||
* topic issue lets this run as the tail of Close Solved: the push that closes
|
* problem closed. Keying off the sub-issues rather than the topic/review issue
|
||||||
* a topic's final core problem releases it in the same run, without waiting
|
* lets this run as the tail of Close Solved: the push that closes the final
|
||||||
* for close-topics to chain after it. The topic issue itself closes seconds
|
* required problem releases it in the same run, without waiting for
|
||||||
* later, on the same day.
|
* close-topics to chain after it. The issue itself closes seconds later, on
|
||||||
|
* the same day.
|
||||||
*
|
*
|
||||||
* Each release is a changelog snapshot at that instant: the topic's problem
|
* Each release is a changelog snapshot at that instant: the issue's problems
|
||||||
* sub-issues grouped as closed on the finish day (ET), closed earlier, and
|
* that had closed by then (a topic's sub-issues plus those since re-parented
|
||||||
* open at release, each with its set, difficulty, and the solution files as
|
* into Review issues, or a review's sub-issues in solve order — see
|
||||||
* they stood in the tagged commit — the last main commit before the finish.
|
* topics.ts), grouped as closed on the finish day (ET) and closed earlier,
|
||||||
|
* each with its set, difficulty, and the solution files as they stood in the
|
||||||
|
* 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
|
||||||
|
* optional problem is tracked by its issue (or its Review day), not here.
|
||||||
|
* apps/docs/lib/releases.ts reads these `### Closed …` tables back for the
|
||||||
|
* 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 (`releasesDatedByTag` in apps/docs/blume.config.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 without a release gets
|
* Reconciles instead of reacting: every finished topic or review without a
|
||||||
* one, so a first run backfills the whole campaign; an existing release keeps
|
* release gets one, so a first run backfills the whole campaign; an existing
|
||||||
* its tag and commit and only has its body rewritten when render() would say
|
* release keeps its tag and commit and only has its body rewritten when
|
||||||
* something else. Re-runs with nothing new are no-ops.
|
* render() would say something else. Re-runs with nothing new are no-ops.
|
||||||
* A release is found by the marker comment in its body, never by tag name, so
|
* A release is found by the marker comment in its body, never by tag name, so
|
||||||
* reopening and re-closing a core problem can never mint a second release.
|
* reopening and re-closing a core problem can never mint a second release.
|
||||||
* A tag that exists without its release (a run that died between the two) is
|
* A tag that exists without its release (a run that died between the two) is
|
||||||
@@ -40,9 +48,10 @@
|
|||||||
* committer identity (the tagger).
|
* committer identity (the tagger).
|
||||||
*/
|
*/
|
||||||
import { etDate } from "../api/src/srs.ts";
|
import { etDate } from "../api/src/srs.ts";
|
||||||
|
import { movedFromTopics, readReviews, type Problem } from "../api/src/reviews.ts";
|
||||||
import { github } from "./github.ts";
|
import { github } from "./github.ts";
|
||||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
import { coreSet, labelValue, readProblems, type Problem } from "./topics.ts";
|
import { completion, coreSet, labelValue, readProblems } from "./topics.ts";
|
||||||
import { ROOT, parseWorkPath } from "./work.ts";
|
import { ROOT, parseWorkPath } from "./work.ts";
|
||||||
|
|
||||||
const DRY =
|
const DRY =
|
||||||
@@ -62,9 +71,10 @@ function git(...args: string[]): string {
|
|||||||
return run.stdout.toString().trim();
|
return run.stdout.toString().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── topics ───────────────────────────────────────────────────────
|
// ── finished topics and reviews ──────────────────────────────────
|
||||||
|
|
||||||
interface Topic {
|
/** A topic or Review issue, as far as its release reads it. */
|
||||||
|
interface Issue {
|
||||||
number: number;
|
number: number;
|
||||||
title: string;
|
title: string;
|
||||||
milestone: string | null;
|
milestone: string | null;
|
||||||
@@ -72,7 +82,7 @@ interface Topic {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Narrow one /issues element; the `topic` label filter cannot exclude PRs. */
|
/** Narrow one /issues element; the `topic` label filter cannot exclude PRs. */
|
||||||
function readTopic(value: unknown): Topic | undefined {
|
function readTopic(value: unknown): Issue | undefined {
|
||||||
if (!value || typeof value !== "object") return;
|
if (!value || typeof value !== "object") return;
|
||||||
if ("pull_request" in value) return;
|
if ("pull_request" in value) return;
|
||||||
if (!("number" in value) || typeof value.number !== "number") return;
|
if (!("number" in value) || typeof value.number !== "number") return;
|
||||||
@@ -90,28 +100,46 @@ function readTopic(value: unknown): Topic | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Finished {
|
interface Finished {
|
||||||
topic: Topic;
|
kind: "topic" | "review";
|
||||||
|
issue: Issue;
|
||||||
|
/** Topic: every problem it owns, by number. Review: its sub-issues, in solve order. */
|
||||||
problems: Problem[];
|
problems: Problem[];
|
||||||
core: number[];
|
/** The set whose last close finished it: a topic's core, a review's every problem. */
|
||||||
|
required: number[];
|
||||||
finishedAt: string;
|
finishedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Closed and open topics alike: the rule reads the sub-issues, so a topic
|
// Closed and open issues alike: the rules read the sub-issues, so a topic or
|
||||||
// finished moments ago (close-topics not yet run) releases now.
|
// review finished moments ago (close-topics not yet run) releases now.
|
||||||
const finished: Finished[] = [];
|
const finished: Finished[] = [];
|
||||||
|
const reviews = await readReviews(gh);
|
||||||
|
const moved = movedFromTopics(reviews);
|
||||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
||||||
const topic = readTopic(raw);
|
const issue = readTopic(raw);
|
||||||
if (!topic) continue;
|
if (!issue) continue;
|
||||||
const problems = await readProblems(gh, topic.number);
|
const problems = await readProblems(gh, issue.number, moved);
|
||||||
const core = coreSet(problems);
|
const core = coreSet(problems);
|
||||||
if (core.finishedAt) finished.push({ topic, problems, core: core.all, finishedAt: core.finishedAt });
|
if (core.finishedAt) finished.push({ kind: "topic", issue, problems, required: core.all, finishedAt: core.finishedAt });
|
||||||
|
}
|
||||||
|
for (const review of reviews) {
|
||||||
|
const set = completion(review.problems);
|
||||||
|
if (!set.finishedAt) continue;
|
||||||
|
finished.push({
|
||||||
|
kind: "review",
|
||||||
|
// The review's phase is its body's `**Phase:**` line; its milestone is the
|
||||||
|
// shared "Review — Transfer & Mocks" and says nothing per issue.
|
||||||
|
issue: { number: review.number, title: review.title, milestone: null, body: review.body },
|
||||||
|
problems: review.problems,
|
||||||
|
required: set.all,
|
||||||
|
finishedAt: set.finishedAt,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Oldest first, so a backfill creates releases in campaign order.
|
// Oldest first, so a backfill creates releases in campaign order.
|
||||||
finished.sort((a, b) => a.finishedAt.localeCompare(b.finishedAt) || a.topic.number - b.topic.number);
|
finished.sort((a, b) => a.finishedAt.localeCompare(b.finishedAt) || a.issue.number - b.issue.number);
|
||||||
|
|
||||||
// ── existing releases ────────────────────────────────────────────
|
// ── existing releases ────────────────────────────────────────────
|
||||||
|
|
||||||
/** Topic -> its release. Name and body are kept in sync with the render on every run. */
|
/** Topic or review issue -> its release. Name and body are kept in sync with the render on every run. */
|
||||||
const released = new Map<number, { id: number; tag: string; name: string; body: string }>();
|
const released = new Map<number, { id: number; tag: string; name: string; body: string }>();
|
||||||
for await (const raw of gh.list(`/repos/${gh.repo}/releases`)) {
|
for await (const raw of gh.list(`/repos/${gh.repo}/releases`)) {
|
||||||
if (!raw || typeof raw !== "object") continue;
|
if (!raw || typeof raw !== "object") continue;
|
||||||
@@ -127,10 +155,11 @@ for await (const raw of gh.list(`/repos/${gh.repo}/releases`)) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Timever of an instant. The API reports whole seconds, so SUBTIME is the
|
* Timever of an instant. The API reports whole seconds, so SUBTIME is the
|
||||||
* tiebreak: two topics finishing in the same second (one push closing both
|
* tiebreak: two topics or reviews finishing in the same second (one push
|
||||||
* last core problems) get consecutive nanoseconds, in (instant, topic) order.
|
* closing both last problems) get consecutive nanoseconds, in (instant,
|
||||||
|
* issue) order.
|
||||||
*/
|
*/
|
||||||
const tags = new Map<number, string>(); // topic -> tag
|
const tags = new Map<number, string>(); // issue -> tag
|
||||||
{
|
{
|
||||||
let prev = "";
|
let prev = "";
|
||||||
let ns = 0;
|
let ns = 0;
|
||||||
@@ -139,7 +168,7 @@ const tags = new Map<number, string>(); // topic -> tag
|
|||||||
const base = `${iso.slice(0, 10).replaceAll("-", "")}.${iso.slice(11, 19).replaceAll(":", "")}`;
|
const base = `${iso.slice(0, 10).replaceAll("-", "")}.${iso.slice(11, 19).replaceAll(":", "")}`;
|
||||||
ns = base === prev ? ns + 1 : Number(iso.slice(20, 23)) * 1_000_000;
|
ns = base === prev ? ns + 1 : Number(iso.slice(20, 23)) * 1_000_000;
|
||||||
prev = base;
|
prev = base;
|
||||||
tags.set(f.topic.number, `${base}.${String(ns).padStart(9, "0")}`);
|
tags.set(f.issue.number, `${base}.${String(ns).padStart(9, "0")}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,13 +203,13 @@ function bodySection(body: string, heading: string): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render(f: Finished, tag: string, sha: string, files: Map<number, string[]>): string {
|
function render(f: Finished, tag: string, sha: string, files: Map<number, string[]>): string {
|
||||||
const { topic, problems } = f;
|
const { issue, problems } = f;
|
||||||
const finishDay = etDate(new Date(f.finishedAt));
|
const finishDay = etDate(new Date(f.finishedAt));
|
||||||
// Snapshot at the finish instant: a problem closed later was open then.
|
// Snapshot at the finish instant: a problem closed later was open then, and
|
||||||
|
// open problems are not part of the release.
|
||||||
const closedBy = (p: Problem) => p.closedAt !== null && p.closedAt <= f.finishedAt;
|
const closedBy = (p: Problem) => p.closedAt !== null && p.closedAt <= f.finishedAt;
|
||||||
const onDay = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) === finishDay);
|
const onDay = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) === finishDay);
|
||||||
const earlier = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) !== finishDay);
|
const earlier = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) !== finishDay);
|
||||||
const open = problems.filter((p) => !closedBy(p));
|
|
||||||
|
|
||||||
const row = (p: Problem): string[] => {
|
const row = (p: Problem): string[] => {
|
||||||
const lc = Number(p.title.match(/^LC (\d+)/)?.[1]);
|
const lc = Number(p.title.match(/^LC (\d+)/)?.[1]);
|
||||||
@@ -206,27 +235,31 @@ function render(f: Finished, tag: string, sha: string, files: Map<number, string
|
|||||||
// with one self-contained sentence and keep the fields on one `·` line.
|
// with one self-contained sentence and keep the fields on one `·` line.
|
||||||
const when = ET_WHEN.format(new Date(f.finishedAt));
|
const when = ET_WHEN.format(new Date(f.finishedAt));
|
||||||
const summary =
|
const summary =
|
||||||
`Finished ${when} ET: all ${f.core.length} core problems closed, ` +
|
`Finished ${when} ET: all ${f.required.length} ${f.kind === "topic" ? "core " : ""}problems closed, ` +
|
||||||
`${onDay.length} ${onDay.length === 1 ? "problem" : "problems"} closed that day.`;
|
`${onDay.length} ${onDay.length === 1 ? "problem" : "problems"} closed that day.`;
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
const scheduled = bodyField(topic.body, "Scheduled");
|
const scheduled = bodyField(issue.body, "Scheduled");
|
||||||
if (scheduled) fields.push(`**Scheduled:** ${scheduled}`);
|
if (scheduled) fields.push(`**Scheduled:** ${scheduled}`);
|
||||||
if (topic.milestone) fields.push(`**Phase:** ${topic.milestone}`);
|
const phase = f.kind === "topic" ? issue.milestone : bodyField(issue.body, "Phase");
|
||||||
fields.push(`**Topic:** #${topic.number}`);
|
if (phase) fields.push(`**Phase:** ${phase}`);
|
||||||
const buildsOn = bodyField(topic.body, "Builds on");
|
fields.push(`**${f.kind === "topic" ? "Topic" : "Review"}:** #${issue.number}`);
|
||||||
|
const buildsOn = bodyField(issue.body, "Builds on");
|
||||||
if (buildsOn) fields.push(`**Builds on:** ${buildsOn}`);
|
if (buildsOn) fields.push(`**Builds on:** ${buildsOn}`);
|
||||||
|
const load = bodyField(issue.body, "Load");
|
||||||
|
if (load) fields.push(`**Load:** ${load}`);
|
||||||
// The marker is how a later run finds this release; see the file header.
|
// The marker is how a later run finds this release; see the file header.
|
||||||
const sections = [`<!-- release-topics: #${topic.number} -->\n${summary}`, fields.join(" · ")];
|
// One marker for both kinds: issue numbers never collide.
|
||||||
sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the topic finished\n\n${table(onDay)}`);
|
const sections = [`<!-- release-topics: #${issue.number} -->\n${summary}`, fields.join(" · ")];
|
||||||
|
sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the ${f.kind} finished\n\n${table(onDay)}`);
|
||||||
if (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`);
|
if (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`);
|
||||||
if (open.length) sections.push(`### Open at release\n\n${table(open)}`);
|
|
||||||
const notes = NOTES.flatMap((h) => {
|
const notes = NOTES.flatMap((h) => {
|
||||||
const text = bodySection(topic.body, h);
|
const text = bodySection(issue.body, h);
|
||||||
return text ? [`**${h}.** ${text}`] : [];
|
return text ? [`**${h}.** ${text}`] : [];
|
||||||
});
|
});
|
||||||
if (notes.length) sections.push(`### Notes\n\n${notes.join("\n\n")}`);
|
if (notes.length) sections.push(`### Notes\n\n${notes.join("\n\n")}`);
|
||||||
sections.push(
|
sections.push(
|
||||||
`---\n\n\`${tag}\` is [timever](${TIMEVER}) for the UTC instant the core set completed; ` +
|
`---\n\n\`${tag}\` is [timever](${TIMEVER}) for the UTC instant the ` +
|
||||||
|
`${f.kind === "topic" ? "core set completed" : "last problem closed"}; ` +
|
||||||
`it tags ${sha.slice(0, 7)}, the last main commit before it.`,
|
`it tags ${sha.slice(0, 7)}, the last main commit before it.`,
|
||||||
);
|
);
|
||||||
return `${sections.join("\n\n")}\n`;
|
return `${sections.join("\n\n")}\n`;
|
||||||
@@ -234,20 +267,20 @@ function render(f: Finished, tag: string, sha: string, files: Map<number, string
|
|||||||
|
|
||||||
// ── reconcile ────────────────────────────────────────────────────
|
// ── reconcile ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const latest = finished.at(-1)?.topic.number;
|
const latest = finished.at(-1)?.issue.number;
|
||||||
const report: string[][] = [];
|
const report: string[][] = [];
|
||||||
const previews: string[] = [];
|
const previews: string[] = [];
|
||||||
let created = 0;
|
let created = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
|
|
||||||
for (const f of finished) {
|
for (const f of finished) {
|
||||||
const { topic } = f;
|
const { issue } = f;
|
||||||
const finishedEt = etDate(new Date(f.finishedAt));
|
const finishedEt = etDate(new Date(f.finishedAt));
|
||||||
const existing = released.get(topic.number);
|
const existing = released.get(issue.number);
|
||||||
// An existing release keeps its tag and commit; only its name and body are
|
// An existing release keeps its tag and commit; only its name and body are
|
||||||
// reconciled. Its tag may postdate this clone (released by another run), so
|
// reconciled. Its tag may postdate this clone (released by another run), so
|
||||||
// fetch it when missing — read-only, so a dry run does it too.
|
// fetch it when missing — read-only, so a dry run does it too.
|
||||||
const tag = existing?.tag ?? tags.get(topic.number)!;
|
const tag = existing?.tag ?? tags.get(issue.number)!;
|
||||||
if (existing && Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT }).exitCode !== 0) {
|
if (existing && Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT }).exitCode !== 0) {
|
||||||
git("fetch", "-q", "origin", `refs/tags/${tag}:refs/tags/${tag}`);
|
git("fetch", "-q", "origin", `refs/tags/${tag}:refs/tags/${tag}`);
|
||||||
}
|
}
|
||||||
@@ -255,7 +288,7 @@ for (const f of finished) {
|
|||||||
? git("rev-parse", `${tag}^{commit}`)
|
? git("rev-parse", `${tag}^{commit}`)
|
||||||
: git("rev-list", "-1", "--first-parent", `--before=${f.finishedAt}`, "HEAD");
|
: git("rev-list", "-1", "--first-parent", `--before=${f.finishedAt}`, "HEAD");
|
||||||
if (!sha) {
|
if (!sha) {
|
||||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "ERROR: no commit before finish"]);
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "ERROR: no commit before finish"]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// LC number -> its first-solve files in the tagged tree. Keyed by file name,
|
// LC number -> its first-solve files in the tagged tree. Keyed by file name,
|
||||||
@@ -269,20 +302,20 @@ for (const f of finished) {
|
|||||||
files.set(Number(lc), [...(files.get(Number(lc)) ?? []), path]);
|
files.set(Number(lc), [...(files.get(Number(lc)) ?? []), path]);
|
||||||
}
|
}
|
||||||
const body = render(f, tag, sha, files);
|
const body = render(f, tag, sha, files);
|
||||||
const name = topic.title;
|
const name = issue.title;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.body === body && existing.name === name) {
|
if (existing.body === body && existing.name === name) {
|
||||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "released"]);
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "released"]);
|
||||||
} else if (DRY) {
|
} else if (DRY) {
|
||||||
previews.push(`## ${name} (${tag})\n\n${body}`);
|
previews.push(`## ${name} (${tag})\n\n${body}`);
|
||||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "would update"]);
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "would update"]);
|
||||||
} else {
|
} else {
|
||||||
await gh.api(`/repos/${gh.repo}/releases/${existing.id}`, {
|
await gh.api(`/repos/${gh.repo}/releases/${existing.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ name, body }),
|
body: JSON.stringify({ name, body }),
|
||||||
});
|
});
|
||||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "updated"]);
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "updated"]);
|
||||||
updated++;
|
updated++;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -290,7 +323,7 @@ for (const f of finished) {
|
|||||||
|
|
||||||
if (DRY) {
|
if (DRY) {
|
||||||
previews.push(`## ${name} (${tag})\n\n${body}`);
|
previews.push(`## ${name} (${tag})\n\n${body}`);
|
||||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "would release"]);
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "would release"]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +332,11 @@ for (const f of finished) {
|
|||||||
const local = Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT });
|
const local = Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT });
|
||||||
if (local.exitCode !== 0) {
|
if (local.exitCode !== 0) {
|
||||||
const tagged = Bun.spawnSync(
|
const tagged = Bun.spawnSync(
|
||||||
["git", "tag", "-a", tag, sha, "-m", `${topic.title} (#${topic.number})\n\nCore set complete: ${f.core.map((n) => `#${n}`).join(", ")}`],
|
[
|
||||||
|
"git", "tag", "-a", tag, sha, "-m",
|
||||||
|
`${issue.title} (#${issue.number})\n\n` +
|
||||||
|
`${f.kind === "topic" ? "Core set complete" : "All problems closed"}: ${f.required.map((n) => `#${n}`).join(", ")}`,
|
||||||
|
],
|
||||||
{ cwd: ROOT, env: { ...process.env, GIT_COMMITTER_DATE: f.finishedAt } },
|
{ cwd: ROOT, env: { ...process.env, GIT_COMMITTER_DATE: f.finishedAt } },
|
||||||
);
|
);
|
||||||
if (tagged.exitCode !== 0) throw new Error(`git tag ${tag}: ${tagged.stderr.toString().trim()}`);
|
if (tagged.exitCode !== 0) throw new Error(`git tag ${tag}: ${tagged.stderr.toString().trim()}`);
|
||||||
@@ -312,21 +349,21 @@ for (const f of finished) {
|
|||||||
tag_name: tag,
|
tag_name: tag,
|
||||||
name,
|
name,
|
||||||
body,
|
body,
|
||||||
make_latest: topic.number === latest ? "true" : "false",
|
make_latest: issue.number === latest ? "true" : "false",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "released now"]);
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "released now"]);
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── report ───────────────────────────────────────────────────────
|
// ── report ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (previews.length) console.log(`${previews.join("\n")}\n`);
|
if (previews.length) console.log(`${previews.join("\n")}\n`);
|
||||||
const rows = [["topic", "title", "finished (ET)", "tag", "status"], ...report];
|
const rows = [["issue", "title", "finished (ET)", "tag", "status"], ...report];
|
||||||
printTable(rows);
|
printTable(rows);
|
||||||
const would = (status: string) => report.filter((r) => r[4] === status).length;
|
const would = (status: string) => report.filter((r) => r[4] === status).length;
|
||||||
console.log(
|
console.log(
|
||||||
`\n${finished.length} finished topics · ` +
|
`\n${finished.length} finished topics and reviews · ` +
|
||||||
(DRY
|
(DRY
|
||||||
? `would release ${would("would release")}, update ${would("would update")}`
|
? `would release ${would("would release")}, update ${would("would update")}`
|
||||||
: `released ${created}, updated ${updated}`),
|
: `released ${created}, updated ${updated}`),
|
||||||
@@ -334,7 +371,7 @@ console.log(
|
|||||||
|
|
||||||
await writeStepSummary(
|
await writeStepSummary(
|
||||||
`### release-topics${DRY ? " (dry run)" : ""}\n\n` +
|
`### release-topics${DRY ? " (dry run)" : ""}\n\n` +
|
||||||
`${finished.length} finished topics, ${released.size} already released.\n\n` +
|
`${finished.length} finished topics and reviews, ${released.size} already released.\n\n` +
|
||||||
(report.length ? `${markdownTable(rows)}\n` : "No finished topics.\n") +
|
(report.length ? `${markdownTable(rows)}\n` : "Nothing finished.\n") +
|
||||||
(previews.length ? `\n${previews.join("\n")}` : ""),
|
(previews.length ? `\n${previews.join("\n")}` : ""),
|
||||||
);
|
);
|
||||||
+93
-24
@@ -18,18 +18,27 @@
|
|||||||
* renders no badges: the docs MDX `wrapper`
|
* renders no badges: the docs MDX `wrapper`
|
||||||
* (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the
|
* (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the
|
||||||
* `video:` key `videos.ts` writes into the badge row 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
|
||||||
|
* problem issue as frontmatter `issue:` (the URL of the `problem`-labelled
|
||||||
|
* issue titled `LC <num> · …`), rendered by the same wrapper as a GitHub
|
||||||
|
* badge after the others; an unplanned warm-up has no catalog issue and no
|
||||||
|
* key. Like `topics:` it is filled once — on create, or on any run that
|
||||||
|
* finds it missing — and the issue list is fetched only on such a run
|
||||||
|
* (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
|
||||||
|
* refetch it.
|
||||||
* - Every tag a page carries that no campaign topic teaches (not a key of
|
* - Every tag a page carries that no campaign topic teaches (not a key of
|
||||||
* `apps/docs/topic-landings.ts`) gets a placeholder guide page,
|
* `apps/docs/topic-landings.ts`) gets a placeholder guide page in the
|
||||||
* `guides/<slug>.mdx`, with `list:` pointing at LeetCode's problem list for
|
* Appendix, `appendix/<slug>.mdx`, with `list:` pointing at LeetCode's
|
||||||
* the tag. Created once (a missing or empty file), then hand-owned except
|
* problem list for the tag. Created once (a missing or empty file), then
|
||||||
* for `sidebar.badge`, which sync keeps at the guide's backlink count (the
|
* hand-owned except for `sidebar.badge`, which sync keeps at the guide's
|
||||||
* problem pages carrying the tag; dropped at zero); the tag's badge on a
|
* backlink count (the problem pages carrying the tag; dropped at zero); the
|
||||||
* problem page links to it. A guide may be filed into a category `(group)`
|
* tag's badge on a problem page links to it. A guide may be filed into a
|
||||||
* folder (e.g. `guides/(2-algorithms)/`) — no route segment, so
|
* category `(group)` folder (e.g. `appendix/(2-algorithms)/`) — no route
|
||||||
* `/guides/<slug>` holds — and is found there by slug; a new placeholder
|
* segment, so `/appendix/<slug>` holds — and is found there by slug; a new
|
||||||
* lands at the `guides/` root for a human to file (and give an `icon:`,
|
* placeholder lands at the `appendix/` root for a human to file (and give
|
||||||
* which every badge linking to the guide shows; sync warns while one is
|
* an `icon:`, which every badge linking to the guide shows; sync warns
|
||||||
* missing or shared).
|
* while one is missing or shared).
|
||||||
*
|
*
|
||||||
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`,
|
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`,
|
||||||
* ordered by topic number via a script-owned `meta.ts`). A problem outside the
|
* ordered by topic number via a script-owned `meta.ts`). A problem outside the
|
||||||
@@ -60,6 +69,7 @@ import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises";
|
|||||||
import { basename, dirname, join } from "node:path";
|
import { basename, dirname, join } from "node:path";
|
||||||
|
|
||||||
import { TOPIC_LANDINGS } from "../docs/topic-landings.ts";
|
import { TOPIC_LANDINGS } from "../docs/topic-landings.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");
|
||||||
@@ -397,10 +407,55 @@ function frontmatterTopics(page: string): TopicTag[] {
|
|||||||
return data.topics.filter((t): t is TopicTag => typeof t?.name === "string" && typeof t?.slug === "string");
|
return data.topics.filter((t): t is TopicTag => typeof t?.name === "string" && typeof t?.slug === "string");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GitHub problem issues ────────────────────────────────────────
|
||||||
|
|
||||||
|
let issueUrls: Promise<Map<number, string> | undefined> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LC number → the URL of its `problem` issue (`LC <num> · <Name>`, open or
|
||||||
|
* closed), listed once per run and only when a page first needs it — or
|
||||||
|
* undefined, warned, when GitHub cannot answer (offline commit, no `gh`
|
||||||
|
* auth): pages go without `issue:` and the next sync fills it in.
|
||||||
|
*/
|
||||||
|
function problemIssues(): Promise<Map<number, string> | undefined> {
|
||||||
|
issueUrls ??= (async () => {
|
||||||
|
try {
|
||||||
|
const gh = await github();
|
||||||
|
const urls = new Map<number, string>();
|
||||||
|
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 (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||||
|
if (!("html_url" in raw) || typeof raw.html_url !== "string") continue;
|
||||||
|
const lc = raw.title.match(/^LC (\d+) · /)?.[1];
|
||||||
|
if (lc && !urls.has(Number(lc))) urls.set(Number(lc), raw.html_url);
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`no problem issues: ${err instanceof Error ? err.message : err}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return issueUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The problem's issue URL, or undefined (warned when GitHub answered but has
|
||||||
|
* no such issue). Only a problem the README plan lists has a catalog issue;
|
||||||
|
* an unplanned warm-up never will, so it is not looked up — otherwise its
|
||||||
|
* missing key would re-list the issues and re-warn on every sync.
|
||||||
|
*/
|
||||||
|
async function issueUrl(sol: Solution): Promise<string | undefined> {
|
||||||
|
if (!topics.has(sol.num)) return undefined;
|
||||||
|
const urls = await problemIssues();
|
||||||
|
const url = urls?.get(sol.num);
|
||||||
|
if (urls && !url) console.warn(`no problem issue titled "LC ${sol.num} · …" for ${sol.num}.${sol.slug}`);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
// ── guides ───────────────────────────────────────────────────────
|
// ── guides ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Placeholder for a tag no campaign topic teaches: the `/guides/<slug>` page a
|
* Placeholder for a tag no campaign topic teaches: the `/appendix/<slug>` page a
|
||||||
* problem's topic badge links to. `list:` is the tag's LeetCode problem list,
|
* problem's topic badge links to. `list:` is the tag's LeetCode problem list,
|
||||||
* rendered as a badge by the docs MDX `wrapper`. Written once — the page
|
* rendered as a badge by the docs MDX `wrapper`. Written once — the page
|
||||||
* is hand-owned from then on.
|
* is hand-owned from then on.
|
||||||
@@ -419,8 +474,12 @@ function guidePage(tag: TopicTag): string {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Folder meta for `guides/`: its own header tab (`navigation.tabs` in blume.config.ts), so only the label. */
|
/**
|
||||||
const GUIDES_META = `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: "Guides" });\n`;
|
* Folder meta for `appendix/`: a drill-in (`display: "page"`) sidebar row
|
||||||
|
* sorted ahead of the phase groups, so it sits right under the root pages
|
||||||
|
* (Introduction, Keywords, Progress).
|
||||||
|
*/
|
||||||
|
const APPENDIX_META = `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: "Appendix", icon: "library", order: 0, display: "page" });\n`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A guide's frontmatter with `sidebar.badge` set to its backlink count — the
|
* A guide's frontmatter with `sidebar.badge` set to its backlink count — the
|
||||||
@@ -547,7 +606,7 @@ function warningText(h: Header): string {
|
|||||||
/** 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): string {
|
function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string | 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*$/, "") ?? "";
|
||||||
@@ -559,6 +618,7 @@ function renderPage(sol: Solution, tags: TopicTag[] | undefined): string {
|
|||||||
/[:#]/.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)}`] : []),
|
||||||
"sidebar:",
|
"sidebar:",
|
||||||
` label: '${h.title.replaceAll("'", "''")}'`,
|
` label: '${h.title.replaceAll("'", "''")}'`,
|
||||||
` badge: '${h.difficulty}'`,
|
` badge: '${h.difficulty}'`,
|
||||||
@@ -654,6 +714,15 @@ async function ensureTopics(page: string, sol: Solution): Promise<string> {
|
|||||||
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. */
|
||||||
|
async function ensureIssue(page: string, sol: Solution): Promise<string> {
|
||||||
|
const fm = page.match(/^---\n[\s\S]*?\n---/);
|
||||||
|
if (!fm || /^issue:/m.test(fm[0])) return page;
|
||||||
|
const url = await issueUrl(sol);
|
||||||
|
if (!url) return page;
|
||||||
|
return fm[0].replace(/^sidebar:$/m, `issue: ${JSON.stringify(url)}\nsidebar:`) + page.slice(fm[0].length);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offset just past the script-owned `## Solution` section: the next top-level
|
* Offset just past the script-owned `## Solution` section: the next top-level
|
||||||
* heading that is not inside a code fence, or end of file. Sections a human
|
* heading that is not inside a code fence, or end of file. Sections a human
|
||||||
@@ -713,7 +782,7 @@ for (const sol of ordered) {
|
|||||||
}
|
}
|
||||||
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 = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
|
||||||
const next = await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol);
|
const next = await ensureIssue(await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), 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));
|
||||||
@@ -727,7 +796,7 @@ for (const sol of ordered) {
|
|||||||
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
|
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
|
||||||
written = next;
|
written = next;
|
||||||
} else {
|
} else {
|
||||||
const page = renderPage(sol, await fetchTopicTags(sol));
|
const page = renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol));
|
||||||
await Bun.write(join(DOCS, name), page);
|
await Bun.write(join(DOCS, name), page);
|
||||||
report.push([workRef, name, "created"]);
|
report.push([workRef, name, "created"]);
|
||||||
written = page;
|
written = page;
|
||||||
@@ -758,16 +827,16 @@ for (const sol of ordered) {
|
|||||||
|
|
||||||
// One guide page per tag that no campaign topic teaches, created when missing
|
// One guide page per tag that no campaign topic teaches, created when missing
|
||||||
// (an empty file counts as missing) and never rewritten. A guide is found by
|
// (an empty file counts as missing) and never rewritten. A guide is found by
|
||||||
// slug anywhere under guides/ — a human files it into a category `(group)`,
|
// slug anywhere under appendix/ — a human files it into a category `(group)`,
|
||||||
// which leaves its route alone — and a new one lands at the guides/ root. A
|
// which leaves its route alone — and a new one lands at the appendix/ root. A
|
||||||
// guide whose tag no page carries any more is left alone: it is hand-owned.
|
// guide whose tag no page carries any more is left alone: it is hand-owned.
|
||||||
const guideTags = [...tags.values()]
|
const guideTags = [...tags.values()]
|
||||||
.filter((tag) => !TOPIC_LANDINGS[tag.slug])
|
.filter((tag) => !TOPIC_LANDINGS[tag.slug])
|
||||||
.sort((a, b) => a.slug.localeCompare(b.slug));
|
.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||||
const guideFiles = new Map<string, string>(); // slug -> path relative to DOCS
|
const guideFiles = new Map<string, string>(); // slug -> path relative to DOCS
|
||||||
for await (const rel of new Bun.Glob("guides/**/*.mdx").scan(DOCS)) guideFiles.set(basename(rel, ".mdx"), rel);
|
for await (const rel of new Bun.Glob("appendix/**/*.mdx").scan(DOCS)) guideFiles.set(basename(rel, ".mdx"), rel);
|
||||||
for (const tag of guideTags) {
|
for (const tag of guideTags) {
|
||||||
const rel = guideFiles.get(tag.slug) ?? `guides/${tag.slug}.mdx`;
|
const rel = guideFiles.get(tag.slug) ?? `appendix/${tag.slug}.mdx`;
|
||||||
const file = Bun.file(join(DOCS, rel));
|
const file = Bun.file(join(DOCS, rel));
|
||||||
if ((await file.exists()) && file.size > 0) continue;
|
if ((await file.exists()) && file.size > 0) continue;
|
||||||
await Bun.write(file, guidePage(tag));
|
await Bun.write(file, guidePage(tag));
|
||||||
@@ -785,7 +854,7 @@ for (const tag of guideTags) {
|
|||||||
// and must not be another guide's.
|
// and must not be another guide's.
|
||||||
const topicIcons = new Set(Object.values(TOPIC_ICONS));
|
const topicIcons = new Set(Object.values(TOPIC_ICONS));
|
||||||
const guideIcons = new Map<string, string>(); // icon -> the guide that took it first
|
const guideIcons = new Map<string, string>(); // icon -> the guide that took it first
|
||||||
for await (const rel of new Bun.Glob("guides/**/*.mdx").scan(DOCS)) {
|
for await (const rel of new Bun.Glob("appendix/**/*.mdx").scan(DOCS)) {
|
||||||
const slug = basename(rel, ".mdx");
|
const slug = basename(rel, ".mdx");
|
||||||
const count = TOPIC_LANDINGS[slug] ? 0 : (tagged.get(slug) ?? 0);
|
const count = TOPIC_LANDINGS[slug] ? 0 : (tagged.get(slug) ?? 0);
|
||||||
const page = await Bun.file(join(DOCS, rel)).text();
|
const page = await Bun.file(join(DOCS, rel)).text();
|
||||||
@@ -809,11 +878,11 @@ for (const [dir, phase] of phases) {
|
|||||||
report.push([`phase ${phase.order}`, `${dir}/meta.ts`, "created"]);
|
report.push([`phase ${phase.order}`, `${dir}/meta.ts`, "created"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Script-owned folder metas — one per live topic folder, plus guides/ —
|
// Script-owned folder metas — one per live topic folder, plus appendix/ —
|
||||||
// rewritten only when they differ.
|
// rewritten only when they differ.
|
||||||
const metas: [label: string, dir: string, meta: string][] = [
|
const metas: [label: string, dir: string, meta: string][] = [
|
||||||
...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]),
|
...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]),
|
||||||
...(guideTags.length ? [["guides", "guides", GUIDES_META] as [string, string, string]] : []),
|
...(guideTags.length ? [["appendix", "appendix", APPENDIX_META] as [string, string, string]] : []),
|
||||||
];
|
];
|
||||||
for (const [label, dir, meta] of metas) {
|
for (const [label, dir, meta] of metas) {
|
||||||
const path = join(DOCS, dir, "meta.ts");
|
const path = join(DOCS, dir, "meta.ts");
|
||||||
|
|||||||
+37
-23
@@ -2,42 +2,51 @@
|
|||||||
* Topic issues and their problem sub-issues: the ONE reading of "is this topic
|
* Topic issues and their problem sub-issues: the ONE reading of "is this topic
|
||||||
* finished", shared by close-topics.ts (closes the topic issue) and
|
* finished", shared by close-topics.ts (closes the topic issue) and
|
||||||
* release-topics.ts (publishes the topic's timever release) so the two can
|
* release-topics.ts (publishes the topic's timever release) so the two can
|
||||||
* never disagree about when a topic was done.
|
* never disagree about when a topic was done. Also the one reading of "is this
|
||||||
|
* Review issue finished", which close-topics.ts closes on.
|
||||||
*
|
*
|
||||||
* Rule: a topic is finished when it has a core set (`set:core` sub-issues) and
|
* Rule: a topic is finished when it has a core set (`set:core` sub-issues) and
|
||||||
* every one of them is closed. Optional and deferred problems are extra credit
|
* every one of them is closed. Optional and deferred problems are extra credit
|
||||||
* and never hold a topic open. A topic with no core sub-issues is never
|
* and never hold a topic open. A topic with no core sub-issues is never
|
||||||
* finished — that would be indistinguishable from a mis-labelled problem set.
|
* finished — that would be indistinguishable from a mis-labelled problem set.
|
||||||
*
|
*
|
||||||
|
* Review issues (`Review <N> — <Phase>`, label `review`) are day blocks for
|
||||||
|
* the transfer phase: problems were re-parented into them from their topics
|
||||||
|
* (a GitHub issue has one parent). A topic still OWNS such a problem —
|
||||||
|
* readProblems() returns it alongside the topic's direct sub-issues, with the
|
||||||
|
* ownership recovered by apps/api/src/reviews.ts (shared with the Worker's
|
||||||
|
* catalog reconcile) — so the finished rule and the release snapshots do not
|
||||||
|
* change when a problem moves. A Review issue is finished when every one of
|
||||||
|
* its sub-issues is closed: it is a fixed assignment, so the set labels do
|
||||||
|
* not matter there.
|
||||||
|
*
|
||||||
* Library: side-effect-free on import; every call takes the caller's github().
|
* Library: side-effect-free on import; every call takes the caller's github().
|
||||||
*/
|
*/
|
||||||
|
import type { Problem } from "../api/src/reviews.ts";
|
||||||
import type { GitHub } from "./github.ts";
|
import type { GitHub } from "./github.ts";
|
||||||
|
|
||||||
/** One problem sub-issue, narrowed from the sub_issues payload. */
|
/** Completion of a required set: a topic's core problems, or every problem of a review. */
|
||||||
export interface Problem {
|
|
||||||
number: number;
|
|
||||||
/** `LC <num> · <Name>`. */
|
|
||||||
title: string;
|
|
||||||
/** ISO instant the issue closed; null while it is open. */
|
|
||||||
closedAt: string | null;
|
|
||||||
labels: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Core {
|
export interface Core {
|
||||||
/** Core sub-issues, closed and open alike, lowest number first. */
|
/** The required problems, closed and open alike, in the order given. */
|
||||||
all: number[];
|
all: number[];
|
||||||
/** The core sub-issues still open — non-empty means the topic stays open. */
|
/** The required problems still open — non-empty means the issue stays open. */
|
||||||
pending: number[];
|
pending: number[];
|
||||||
/** When the last core sub-issue closed; null until the topic is finished. */
|
/** When the last required problem closed; null until the set is finished (never, if empty). */
|
||||||
finishedAt: string | null;
|
finishedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every sub-issue of a topic. The sub_issues payload carries the full issue
|
* Every problem a topic owns: its sub-issues plus the ones moved from it into
|
||||||
* objects (state + labels), so no per-problem follow-up request is needed.
|
* a Review issue (`moved`, from movedFromTopics()). The sub_issues payload
|
||||||
|
* carries the full issue objects (state + labels), so no per-problem
|
||||||
|
* follow-up request is needed.
|
||||||
*/
|
*/
|
||||||
export async function readProblems(gh: GitHub, topic: number): Promise<Problem[]> {
|
export async function readProblems(
|
||||||
const problems: Problem[] = [];
|
gh: GitHub,
|
||||||
|
topic: number,
|
||||||
|
moved: Map<number, Problem[]>,
|
||||||
|
): Promise<Problem[]> {
|
||||||
|
const problems = [...(moved.get(topic) ?? [])];
|
||||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
|
||||||
if (!raw || typeof raw !== "object") continue;
|
if (!raw || typeof raw !== "object") continue;
|
||||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||||
@@ -63,13 +72,18 @@ export function labelValue(problem: Problem, prefix: string): string | undefined
|
|||||||
return problem.labels.find((l) => l.startsWith(prefix))?.slice(prefix.length);
|
return problem.labels.find((l) => l.startsWith(prefix))?.slice(prefix.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A topic's rule: its `set:core` problems are the required set. */
|
||||||
export function coreSet(problems: Problem[]): Core {
|
export function coreSet(problems: Problem[]): Core {
|
||||||
const core = problems.filter((p) => labelValue(p, "set:") === "core");
|
return completion(problems.filter((p) => labelValue(p, "set:") === "core"));
|
||||||
const pending = core.filter((p) => !p.closedAt).map((p) => p.number);
|
}
|
||||||
|
|
||||||
|
/** The shared rule: finished when non-empty and every required problem is closed. */
|
||||||
|
export function completion(required: Problem[]): Core {
|
||||||
|
const pending = required.filter((p) => !p.closedAt).map((p) => p.number);
|
||||||
// ISO instants from the API share one format, so string order is time order.
|
// ISO instants from the API share one format, so string order is time order.
|
||||||
const finishedAt =
|
const finishedAt =
|
||||||
core.length && !pending.length
|
required.length && !pending.length
|
||||||
? core.map((p) => p.closedAt!).reduce((a, b) => (a > b ? a : b))
|
? required.map((p) => p.closedAt!).reduce((a, b) => (a > b ? a : b))
|
||||||
: null;
|
: null;
|
||||||
return { all: core.map((p) => p.number), pending, finishedAt };
|
return { all: required.map((p) => p.number), pending, finishedAt };
|
||||||
}
|
}
|
||||||
+25
-65
@@ -1,43 +1,8 @@
|
|||||||
import { defineConfig } from "blume";
|
import { defineConfig } from "blume";
|
||||||
import type { StandardSchema } from "blume/core/standard-schema.ts";
|
import type { StandardSchema } from "blume/core/standard-schema.ts";
|
||||||
import { custom, filesystem } from "blume/sources";
|
import { filesystem } from "blume/sources";
|
||||||
import { githubReleasesSource } from "blume/sources/github-releases.ts";
|
|
||||||
import redirects from "./redirects";
|
import redirects from "./redirects";
|
||||||
|
|
||||||
/**
|
|
||||||
* GitHub releases → /changelog, dated by the topic's real finish instant.
|
|
||||||
*
|
|
||||||
* Blume dates a release entry by `published_at`, which the GitHub API cannot
|
|
||||||
* backdate — so the topic releases backfilled on one day (apps/cli/
|
|
||||||
* release-topics.ts) would all read that day. Their tags are timever
|
|
||||||
* (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the moment the topic finished, so this
|
|
||||||
* fetch rewrites each timever release's `published_at` to that instant before
|
|
||||||
* Blume maps it; anything else passes through untouched. The config-level
|
|
||||||
* `githubReleases()` accepts no `fetchImpl`, hence the `custom()` wrap of the
|
|
||||||
* underlying source (the `blume/sources/*.ts` path the Blume docs use for
|
|
||||||
* custom sources), with the context the built-in adapter would receive.
|
|
||||||
*/
|
|
||||||
const TIMEVER = /^(\d{4})(\d{2})(\d{2})\.(\d{2})(\d{2})(\d{2})\.\d{9}$/;
|
|
||||||
|
|
||||||
const releasesDatedByTag: typeof fetch = async (input, init) => {
|
|
||||||
const res = await fetch(input, init);
|
|
||||||
if (!res.ok) return res;
|
|
||||||
const releases: unknown = await res.json();
|
|
||||||
if (Array.isArray(releases)) {
|
|
||||||
for (const release of releases) {
|
|
||||||
const m =
|
|
||||||
typeof release?.tag_name === "string"
|
|
||||||
? release.tag_name.match(TIMEVER)
|
|
||||||
: null;
|
|
||||||
if (m)
|
|
||||||
release.published_at = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Blume reads only `ok` and the JSON body, so the original headers (whose
|
|
||||||
// content-length/encoding describe the upstream bytes) are not carried over.
|
|
||||||
return Response.json(releases, { status: res.status });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Blume's defaults (`inter`, `ibm-plex-mono`) are fetched from fonts.gstatic.com
|
// 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,
|
// 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
|
// silently ships no fonts at all. The same two families from Fontsource, as
|
||||||
@@ -69,8 +34,9 @@ const plexMono = {
|
|||||||
/**
|
/**
|
||||||
* Custom frontmatter, rendered as the badge row by
|
* Custom frontmatter, rendered as the badge row by
|
||||||
* components/ProblemBadges.astro. Problem pages: `topics:` is LeetCode's topic
|
* components/ProblemBadges.astro. Problem pages: `topics:` is LeetCode's topic
|
||||||
* tags (written by apps/cli/sync.ts), `video:` the NeetCode walkthrough's
|
* tags and `issue:` the problem's GitHub issue URL (both written by
|
||||||
* YouTube id (apps/cli/videos.ts). Guide pages (content/guides/, created by
|
* apps/cli/sync.ts), `video:` the NeetCode walkthrough's
|
||||||
|
* YouTube id (apps/cli/videos.ts). Guide pages (content/appendix/, created by
|
||||||
* sync): `list:` is the tag's LeetCode problem list. Blume rejects undeclared
|
* sync): `list:` is the tag's LeetCode problem list. Blume rejects undeclared
|
||||||
* keys and takes any Standard Schema; these are hand-rolled so the docs
|
* keys and takes any Standard Schema; these are hand-rolled so the docs
|
||||||
* workspace needs no zod. All optional — every other page carries none.
|
* workspace needs no zod. All optional — every other page carries none.
|
||||||
@@ -108,6 +74,12 @@ const listSchema = optional(
|
|||||||
typeof value === "string" &&
|
typeof value === "string" &&
|
||||||
/^https:\/\/leetcode\.com\/problem-list\/[\w-]+\/$/.test(value),
|
/^https:\/\/leetcode\.com\/problem-list\/[\w-]+\/$/.test(value),
|
||||||
);
|
);
|
||||||
|
const issueSchema = optional(
|
||||||
|
"expected a https://github.com/<owner>/<repo>/issues/<n> URL",
|
||||||
|
(value): value is string =>
|
||||||
|
typeof value === "string" &&
|
||||||
|
/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+$/.test(value),
|
||||||
|
);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
title: "prdlk",
|
title: "prdlk",
|
||||||
@@ -123,11 +95,17 @@ export default defineConfig({
|
|||||||
lastModified: "git",
|
lastModified: "git",
|
||||||
redirects,
|
redirects,
|
||||||
frontmatter: {
|
frontmatter: {
|
||||||
extend: { topics: topicsSchema, video: videoSchema, list: listSchema },
|
extend: {
|
||||||
|
topics: topicsSchema,
|
||||||
|
video: videoSchema,
|
||||||
|
list: listSchema,
|
||||||
|
issue: issueSchema,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
seo: {
|
seo: {
|
||||||
og: { enabled: true },
|
og: { enabled: true },
|
||||||
rss: { enabled: true, types: ["changelog"] },
|
// 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,
|
sitemap: true,
|
||||||
robots: true,
|
robots: true,
|
||||||
structuredData: true,
|
structuredData: true,
|
||||||
@@ -147,39 +125,21 @@ export default defineConfig({
|
|||||||
llmsTxt: true,
|
llmsTxt: true,
|
||||||
mcp: { enabled: false },
|
mcp: { enabled: false },
|
||||||
},
|
},
|
||||||
|
// The only content source. `/changelog` is a custom page (pages/
|
||||||
|
// changelog.astro) reading the GitHub releases itself — one timeline, no
|
||||||
|
// per-release pages, so nothing of it enters the sidebar or prev/next.
|
||||||
content: {
|
content: {
|
||||||
sources: [
|
sources: [filesystem({ root: "content" })],
|
||||||
filesystem({ root: "content" }),
|
|
||||||
custom(
|
|
||||||
githubReleasesSource(
|
|
||||||
{
|
|
||||||
name: "changelog",
|
|
||||||
prefix: "changelog",
|
|
||||||
owner: "prdlk",
|
|
||||||
repo: "leetcode",
|
|
||||||
fetchImpl: releasesDatedByTag,
|
|
||||||
},
|
|
||||||
// What resolve.ts hands the built-in adapter: `.blume/cache/<name>/`,
|
|
||||||
// and a refresh on every load (the source reads nothing else).
|
|
||||||
{
|
|
||||||
projectRoot: import.meta.dirname,
|
|
||||||
cacheDir: `${import.meta.dirname}/.blume/cache/changelog`,
|
|
||||||
mode: "build",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
fonts: { display: inter, body: inter, mono: plexMono },
|
fonts: { display: inter, body: inter, mono: plexMono },
|
||||||
},
|
},
|
||||||
navigation: {
|
navigation: {
|
||||||
|
repo: "https://github.com/prdlk/leetcode",
|
||||||
sidebar: {
|
sidebar: {
|
||||||
display: "flat", // "flat" | "group" | "page"
|
display: "flat", // "flat" | "group" | "page"
|
||||||
},
|
},
|
||||||
tabs: [
|
// A plain header link on the right, not a tab.
|
||||||
{ label: "Guides", path: "/guides" },
|
actions: [{ href: "/changelog", label: "Changelog" }],
|
||||||
{ label: "Changelog", path: "/changelog" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
* - `topics:` (problem pages, written by `apps/cli/sync.ts`) is LeetCode's own
|
* - `topics:` (problem pages, written by `apps/cli/sync.ts`) is LeetCode's own
|
||||||
* topic tags. A tag the campaign teaches (`topic-landings.ts`) links to that
|
* topic tags. A tag the campaign teaches (`topic-landings.ts`) links to that
|
||||||
* topic's landing page and is filled; any other tag links to its guide
|
* topic's landing page and is filled; any other tag links to its guide
|
||||||
* page, `/guides/<slug>` (sync creates one per such tag), and is outlined.
|
* page in the Appendix, `/appendix/<slug>` (sync creates one per such tag), and is outlined.
|
||||||
* `tagTarget()` picks the route (and between two landings, e.g. DFS); the
|
* `tagTarget()` picks the route (and between two landings, e.g. DFS); the
|
||||||
* `PageFooter` backlinks list reads the same answer. Each tag badge is a
|
* `PageFooter` backlinks list reads the same answer. Each tag badge is a
|
||||||
* pill carrying the icon its target shows in the sidebar (the topic's
|
* pill carrying the icon its target shows in the sidebar (the topic's
|
||||||
@@ -22,7 +22,10 @@
|
|||||||
* furthest apart. A tag with no page (the LeetCode-list fallback) stays
|
* furthest apart. A tag with no page (the LeetCode-list fallback) stays
|
||||||
* gray and iconless.
|
* gray and iconless.
|
||||||
* - `video:` (problem pages, written by `apps/cli/videos.ts`) is the NeetCode
|
* - `video:` (problem pages, written by `apps/cli/videos.ts`) is the NeetCode
|
||||||
* walkthrough's YouTube id, the red "Explanation" badge at the end of the row.
|
* walkthrough's YouTube id, the red "Explanation" badge.
|
||||||
|
* - `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.
|
||||||
* - `list:` (guide pages, written by sync on create) is the tag's LeetCode
|
* - `list:` (guide pages, written by sync on create) is the tag's LeetCode
|
||||||
* problem list, an orange "LeetCode list" badge.
|
* problem list, an orange "LeetCode list" badge.
|
||||||
*/
|
*/
|
||||||
@@ -44,6 +47,7 @@ const entry = at ? await getEntry(at.collection, at.entryId) : undefined;
|
|||||||
const tags: TopicTag[] = entry?.data.topics ?? [];
|
const tags: TopicTag[] = entry?.data.topics ?? [];
|
||||||
const video: string | undefined = entry?.data.video;
|
const video: string | undefined = entry?.data.video;
|
||||||
const list: string | undefined = entry?.data.list;
|
const list: string | undefined = entry?.data.list;
|
||||||
|
const issue: string | undefined = entry?.data.issue;
|
||||||
|
|
||||||
const served = new Set<string>(data.routes.map((r: { path: string }) => r.path));
|
const served = new Set<string>(data.routes.map((r: { path: string }) => r.path));
|
||||||
|
|
||||||
@@ -70,7 +74,7 @@ const colors = tags.length
|
|||||||
? tagColors(
|
? tagColors(
|
||||||
[
|
[
|
||||||
...Object.keys(TOPIC_LANDINGS),
|
...Object.keys(TOPIC_LANDINGS),
|
||||||
...[...served].flatMap((path) => path.match(/^\/guides\/([^/]+)$/u)?.slice(1) ?? []),
|
...[...served].flatMap((path) => path.match(/^\/appendix\/([^/]+)$/u)?.slice(1) ?? []),
|
||||||
],
|
],
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
data.routes
|
data.routes
|
||||||
@@ -100,7 +104,7 @@ const linkClass = "no-underline transition-opacity hover:opacity-80";
|
|||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
(badges.length > 0 || video || list) && (
|
(badges.length > 0 || video || list || issue) && (
|
||||||
<div class="not-prose mb-6 flex flex-wrap gap-1.5">
|
<div class="not-prose mb-6 flex flex-wrap gap-1.5">
|
||||||
{badges.map((b) => (
|
{badges.map((b) => (
|
||||||
<a
|
<a
|
||||||
@@ -121,6 +125,13 @@ const linkClass = "no-underline transition-opacity hover:opacity-80";
|
|||||||
</Badge>
|
</Badge>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{issue && (
|
||||||
|
<a href={issue} target="_blank" rel="noopener" class={linkClass}>
|
||||||
|
<Badge icon="github" shape="pill">
|
||||||
|
Issue #{issue.slice(issue.lastIndexOf("/") + 1)}
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
{list && (
|
{list && (
|
||||||
<a href={list} target="_blank" rel="noopener" class={linkClass}>
|
<a href={list} target="_blank" rel="noopener" class={linkClass}>
|
||||||
<Badge variant="warning" icon="list" shape="pill">
|
<Badge variant="warning" icon="list" shape="pill">
|
||||||
|
|||||||
+1
@@ -6,6 +6,7 @@ topics:
|
|||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Binary Search", slug: "binary-search" }
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
video: "nIVW4P8b1VA"
|
video: "nIVW4P8b1VA"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/62"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Find Minimum in Rotated Sorted Array'
|
label: 'Find Minimum in Rotated Sorted Array'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
+1
@@ -6,6 +6,7 @@ topics:
|
|||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Binary Search", slug: "binary-search" }
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
video: "U8XENwh8Oy8"
|
video: "U8XENwh8Oy8"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/60"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Search in Rotated Sorted Array'
|
label: 'Search in Rotated Sorted Array'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
title: '34. Find First and Last Position of Element in Sorted Array'
|
||||||
|
description: Given an array of integers nums sorted in non-decreasing order, find the starting and ending position of a given target value
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Array", slug: "array" }
|
||||||
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/65"
|
||||||
|
sidebar:
|
||||||
|
label: 'Find First and Last Position of Element in Sorted Array'
|
||||||
|
badge: 'Medium'
|
||||||
|
---
|
||||||
|
|
||||||
|
::::warning
|
||||||
|
You must write an algorithm with O(log n) runtime complexity.
|
||||||
|
::::
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `nums = [5,7,7,8,8,10], target = 8`
|
||||||
|
- Output: `[3,4]`
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `nums = [5,7,7,8,8,10], target = 6`
|
||||||
|
- Output: `[-1,-1]`
|
||||||
|
|
||||||
|
### Example 3:
|
||||||
|
- Input: `nums = [], target = 0`
|
||||||
|
- Output: `[-1,-1]`
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `0 <= nums.length <= 10^5`
|
||||||
|
- `-10^9 <= nums[i] <= 10^9`
|
||||||
|
- `nums` is a non-decreasing array.
|
||||||
|
- `-10^9 <= target <= 10^9`
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def searchRange(self, nums: list[int], target: int) -> list[int]:
|
||||||
|
def find(first):
|
||||||
|
result = -1
|
||||||
|
left, right = 0, len(nums) - 1
|
||||||
|
while left <= right:
|
||||||
|
mid = (left + right) // 2
|
||||||
|
if nums[mid] == target:
|
||||||
|
result = mid
|
||||||
|
if first:
|
||||||
|
right = mid - 1
|
||||||
|
else:
|
||||||
|
left = mid + 1
|
||||||
|
elif nums[mid] < target:
|
||||||
|
left = mid + 1
|
||||||
|
else:
|
||||||
|
right = mid - 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
return [find(True), find(False)]
|
||||||
|
```
|
||||||
@@ -6,6 +6,7 @@ topics:
|
|||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { 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"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Search Insert Position'
|
label: 'Search Insert Position'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ topics:
|
|||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Binary Search", slug: "binary-search" }
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
video: "s4DPM8ct1pI"
|
video: "s4DPM8ct1pI"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/58"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Binary Search'
|
label: 'Binary Search'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Binary Search", slug: "binary-search" }
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
- { name: "Matrix", slug: "matrix" }
|
- { name: "Matrix", slug: "matrix" }
|
||||||
video: "Ber2pi2C0j0"
|
video: "Ber2pi2C0j0"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/61"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Search a 2D Matrix'
|
label: 'Search a 2D Matrix'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ topics:
|
|||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Binary Search", slug: "binary-search" }
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
video: "U2SozAs9RzA"
|
video: "U2SozAs9RzA"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/59"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Koko Eating Bananas'
|
label: 'Koko Eating Bananas'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -3,3 +3,84 @@ title: Binary Search
|
|||||||
description: 'Half of a sorted range is removed at each step until the target is found.'
|
description: 'Half of a sorted range is removed at each step until the target is found.'
|
||||||
hidden: true
|
hidden: true
|
||||||
---
|
---
|
||||||
|
|
||||||
|
Binary search is an efficient array search algorithm. It works by narrowing down the search range by half each time. If you have looked up a word in a physical dictionary, you've already used binary search in real life. Let's look at a simple example:
|
||||||
|
|
||||||
|
*Given a sorted array of integers and an integer called target, find the element that equals the target and return its index. If the element is not found, return -1.*
|
||||||
|
|
||||||
|
The key observation here is that the array is sorted. We pick a random element in the array and compare it to the target.
|
||||||
|
|
||||||
|
- If we happen to pick the element that equals the target (how lucky!), then bingo. We don't need to do any more work; we return its index.
|
||||||
|
- If the element is smaller than the target, then we know the target cannot be found in the section to the left of the current element, since everything to the left is even smaller. So, we discard the current element and everything on the left from the search range.
|
||||||
|
- If the element is larger than the target, then we know the target cannot be found in the section to the right of the current element, since everything to the right is even larger. So, we discard the current element and everything on the right from the search range.
|
||||||
|
|
||||||
|
We repeat this process until we find the target. Instead of picking a random element, we always pick the **middle** element in the current search range. This way, we can discard half of the options and shrink the search range by half each time. This gives us `O(log(N))` runtime.
|
||||||
|
|
||||||
|
This idea can be implemented both iteratively and recursively. However, the major difference is that the iterative version of binary search uses `O(1)` memory while the recursive version uses `O(log(N))` memory.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
The search range is represented by the `left` and `right` indices that start from both ends of the array and move towards each other as we search. When moving the index, we discard elements and shrink the search range.
|
||||||
|
|
||||||
|
Time Complexity: `O(log(n))`
|
||||||
|
|
||||||
|
|
||||||
|
<Carousel>
|
||||||
|
<CarouselImage src='../../../public/binary_search.001.png' />
|
||||||
|
<CarouselImage src='../../../public/binary_search.002.png' />
|
||||||
|
<CarouselImage src='../../../public/binary_search.003.png' />
|
||||||
|
<CarouselImage src='../../../public/binary_search.004.png' />
|
||||||
|
<CarouselImage src='../../../public/binary_search.005.png' />
|
||||||
|
<CarouselImage src='../../../public/binary_search.006.png' />
|
||||||
|
<CarouselImage src='../../../public/binary_search.007.png' />
|
||||||
|
<CarouselImage src='../../../public/binary_search.008.png' />
|
||||||
|
</Carousel>
|
||||||
|
|
||||||
|
```python
|
||||||
|
def binary_search(arr: list[int], target: int) -> int:
|
||||||
|
left, right = 0, len(arr) - 1
|
||||||
|
# <= because left and right could point to the same element, < would miss it
|
||||||
|
while left <= right:
|
||||||
|
# double slash for integer division in python 3,
|
||||||
|
# we don't have to worry about integer `left + right` overflow
|
||||||
|
# since python integers can be arbitrarily large
|
||||||
|
mid = (left + right) // 2
|
||||||
|
# found target, return its index
|
||||||
|
if arr[mid] == target:
|
||||||
|
return mid
|
||||||
|
# middle less than target, discard left half by making left search boundary `mid + 1`
|
||||||
|
if arr[mid] < target:
|
||||||
|
left = mid + 1
|
||||||
|
# middle greater than target, discard right half by making right search boundary `mid - 1`
|
||||||
|
else:
|
||||||
|
right = mid - 1
|
||||||
|
return -1 # if we get here we didn't hit above return so we didn't find target
|
||||||
|
```
|
||||||
|
|
||||||
|
### Calculating `mid`
|
||||||
|
|
||||||
|
Note that when calculating `mid`, if the number of elements is even, there are two elements in the middle. We usually follow the convention of picking the first one, equivalent to doing integer division `(left + right) / 2`.
|
||||||
|
|
||||||
|
In most programming languages, we calculate `mid` with `left + floor((right-left) / 2)` to avoid potential integer overflow. However, in Python, we do not need to worry about integer overflow with `left + right` because Python3 integers can be arbitrarily large.
|
||||||
|
|
||||||
|
## Deduction
|
||||||
|
|
||||||
|
It's essential to understand and deduce the algorithm yourself instead of memorizing it. In an actual interview, interviewers may ask you additional questions to test your understanding, so simply memorizing the algorithm may not be enough to convince the interviewer.
|
||||||
|
|
||||||
|
Key elements in writing a correct binary search:
|
||||||
|
|
||||||
|
### 1. When to terminate the loop
|
||||||
|
|
||||||
|
Make sure the `while` loop includes an equality comparison; otherwise, we might skip the loop and miss the potential match for the edge case of a one-element array.
|
||||||
|
|
||||||
|
### 2. Whether/how to update `left` and `right` boundary in the `if` conditions
|
||||||
|
|
||||||
|
Consider which side to discard. If `arr[mid]` is smaller than the `target`, we should discard everything on the left by making `left = mid + 1`.
|
||||||
|
|
||||||
|
### 3. Should I discard the current element?
|
||||||
|
|
||||||
|
For vanilla binary search, we can discard it since it cannot be the final answer if it is not equal to the target. There might be situations where you would want to think twice before discarding the current element. We'll discuss this in the next module.
|
||||||
|
|
||||||
|
## When to use binary search
|
||||||
|
|
||||||
|
Interestingly, binary search works beyond sorted arrays. **You can use binary search whenever you make a binary decision to shrink the search range.**
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
import { defineMeta } from "blume";
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
export default defineMeta({
|
export default defineMeta({ title: "Binary Search", order: 5, icon: "search", display: "group" });
|
||||||
title: "Binary Search",
|
|
||||||
order: 5,
|
|
||||||
icon: "search",
|
|
||||||
display: "group",
|
|
||||||
});
|
|
||||||
@@ -6,6 +6,7 @@ topics:
|
|||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Hash Table", slug: "hash-table" }
|
- { name: "Hash Table", slug: "hash-table" }
|
||||||
video: "KLlXCFG5TnA"
|
video: "KLlXCFG5TnA"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/29"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Two Sum'
|
label: 'Two Sum'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
title: '205. Isomorphic Strings'
|
||||||
|
description: Given two strings s and t, determine if they are isomorphic
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Hash Table", slug: "hash-table" }
|
||||||
|
- { name: "String", slug: "string" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/33"
|
||||||
|
sidebar:
|
||||||
|
label: 'Isomorphic Strings'
|
||||||
|
badge: 'Easy'
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `s = "egg", t = "add"`
|
||||||
|
- Output: `true`
|
||||||
|
- Explanation: The strings `s` and `t` can be made identical by: • Mapping 'e' to 'a'. • Mapping 'g' to 'd'.
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `s = "f11", t = "b23"`
|
||||||
|
- Output: `false`
|
||||||
|
- Explanation: The strings `s` and `t` can not be made identical as `'1`' needs to be mapped to both `'2`' and `'3`'.
|
||||||
|
|
||||||
|
### Example 3:
|
||||||
|
- Input: `s = "paper", t = "title"`
|
||||||
|
- Output: `true`
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `1 <= s.length <= 5 * 10^4`
|
||||||
|
- `t.length == s.length`
|
||||||
|
- `s` and `t` consist of any valid ascii character.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def isIsomorphic(self, s: str, t: str) -> bool:
|
||||||
|
N = len(t)
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
for i in range(N):
|
||||||
|
c1, c2 = s[i], t[i]
|
||||||
|
if c1 in seen and seen[c1] != c2:
|
||||||
|
return False
|
||||||
|
elif c1 in seen and seen[c1] == c2:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
seen[c1] = c2
|
||||||
|
|
||||||
|
return len(set(s)) == len(set(t))
|
||||||
|
```
|
||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Hash Table", slug: "hash-table" }
|
- { name: "Hash Table", slug: "hash-table" }
|
||||||
- { name: "Sorting", slug: "sorting" }
|
- { name: "Sorting", slug: "sorting" }
|
||||||
video: "3OamzN90kPg"
|
video: "3OamzN90kPg"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/27"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Contains Duplicate'
|
label: 'Contains Duplicate'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "String", slug: "string" }
|
- { name: "String", slug: "string" }
|
||||||
- { name: "Sorting", slug: "sorting" }
|
- { name: "Sorting", slug: "sorting" }
|
||||||
video: "9UtInBqnCgA"
|
video: "9UtInBqnCgA"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/28"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Valid Anagram'
|
label: 'Valid Anagram'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ topics:
|
|||||||
- { name: "Counting", slug: "counting" }
|
- { name: "Counting", slug: "counting" }
|
||||||
- { name: "Quickselect", slug: "quickselect" }
|
- { name: "Quickselect", slug: "quickselect" }
|
||||||
video: "YPTqKIgVk-k"
|
video: "YPTqKIgVk-k"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/31"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Top K Frequent Elements'
|
label: 'Top K Frequent Elements'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "String", slug: "string" }
|
- { name: "String", slug: "string" }
|
||||||
- { name: "Sorting", slug: "sorting" }
|
- { name: "Sorting", slug: "sorting" }
|
||||||
video: "vzdNOK2oB2E"
|
video: "vzdNOK2oB2E"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/30"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Group Anagrams'
|
label: 'Group Anagrams'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -3,3 +3,46 @@ title: Hash Table
|
|||||||
description: 'A key maps to a value in constant time, which removes the need for a full scan.'
|
description: 'A key maps to a value in constant time, which removes the need for a full scan.'
|
||||||
hidden: true
|
hidden: true
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Hash Tables
|
||||||
|
|
||||||
|
Sometimes when programming, we need a data structure that maps an arbitrary data type to another arbitrary data type, like an array with non-integer indices. For example, a phone book that allows lookup by a person's name requires such data type.
|
||||||
|
|
||||||
|
A **hash table** (or a hash map) is one way of representing this data structure. The idea of hash table is this:
|
||||||
|
|
||||||
|
Data is stored in a fix-sized array, where each data value has an unique index. When we add a new `(key, value)` pair to the array, we use a hash function to generate a hash value within the range of the array with the `key`. We then store the data `value` at the index indicated by the generated hash value. Later, when we retrieve the data with the `key` from the hash table, we hash the `key` again and look up the data in the array with the corresponding index. Since the hash function always returns the same hash values with the same input, we will always find the correct item in that index.
|
||||||
|
|
||||||
|
|
||||||
|
<Frame caption="Hash Table Operations">
|
||||||
|
<img src="../../../public/hash-table-operations.svg" alt="Hash Table Operations" />
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
|
||||||
|
## Efficiency
|
||||||
|
|
||||||
|
Assuming the hash function uses constant time, the average time complexity is `O(n / k)` for each insertion/access operation on the table, where `n` is the number of entries in the hash table and `k` is the array size of the table. This assumes the hash function distributes keys uniformly across the `k` buckets, so each bucket holds about `n / k` entries on average.
|
||||||
|
|
||||||
|
**Frequency Counting**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_counter(arr: list[int]) -> dict[int, int]:
|
||||||
|
# initialize a hash map to store each number and its count
|
||||||
|
counter: dict[int, int] = {}
|
||||||
|
for num in arr:
|
||||||
|
# check if num is a key in the hash map
|
||||||
|
if num in counter:
|
||||||
|
# update the count of num to increase by 1
|
||||||
|
counter[num] += 1
|
||||||
|
else:
|
||||||
|
# set the count of num to 1
|
||||||
|
counter[num] = 1
|
||||||
|
return counter
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip[Python provides a built-in `collections.Counter` class]
|
||||||
|
```python
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
fq = Counter(arr)
|
||||||
|
```
|
||||||
|
:::
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
import { defineMeta } from "blume";
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
export default defineMeta({
|
export default defineMeta({ title: "Hash Table", order: 1, icon: "hash", display: "group" });
|
||||||
title: "Hash Table",
|
|
||||||
order: 1,
|
|
||||||
icon: "hash",
|
|
||||||
display: "group",
|
|
||||||
});
|
|
||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Stack", slug: "stack" }
|
- { name: "Stack", slug: "stack" }
|
||||||
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
||||||
video: "cTBiBSnjO3c"
|
video: "cTBiBSnjO3c"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/72"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Daily Temperatures'
|
label: 'Daily Temperatures'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "Sorting", slug: "sorting" }
|
- { name: "Sorting", slug: "sorting" }
|
||||||
- { 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"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Car Fleet'
|
label: 'Car Fleet'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
+1
@@ -6,6 +6,7 @@ topics:
|
|||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Prefix Sum", slug: "prefix-sum" }
|
- { name: "Prefix Sum", slug: "prefix-sum" }
|
||||||
video: "bNvIQI2wAjk"
|
video: "bNvIQI2wAjk"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/37"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Product of Array Except Self'
|
label: 'Product of Array Except Self'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Design", slug: "design" }
|
- { name: "Design", slug: "design" }
|
||||||
- { 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"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Range Sum Query - Immutable'
|
label: 'Range Sum Query - Immutable'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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" }
|
||||||
video: "fFVZt-6sgyo"
|
video: "fFVZt-6sgyo"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/38"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Subarray Sum Equals K'
|
label: 'Subarray Sum Equals K'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
title: '974. Subarray Sums Divisible by K'
|
||||||
|
description: Given an integer array nums and an integer k, return the number of non-empty subarrays that have a sum divisible by k
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Array", slug: "array" }
|
||||||
|
- { name: "Hash Table", slug: "hash-table" }
|
||||||
|
- { name: "Prefix Sum", slug: "prefix-sum" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/40"
|
||||||
|
sidebar:
|
||||||
|
label: 'Subarray Sums Divisible by K'
|
||||||
|
badge: 'Medium'
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `nums = [4,5,0,-2,-3,1], k = 5`
|
||||||
|
- Output: `7`
|
||||||
|
- Explanation: There are `7` subarrays with a sum divisible by `k = 5`: [4, `5`, `0`, `-2`, `-3`, 1], `[5]`, [5, 0], [5, `0`, `-2`, -3], `[0]`, [0, `-2`, -3], [-2, -3]
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `nums = [5], k = 9`
|
||||||
|
- Output: `0`
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `1 <= nums.length <= 3 * 10^4`
|
||||||
|
- `-10^4 <= nums[i] <= 10^4`
|
||||||
|
- `2 <= k <= 10^4`
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def subarraysDivByK(self, nums: list[int], k: int) -> int:
|
||||||
|
remainder_counts = {0: 1}
|
||||||
|
cur_sum = 0
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for n in nums:
|
||||||
|
cur_sum += n
|
||||||
|
remainder = cur_sum % k
|
||||||
|
|
||||||
|
if remainder in remainder_counts:
|
||||||
|
count += remainder_counts[remainder]
|
||||||
|
|
||||||
|
remainder_counts[remainder] = remainder_counts.get(remainder, 0) + 1
|
||||||
|
|
||||||
|
return count
|
||||||
|
```
|
||||||
@@ -3,3 +3,43 @@ title: Prefix Sum
|
|||||||
description: 'A running total is stored at each index, so the sum of any range is one subtraction.'
|
description: 'A running total is stored at each index, so the sum of any range is one subtraction.'
|
||||||
hidden: true
|
hidden: true
|
||||||
---
|
---
|
||||||
|
|
||||||
|
Suppose you are handed an array and asked for the sum of the elements between index `i` and index `j`. One query is easy: loop from `i` to `j` and add. But now imagine thousands of such queries over the same array, or a search that inspects the sum of every possible subarray. Recomputing each range from scratch costs `O(n)` per query, and the work piles up fast.
|
||||||
|
|
||||||
|
Prefix sum removes that repetition. You spend `O(n)` once to precompute running totals, and afterward every range sum collapses into a single subtraction that runs in `O(1)`, no matter how wide the range.
|
||||||
|
|
||||||
|
<YouTube id="CRVxUgmIO8M" title="Prefix Sum Explained" />
|
||||||
|
|
||||||
|
## Transformation
|
||||||
|
|
||||||
|
A prefix sum turns an array into a new array of running totals. For an input array `arr` of length `n`, build a `prefix` array of length `n + 1`:
|
||||||
|
|
||||||
|
`prefix[0] = 0`
|
||||||
|
|
||||||
|
`prefix[k] = arr[0] + arr[1] + ... + arr[k-1]`
|
||||||
|
|
||||||
|
Each entry is the previous total plus one more element, so a single pass builds the whole thing:
|
||||||
|
|
||||||
|
`prefix[k] = prefix[k-1] + arr[k-1]`
|
||||||
|
|
||||||
|
The leading `prefix[0] = 0` is the sum of zero elements. That extra slot is what makes the range math below land exactly on the boundaries, which is why `prefix` has one more cell than `arr`.
|
||||||
|
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
## Any range sum is one subtraction
|
||||||
|
|
||||||
|
The sum of the subarray from index `i` up to (but not including) `j` is `prefix[j] - prefix[i]`. `prefix[j]` is the total of everything before `j`, and `prefix[i]` is the total of everything before `i`, so subtracting throws away the front you did not ask for and leaves the middle.
|
||||||
|
|
||||||
|
## Watch the boundaries
|
||||||
|
|
||||||
|
The one place prefix sums trip people up is the off-by-one at the edges. Keep three facts straight: `prefix` has length `n + 1`, `prefix[0]` is `0`, and `sum(arr[i..j-1]) = prefix[j] - prefix[i]` uses an exclusive right index `j`. If a problem hands you an inclusive right index `r`, translate it to `prefix[r + 1] - prefix[i]`.
|
||||||
|
|
||||||
|
## When to reach for it
|
||||||
|
|
||||||
|
Prefix sums fit whenever you need repeated range sums over a fixed array, or when an algorithm searches across subarray boundaries. For a single one-off sum, a plain loop is simpler; the prefix array earns its `O(n)` setup only once the same array is queried many times.
|
||||||
+1
@@ -6,6 +6,7 @@ topics:
|
|||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
||||||
video: "1pkOgXD63yU"
|
video: "1pkOgXD63yU"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/50"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Best Time to Buy and Sell Stock'
|
label: 'Best Time to Buy and Sell Stock'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
title: '209. Minimum Size Subarray Sum'
|
||||||
|
description: Given an array of positive integers nums and a positive integer target, return the minimal length of a subarray whose sum is greater than or equal to target. If there is no such subarray, return 0 instead
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Array", slug: "array" }
|
||||||
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
|
- { name: "Sliding Window", slug: "sliding-window" }
|
||||||
|
- { name: "Prefix Sum", slug: "prefix-sum" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/54"
|
||||||
|
sidebar:
|
||||||
|
label: 'Minimum Size Subarray Sum'
|
||||||
|
badge: 'Medium'
|
||||||
|
---
|
||||||
|
|
||||||
|
::::warning
|
||||||
|
If you have figured out the O(n) solution, try coding another solution of which the time complexity is O(n log(n)).
|
||||||
|
::::
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `target = 7, nums = [2,3,1,2,4,3]`
|
||||||
|
- Output: `2`
|
||||||
|
- Explanation: The subarray `[4,3]` has the minimal length under the problem constraint.
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `target = 4, nums = [1,4,4]`
|
||||||
|
- Output: `1`
|
||||||
|
|
||||||
|
### Example 3:
|
||||||
|
- Input: `target = 11, nums = [1,1,1,1,1,1,1,1]`
|
||||||
|
- Output: `0`
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `1 <= target <= 10^9`
|
||||||
|
- `1 <= nums.length <= 10^5`
|
||||||
|
- `1 <= nums[i] <= 10^4`
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def minSubArrayLen(self, target: int, nums: list[int]) -> int:
|
||||||
|
window_sum = 0
|
||||||
|
length = len(nums) + 1
|
||||||
|
left = 0
|
||||||
|
|
||||||
|
for right in range(len(nums)):
|
||||||
|
window_sum += nums[right]
|
||||||
|
while window_sum >= target:
|
||||||
|
length = min(length, right - left + 1)
|
||||||
|
window_sum -= nums[left]
|
||||||
|
left += 1
|
||||||
|
if length > len(nums):
|
||||||
|
return 0
|
||||||
|
return length
|
||||||
|
```
|
||||||
+1
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "String", slug: "string" }
|
- { name: "String", slug: "string" }
|
||||||
- { name: "Sliding Window", slug: "sliding-window" }
|
- { name: "Sliding Window", slug: "sliding-window" }
|
||||||
video: "wiGpQwVHdE0"
|
video: "wiGpQwVHdE0"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/51"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Longest Substring Without Repeating Characters'
|
label: 'Longest Substring Without Repeating Characters'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
+1
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "String", slug: "string" }
|
- { name: "String", slug: "string" }
|
||||||
- { name: "Sliding Window", slug: "sliding-window" }
|
- { name: "Sliding Window", slug: "sliding-window" }
|
||||||
video: "gqXU1UyA8pk"
|
video: "gqXU1UyA8pk"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/52"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Longest Repeating Character Replacement'
|
label: 'Longest Repeating Character Replacement'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "String", slug: "string" }
|
- { name: "String", slug: "string" }
|
||||||
- { name: "Sliding Window", slug: "sliding-window" }
|
- { name: "Sliding Window", slug: "sliding-window" }
|
||||||
video: "UbyhOgBN834"
|
video: "UbyhOgBN834"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/53"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Permutation in String'
|
label: 'Permutation in String'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -4,12 +4,6 @@ description: 'Sliding window is a specialized two-pointer family where the point
|
|||||||
hidden: true
|
hidden: true
|
||||||
---
|
---
|
||||||
|
|
||||||
:::warning
|
|
||||||
This is the family where invariants matter the most
|
|
||||||
:::
|
|
||||||
|
|
||||||
## What Makes Sliding Window Different?
|
|
||||||
|
|
||||||
Many problems have two moving boundaries. That alone does not make them sliding window problems.
|
Many problems have two moving boundaries. That alone does not make them sliding window problems.
|
||||||
|
|
||||||
A problem belongs to this family when:
|
A problem belongs to this family when:
|
||||||
@@ -18,87 +12,204 @@ A problem belongs to this family when:
|
|||||||
- you can update the window state incrementally as elements enter or leave
|
- you can update the window state incrementally as elements enter or leave
|
||||||
- you maintain a condition that tells you whether the window is valid
|
- you maintain a condition that tells you whether the window is valid
|
||||||
|
|
||||||
## What is an Invariant?
|
:::tip
|
||||||
|
The rule for all three: every element enters once and leaves once, so the whole thing runs in O(n).
|
||||||
|
:::
|
||||||
|
|
||||||
|
<Accordion>
|
||||||
|
<AccordionItem title="What is an invariant?">
|
||||||
|
An invariant is a statement that stays true before and after every iteration. In a sliding window, that statement usually describes the window itself, such as “the window is valid,” “it has no duplicates,” or “its sum is at most the target.” It can also describe a helper structure, such as “the frequency map matches the characters currently inside the window.”
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem title="Why invariants matter?">
|
||||||
|
Without an invariant, shrinking the window is just a guess. You no longer know when the window is valid, when to update the answer, or why the left pointer should stop. The invariant is what makes the algorithm correct.
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem title="How sliding window differs from other two-pointer families?">
|
||||||
|
Sliding window is not the same as every other two-pointer pattern. In Container With Most Water, the interval is only the remaining search space. In sliding window, the interval itself is the thing you care about. In Remove Duplicates, the left side is already the finished answer prefix. In sliding window, both pointers describe a live window that is still being checked.
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
|
||||||
An invariant is a statement that stays true before and after every iteration.
|
## 1. Fixed-size windows
|
||||||
|
|
||||||
For sliding window, the invariant often looks like one of these:
|
|
||||||
|
|
||||||
- "the current window is valid"
|
|
||||||
- "the current window contains no duplicates"
|
|
||||||
- "the current window sum is at most the target"
|
|
||||||
- "the frequency map matches the characters currently inside the window"
|
|
||||||
|
|
||||||
The invariant gives the loop its structure:
|
|
||||||
|
|
||||||
- expand the window by moving right
|
|
||||||
- update the window state
|
|
||||||
- if the invariant breaks, shrink from the left until it holds again
|
|
||||||
- update the answer only when you know the invariant is satisfied
|
|
||||||
|
|
||||||
|
|
||||||
## Why Invariants Matter
|
|
||||||
|
|
||||||
Without an invariant, shrinking the window becomes arbitrary. You no longer know:
|
|
||||||
|
|
||||||
- when the window is valid
|
|
||||||
- when to update the answer
|
|
||||||
- why the left pointer should stop moving
|
|
||||||
- The invariant is the reason the algorithm is correct.
|
|
||||||
|
|
||||||
## The Main Subtypes
|
|
||||||
|
|
||||||
### 1. Fixed-size windows
|
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
The window size never changes. The main task is updating the window state efficiently when one element enters and one leaves.
|
The window size never changes. The main task is updating the window state efficiently when one element enters and one leaves.
|
||||||
:::
|
:::
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 2. Flexible-size windows with a validity rule
|
<Frame caption="Sliding Window Fixed" hint="">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
|
||||||
|
```python
|
||||||
|
def fixed_window(nums, k):
|
||||||
|
window_sum = 0
|
||||||
|
best = float('-inf')
|
||||||
|
|
||||||
|
for right in range(len(nums)):
|
||||||
|
window_sum += nums[right] # add the new element
|
||||||
|
|
||||||
|
if right >= k - 1: # window is full
|
||||||
|
best = max(best, window_sum) # record answer
|
||||||
|
window_sum -= nums[right - k + 1] # remove the oldest element
|
||||||
|
|
||||||
|
return best
|
||||||
|
```
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step title="">
|
||||||
|
Add `nums[right]` to the window.
|
||||||
|
</Step>
|
||||||
|
<Step title="">
|
||||||
|
If the window has `k` elements, record the answer.
|
||||||
|
</Step>
|
||||||
|
<Step title="">
|
||||||
|
Remove the leftmost element so the window stays at size `k`.
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
|
||||||
|
## 2. Flexible-size windows with a validity rule
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
Here the window expands until invalid, then shrinks until valid again.
|
Here the window expands until invalid, then shrinks until valid again.
|
||||||
:::
|
:::
|
||||||
|
|
||||||

|
<Frame caption="Sliding Window Flexible" hint="">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### 3. Frequency-tracking windows
|
</Frame>
|
||||||
|
|
||||||
|
```python
|
||||||
|
def flexible_window(nums, target):
|
||||||
|
left = 0
|
||||||
|
window_sum = 0
|
||||||
|
best = 0
|
||||||
|
|
||||||
|
for right in range(len(nums)):
|
||||||
|
window_sum += nums[right] # grow the window
|
||||||
|
|
||||||
|
while window_sum > target: # window is invalid
|
||||||
|
window_sum -= nums[left] # shrink from the left
|
||||||
|
left += 1
|
||||||
|
|
||||||
|
best = max(best, right - left + 1) # window is valid, record answer
|
||||||
|
|
||||||
|
return best
|
||||||
|
```
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step title="">
|
||||||
|
Add `nums[right]` to the window.
|
||||||
|
</Step>
|
||||||
|
<Step title="">
|
||||||
|
While the window is invalid, remove `nums[left]` and move `left` up.
|
||||||
|
</Step>
|
||||||
|
<Step title="">
|
||||||
|
The window is now valid. Record its size.
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
For "shortest window" problems, swap the logic: use `while` for the *valid* case, record the answer inside the loop, then shrink.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def shortest_valid_window(nums, target):
|
||||||
|
left = 0
|
||||||
|
window_sum = 0
|
||||||
|
best = float('inf')
|
||||||
|
|
||||||
|
for right in range(len(nums)):
|
||||||
|
window_sum += nums[right]
|
||||||
|
|
||||||
|
while window_sum >= target: # window is valid
|
||||||
|
best = min(best, right - left + 1) # record, then try to shrink
|
||||||
|
window_sum -= nums[left]
|
||||||
|
left += 1
|
||||||
|
|
||||||
|
return best if best != float('inf') else 0
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Frequency-tracking windows
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
The difficulty comes from keeping the bookkeeping correct as characters enter and leave.
|
The difficulty comes from keeping the bookkeeping correct as characters enter and leave.
|
||||||
:::
|
:::
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## How Sliding Window Differs From Other Two-Pointer Families
|
<Frame caption="Frequency-tracking windows" hint="">
|
||||||
|
|
||||||
Compare it with Container With Most Water:
|

|
||||||
|
|
||||||
- In sliding window, the interval itself has meaning.
|
</Frame>
|
||||||
- In container, the interval is just the remaining search space.
|
|
||||||
|
|
||||||
Compare it with Remove Duplicates:
|
```python
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
- In sliding window, both pointers describe a live window.
|
def frequency_window(s):
|
||||||
- In compaction, the left part of the array is already the answer prefix.
|
left = 0
|
||||||
|
counts = defaultdict(int)
|
||||||
|
best = 0
|
||||||
|
|
||||||
## A Practical Template
|
for right in range(len(s)):
|
||||||
|
counts[s[right]] += 1 # character enters
|
||||||
|
|
||||||
A common variable-size sliding window has this shape:
|
while counts[s[right]] > 1: # rule: no repeats
|
||||||
|
counts[s[left]] -= 1 # character leaves
|
||||||
|
left += 1
|
||||||
|
|
||||||
1. Add the new rightmost element.
|
best = max(best, right - left + 1)
|
||||||
2. Update the window state.
|
|
||||||
3. While the invariant is broken, remove elements from the left.
|
|
||||||
4. Once the invariant is restored, update the answer.
|
|
||||||
|
|
||||||
The exact data structure changes from problem to problem, but the logic stays the same.
|
return best
|
||||||
|
```
|
||||||
|
|
||||||
As you work through this section, keep asking:
|
<Steps>
|
||||||
|
<Step title="">
|
||||||
|
Add `s[right]` to the window.
|
||||||
|
</Step>
|
||||||
|
<Step title="">
|
||||||
|
While the window has more than one character, remove `s[left]` and move `left` up.
|
||||||
|
</Step>
|
||||||
|
<Step title="">
|
||||||
|
The window is now valid. Record its size.
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
For anagram or "minimum window substring" problems, you also keep a target count. Track how many characters currently match the target with one integer (`matched`). Update it only when a count crosses the target value. This keeps each step `O(1)`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def anagram_window(s, p):
|
||||||
|
need = defaultdict(int)
|
||||||
|
for c in p:
|
||||||
|
need[c] += 1
|
||||||
|
|
||||||
|
counts = defaultdict(int)
|
||||||
|
matched = 0 # how many chars have counts[c] == need[c]
|
||||||
|
result = []
|
||||||
|
k = len(p)
|
||||||
|
|
||||||
|
for right in range(len(s)):
|
||||||
|
c = s[right]
|
||||||
|
counts[c] += 1
|
||||||
|
if counts[c] == need[c]:
|
||||||
|
matched += 1
|
||||||
|
|
||||||
|
if right >= k: # window too big, remove left
|
||||||
|
d = s[right - k]
|
||||||
|
if counts[d] == need[d]:
|
||||||
|
matched -= 1
|
||||||
|
counts[d] -= 1
|
||||||
|
|
||||||
|
if matched == len(need):
|
||||||
|
result.append(right - k + 1)
|
||||||
|
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
:::warning
|
|
||||||
What invariant does my window maintain, and at what moment is it safe to update the answer?
|
|
||||||
:::
|
|
||||||
+1
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Math", slug: "math" }
|
- { name: "Math", slug: "math" }
|
||||||
- { name: "Stack", slug: "stack" }
|
- { name: "Stack", slug: "stack" }
|
||||||
video: "iu0082c4HDE"
|
video: "iu0082c4HDE"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/69"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Evaluate Reverse Polish Notation'
|
label: 'Evaluate Reverse Polish Notation'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ topics:
|
|||||||
- { name: "Stack", slug: "stack" }
|
- { name: "Stack", slug: "stack" }
|
||||||
- { name: "Design", slug: "design" }
|
- { name: "Design", slug: "design" }
|
||||||
video: "qkLl7nAwDPo"
|
video: "qkLl7nAwDPo"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/68"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Min Stack'
|
label: 'Min Stack'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Stack", slug: "stack" }
|
- { name: "Stack", slug: "stack" }
|
||||||
- { name: "Bracket Sequences", slug: "bracket-sequences" }
|
- { name: "Bracket Sequences", slug: "bracket-sequences" }
|
||||||
video: "WTzjTskDFMg"
|
video: "WTzjTskDFMg"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/67"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Valid Parentheses'
|
label: 'Valid Parentheses'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Two Pointers", slug: "two-pointers" }
|
- { name: "Two Pointers", slug: "two-pointers" }
|
||||||
- { name: "Greedy", slug: "greedy" }
|
- { name: "Greedy", slug: "greedy" }
|
||||||
video: "UuiTKBwPgAo"
|
video: "UuiTKBwPgAo"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/44"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Container With Most Water'
|
label: 'Container With Most Water'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ topics:
|
|||||||
- { name: "Two Pointers", slug: "two-pointers" }
|
- { name: "Two Pointers", slug: "two-pointers" }
|
||||||
- { name: "String", slug: "string" }
|
- { name: "String", slug: "string" }
|
||||||
video: "jJXJ16kPFWg"
|
video: "jJXJ16kPFWg"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/41"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Valid Palindrome'
|
label: 'Valid Palindrome'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Two Pointers", slug: "two-pointers" }
|
- { name: "Two Pointers", slug: "two-pointers" }
|
||||||
- { name: "Sorting", slug: "sorting" }
|
- { name: "Sorting", slug: "sorting" }
|
||||||
video: "jzZsG8n2R9A"
|
video: "jzZsG8n2R9A"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/43"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: '3Sum'
|
label: '3Sum'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
+1
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Two Pointers", slug: "two-pointers" }
|
- { name: "Two Pointers", slug: "two-pointers" }
|
||||||
- { name: "Binary Search", slug: "binary-search" }
|
- { name: "Binary Search", slug: "binary-search" }
|
||||||
video: "cQ1Oz4ckceM"
|
video: "cQ1Oz4ckceM"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/42"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Two Sum II - Input Array Is Sorted'
|
label: 'Two Sum II - Input Array Is Sorted'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "String", slug: "string" }
|
- { name: "String", slug: "string" }
|
||||||
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
||||||
video: "99RVfqklbCE"
|
video: "99RVfqklbCE"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/45"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Is Subsequence'
|
label: 'Is Subsequence'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
title: '42. Trapping Rain Water'
|
||||||
|
description: Given n non-negative integers representing an elevation map where the width of each bar is 1, compute how much water it can trap after raining
|
||||||
|
icon: dot
|
||||||
|
topics:
|
||||||
|
- { name: "Array", slug: "array" }
|
||||||
|
- { name: "Two Pointers", slug: "two-pointers" }
|
||||||
|
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
||||||
|
- { name: "Stack", slug: "stack" }
|
||||||
|
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/211"
|
||||||
|
sidebar:
|
||||||
|
label: 'Trapping Rain Water'
|
||||||
|
badge: 'Hard'
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `height = [0,1,0,2,1,0,1,3,2,1,2,1]`
|
||||||
|
- Output: `6`
|
||||||
|
- Explanation: The above elevation map (black section) is represented by array `[0,1,0,2,1,0,1,3,2,1,2,1]`. In this case, `6` units of rain water (blue section) are being trapped.
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `height = [4,2,0,3,2,5]`
|
||||||
|
- Output: `9`
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `n == height.length`
|
||||||
|
- `1 <= n <= 2 * 10^4`
|
||||||
|
- `0 <= height[i] <= 10^5`
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def trap(self, height: list[int]) -> int:
|
||||||
|
left, right = 0, len(height) - 1
|
||||||
|
leftMax, rightMax = 0, 0
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
while left < right:
|
||||||
|
leftMax = max(leftMax, height[left])
|
||||||
|
rightMax = max(rightMax, height[right])
|
||||||
|
|
||||||
|
if height[left] < height[right]:
|
||||||
|
total += leftMax - height[left]
|
||||||
|
left += 1
|
||||||
|
else:
|
||||||
|
total += rightMax - height[right]
|
||||||
|
right -= 1
|
||||||
|
|
||||||
|
return total
|
||||||
|
```
|
||||||
+1
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Two Pointers", slug: "two-pointers" }
|
- { name: "Two Pointers", slug: "two-pointers" }
|
||||||
- { name: "Sorting", slug: "sorting" }
|
- { name: "Sorting", slug: "sorting" }
|
||||||
video: "FPCZsG_AkUg"
|
video: "FPCZsG_AkUg"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/46"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Squares of a Sorted Array'
|
label: 'Squares of a Sorted Array'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "Design", slug: "design" }
|
- { name: "Design", slug: "design" }
|
||||||
- { name: "Doubly-Linked List", slug: "doubly-linked-list" }
|
- { name: "Doubly-Linked List", slug: "doubly-linked-list" }
|
||||||
video: "7ABFKPK2hD4"
|
video: "7ABFKPK2hD4"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/87"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'LRU Cache'
|
label: 'LRU Cache'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Math", slug: "math" }
|
- { name: "Math", slug: "math" }
|
||||||
- { name: "Recursion", slug: "recursion" }
|
- { name: "Recursion", slug: "recursion" }
|
||||||
video: "wgFPrzTjm7s"
|
video: "wgFPrzTjm7s"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/88"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Add Two Numbers'
|
label: 'Add Two Numbers'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "Two Pointers", slug: "two-pointers" }
|
- { name: "Two Pointers", slug: "two-pointers" }
|
||||||
- { name: "Floyd's Cycle Finding Algorithm", slug: "floyds-cycle-finding-algorithm" }
|
- { name: "Floyd's Cycle Finding Algorithm", slug: "floyds-cycle-finding-algorithm" }
|
||||||
video: "gBTe7lFR3vc"
|
video: "gBTe7lFR3vc"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/79"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Linked List Cycle'
|
label: 'Linked List Cycle'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ topics:
|
|||||||
- { name: "Linked List", slug: "linked-list" }
|
- { name: "Linked List", slug: "linked-list" }
|
||||||
- { name: "Recursion", slug: "recursion" }
|
- { name: "Recursion", slug: "recursion" }
|
||||||
video: "G0_I-ZF0S38"
|
video: "G0_I-ZF0S38"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/77"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Reverse Linked List'
|
label: 'Reverse Linked List'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ topics:
|
|||||||
- { name: "Linked List", slug: "linked-list" }
|
- { name: "Linked List", slug: "linked-list" }
|
||||||
- { name: "Recursion", slug: "recursion" }
|
- { name: "Recursion", slug: "recursion" }
|
||||||
video: "XIdigk956u0"
|
video: "XIdigk956u0"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/78"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Merge Two Sorted Lists'
|
label: 'Merge Two Sorted Lists'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Math", slug: "math" }
|
- { name: "Math", slug: "math" }
|
||||||
- { name: "Matrix", slug: "matrix" }
|
- { name: "Matrix", slug: "matrix" }
|
||||||
video: "fMSJSS7eO1w"
|
video: "fMSJSS7eO1w"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/91"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Rotate Image'
|
label: 'Rotate Image'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Matrix", slug: "matrix" }
|
- { name: "Matrix", slug: "matrix" }
|
||||||
- { name: "Simulation", slug: "simulation" }
|
- { name: "Simulation", slug: "simulation" }
|
||||||
video: "BJnMZNwUk1M"
|
video: "BJnMZNwUk1M"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/92"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Spiral Matrix'
|
label: 'Spiral Matrix'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Hash Table", slug: "hash-table" }
|
- { name: "Hash Table", slug: "hash-table" }
|
||||||
- { name: "Matrix", slug: "matrix" }
|
- { name: "Matrix", slug: "matrix" }
|
||||||
video: "T41rL0L3Pnw"
|
video: "T41rL0L3Pnw"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/93"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Set Matrix Zeroes'
|
label: 'Set Matrix Zeroes'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
+1
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "Binary Search Tree", slug: "binary-search-tree" }
|
- { name: "Binary Search Tree", slug: "binary-search-tree" }
|
||||||
- { name: "Binary Tree", slug: "binary-tree" }
|
- { name: "Binary Tree", slug: "binary-tree" }
|
||||||
video: "5LUXSvjmGCw"
|
video: "5LUXSvjmGCw"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/112"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Kth Smallest Element in a BST'
|
label: 'Kth Smallest Element in a BST'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
+1
@@ -10,6 +10,7 @@ topics:
|
|||||||
- { name: "Binary Lifting", slug: "binary-lifting" }
|
- { name: "Binary Lifting", slug: "binary-lifting" }
|
||||||
- { name: "Lowest Common Ancestor", slug: "lowest-common-ancestor" }
|
- { name: "Lowest Common Ancestor", slug: "lowest-common-ancestor" }
|
||||||
video: "gs2LMfuOR9k"
|
video: "gs2LMfuOR9k"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/113"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Lowest Common Ancestor of a Binary Search Tree'
|
label: 'Lowest Common Ancestor of a Binary Search Tree'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
+1
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "Binary Search Tree", slug: "binary-search-tree" }
|
- { name: "Binary Search Tree", slug: "binary-search-tree" }
|
||||||
- { name: "Binary Tree", slug: "binary-tree" }
|
- { name: "Binary Tree", slug: "binary-tree" }
|
||||||
video: "s6ATEkipzow"
|
video: "s6ATEkipzow"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/111"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Validate Binary Search Tree'
|
label: 'Validate Binary Search Tree'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
+1
@@ -9,6 +9,7 @@ topics:
|
|||||||
- { name: "Heap (Priority Queue)", slug: "heap-priority-queue" }
|
- { name: "Heap (Priority Queue)", slug: "heap-priority-queue" }
|
||||||
- { name: "Quickselect", slug: "quickselect" }
|
- { name: "Quickselect", slug: "quickselect" }
|
||||||
video: "XEmy13g1Qxc"
|
video: "XEmy13g1Qxc"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/118"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Kth Largest Element in an Array'
|
label: 'Kth Largest Element in an Array'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ topics:
|
|||||||
- { name: "Merge Sort", slug: "merge-sort" }
|
- { name: "Merge Sort", slug: "merge-sort" }
|
||||||
- { name: "Tournament Sort", slug: "tournament-sort" }
|
- { name: "Tournament Sort", slug: "tournament-sort" }
|
||||||
video: "q5a5OiGbT6Q"
|
video: "q5a5OiGbT6Q"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/120"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Merge k Sorted Lists'
|
label: 'Merge k Sorted Lists'
|
||||||
badge: 'Hard'
|
badge: 'Hard'
|
||||||
|
|||||||
+1
@@ -12,6 +12,7 @@ topics:
|
|||||||
- { name: "Quickselect", slug: "quickselect" }
|
- { name: "Quickselect", slug: "quickselect" }
|
||||||
- { name: "K-D Tree", slug: "k-d-tree" }
|
- { name: "K-D Tree", slug: "k-d-tree" }
|
||||||
video: "rI2EBUEMfTk"
|
video: "rI2EBUEMfTk"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/119"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'K Closest Points to Origin'
|
label: 'K Closest Points to Origin'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
||||||
- { name: "Binary Tree", slug: "binary-tree" }
|
- { name: "Binary Tree", slug: "binary-tree" }
|
||||||
video: "6ZnyEApgFYg"
|
video: "6ZnyEApgFYg"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/106"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Binary Tree Level Order Traversal'
|
label: 'Binary Tree Level Order Traversal'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
||||||
- { name: "Binary Tree", slug: "binary-tree" }
|
- { name: "Binary Tree", slug: "binary-tree" }
|
||||||
video: "7cp5imvDzl4"
|
video: "7cp5imvDzl4"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/108"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Count Good Nodes in Binary Tree'
|
label: 'Count Good Nodes in Binary Tree'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
||||||
- { name: "Binary Tree", slug: "binary-tree" }
|
- { name: "Binary Tree", slug: "binary-tree" }
|
||||||
video: "d4zLyf32e3I"
|
video: "d4zLyf32e3I"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/107"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Binary Tree Right Side View'
|
label: 'Binary Tree Right Side View'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
||||||
- { name: "Binary Tree", slug: "binary-tree" }
|
- { name: "Binary Tree", slug: "binary-tree" }
|
||||||
video: "hTM3phVI6YQ"
|
video: "hTM3phVI6YQ"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/97"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Maximum Depth of Binary Tree'
|
label: 'Maximum Depth of Binary Tree'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
||||||
- { name: "Binary Tree", slug: "binary-tree" }
|
- { name: "Binary Tree", slug: "binary-tree" }
|
||||||
video: "OnSn2XEQ4MY"
|
video: "OnSn2XEQ4MY"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/96"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Invert Binary Tree'
|
label: 'Invert Binary Tree'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
+1
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "Binary Tree", slug: "binary-tree" }
|
- { name: "Binary Tree", slug: "binary-tree" }
|
||||||
- { name: "Binary Lifting", slug: "binary-lifting" }
|
- { name: "Binary Lifting", slug: "binary-lifting" }
|
||||||
- { name: "Lowest Common Ancestor", slug: "lowest-common-ancestor" }
|
- { name: "Lowest Common Ancestor", slug: "lowest-common-ancestor" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/98"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Lowest Common Ancestor of a Binary Tree'
|
label: 'Lowest Common Ancestor of a Binary Tree'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "Design", slug: "design" }
|
- { name: "Design", slug: "design" }
|
||||||
- { name: "Trie", slug: "trie" }
|
- { name: "Trie", slug: "trie" }
|
||||||
video: "oobqoCJlHA0"
|
video: "oobqoCJlHA0"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/127"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Implement Trie (Prefix Tree)'
|
label: 'Implement Trie (Prefix Tree)'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
+1
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "Design", slug: "design" }
|
- { name: "Design", slug: "design" }
|
||||||
- { name: "Trie", slug: "trie" }
|
- { name: "Trie", slug: "trie" }
|
||||||
video: "BTf05gs_8iU"
|
video: "BTf05gs_8iU"
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/128"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Design Add and Search Words Data Structure'
|
label: 'Design Add and Search Words Data Structure'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "String", slug: "string" }
|
- { name: "String", slug: "string" }
|
||||||
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
||||||
- { name: "Bidirectional Search", slug: "bidirectional-search" }
|
- { name: "Bidirectional Search", slug: "bidirectional-search" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/138"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Word Ladder'
|
label: 'Word Ladder'
|
||||||
badge: 'Hard'
|
badge: 'Hard'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ topics:
|
|||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
||||||
- { name: "Matrix", slug: "matrix" }
|
- { name: "Matrix", slug: "matrix" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/137"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Rotting Oranges'
|
label: 'Rotting Oranges'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Depth-First Search", slug: "depth-first-search" }
|
- { name: "Depth-First Search", slug: "depth-first-search" }
|
||||||
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
||||||
- { name: "Graph Theory", slug: "graph" }
|
- { name: "Graph Theory", slug: "graph" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/131"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Clone Graph'
|
label: 'Clone Graph'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
||||||
- { name: "Union-Find", slug: "union-find" }
|
- { name: "Union-Find", slug: "union-find" }
|
||||||
- { name: "Matrix", slug: "matrix" }
|
- { name: "Matrix", slug: "matrix" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/130"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Number of Islands'
|
label: 'Number of Islands'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Depth-First Search", slug: "depth-first-search" }
|
- { name: "Depth-First Search", slug: "depth-first-search" }
|
||||||
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
||||||
- { name: "Matrix", slug: "matrix" }
|
- { name: "Matrix", slug: "matrix" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/132"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Pacific Atlantic Water Flow'
|
label: 'Pacific Atlantic Water Flow'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "Graph Theory", slug: "graph" }
|
- { name: "Graph Theory", slug: "graph" }
|
||||||
- { name: "Topological Sort", slug: "topological-sort" }
|
- { name: "Topological Sort", slug: "topological-sort" }
|
||||||
- { name: "Directed Acyclic Graph", slug: "directed-acyclic-graph" }
|
- { name: "Directed Acyclic Graph", slug: "directed-acyclic-graph" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/142"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Course Schedule'
|
label: 'Course Schedule'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
||||||
- { name: "Graph Theory", slug: "graph" }
|
- { name: "Graph Theory", slug: "graph" }
|
||||||
- { name: "Topological Sort", slug: "topological-sort" }
|
- { name: "Topological Sort", slug: "topological-sort" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/143"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Course Schedule II'
|
label: 'Course Schedule II'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
+1
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
||||||
- { name: "Union-Find", slug: "union-find" }
|
- { name: "Union-Find", slug: "union-find" }
|
||||||
- { name: "Graph Theory", slug: "graph" }
|
- { name: "Graph Theory", slug: "graph" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/147"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Number of Connected Components in an Undirected Graph'
|
label: 'Number of Connected Components in an Undirected Graph'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ topics:
|
|||||||
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
||||||
- { name: "Union-Find", slug: "union-find" }
|
- { name: "Union-Find", slug: "union-find" }
|
||||||
- { name: "Graph Theory", slug: "graph" }
|
- { name: "Graph Theory", slug: "graph" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/146"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Redundant Connection'
|
label: 'Redundant Connection'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ icon: dot
|
|||||||
topics:
|
topics:
|
||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/158"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'House Robber'
|
label: 'House Robber'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ topics:
|
|||||||
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
- { name: "Breadth-First Search", slug: "breadth-first-search" }
|
||||||
- { name: "Knapsack Problem", slug: "knapsack-problem" }
|
- { name: "Knapsack Problem", slug: "knapsack-problem" }
|
||||||
- { name: "Complete Knapsack", slug: "complete-knapsack" }
|
- { name: "Complete Knapsack", slug: "complete-knapsack" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/159"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Coin Change'
|
label: 'Coin Change'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ topics:
|
|||||||
- { name: "Math", slug: "math" }
|
- { name: "Math", slug: "math" }
|
||||||
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
||||||
- { name: "Memoization", slug: "memoization" }
|
- { name: "Memoization", slug: "memoization" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/157"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Climbing Stairs'
|
label: 'Climbing Stairs'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ icon: dot
|
|||||||
topics:
|
topics:
|
||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Backtracking", slug: "backtracking" }
|
- { name: "Backtracking", slug: "backtracking" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/150"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Combination Sum'
|
label: 'Combination Sum'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ icon: dot
|
|||||||
topics:
|
topics:
|
||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Backtracking", slug: "backtracking" }
|
- { name: "Backtracking", slug: "backtracking" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/151"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Permutations'
|
label: 'Permutations'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ topics:
|
|||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Backtracking", slug: "backtracking" }
|
- { name: "Backtracking", slug: "backtracking" }
|
||||||
- { name: "Bit Manipulation", slug: "bit-manipulation" }
|
- { name: "Bit Manipulation", slug: "bit-manipulation" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/149"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Subsets'
|
label: 'Subsets'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ icon: dot
|
|||||||
topics:
|
topics:
|
||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Greedy", slug: "greedy" }
|
- { name: "Greedy", slug: "greedy" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/176"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Gas Station'
|
label: 'Gas Station'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ topics:
|
|||||||
- { name: "Array", slug: "array" }
|
- { name: "Array", slug: "array" }
|
||||||
- { name: "Divide and Conquer", slug: "divide-and-conquer" }
|
- { name: "Divide and Conquer", slug: "divide-and-conquer" }
|
||||||
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
||||||
|
issue: "https://github.com/prdlk/leetcode/issues/174"
|
||||||
sidebar:
|
sidebar:
|
||||||
label: 'Maximum Subarray'
|
label: 'Maximum Subarray'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
|
|||||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user