mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
Compare commits
232
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66affbe128 | ||
|
|
9de25fecff | ||
|
|
d388b3000e | ||
|
|
f14dcf2723 | ||
|
|
d171fe1565 | ||
|
|
e9d40020f4 | ||
|
|
0b004fcba2 | ||
|
|
33022c806d | ||
|
|
d59b9dccd9 | ||
|
|
17c691c481 | ||
|
|
ea62cda955 | ||
|
|
74939c31d7 | ||
|
|
0d5c40ac4a | ||
|
|
4a1838ab28 | ||
|
|
c2a375fffc | ||
|
|
0e9e06eb8d | ||
|
|
937266b629 | ||
|
|
88c0c9b898 | ||
|
|
099123539e | ||
|
|
8c39b3ac23 | ||
|
|
7ead021ee2 | ||
|
|
0d066b8104 | ||
|
|
9af5ab1fbb | ||
|
|
f770408ea2 | ||
|
|
cf6f09825a | ||
|
|
ab09e40a2e | ||
|
|
91dad73ebf | ||
|
|
400bd5abf5 | ||
|
|
a258b46cf0 | ||
|
|
4875fd5139 | ||
|
|
7b14673e95 | ||
|
|
1c45e03ddd | ||
|
|
cbc858b667 | ||
|
|
c890fd35a7 | ||
|
|
3e62d6fa6f | ||
|
|
017f111594 | ||
|
|
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 | ||
|
|
e1c8f1a0f1 | ||
|
|
57a5917e09 | ||
|
|
f8e90d7f55 | ||
|
|
a39336f7ed | ||
|
|
6396f1fbec | ||
|
|
82fe05b225 | ||
|
|
d7fbe88eb8 | ||
|
|
856ab0667a | ||
|
|
2c574018fb | ||
|
|
1f6413f3d8 | ||
|
|
3b9079f87f | ||
|
|
d5496581df | ||
|
|
e931391200 | ||
|
|
790e256127 | ||
|
|
a8bd4fbcd7 | ||
|
|
a7737502e1 | ||
|
|
2ac54e9930 | ||
|
|
ef91c0d1fe | ||
|
|
910e83fa64 | ||
|
|
3c9feb33fe | ||
|
|
b76b199b17 | ||
|
|
cd9aef1229 | ||
|
|
34b944d32d | ||
|
|
b710c777c3 | ||
|
|
19dedc52ce | ||
|
|
d9dc90e433 | ||
|
|
9c2aaf5969 | ||
|
|
a30e593202 | ||
|
|
d194cedf7a | ||
|
|
5d2d1dff3c | ||
|
|
dde06daea3 | ||
|
|
6e9c407698 | ||
|
|
63f0a81af3 | ||
|
|
5ed33057ed | ||
|
|
f4fc0fd0e7 | ||
|
|
f2a2d2d7de | ||
|
|
fa74adeda7 | ||
|
|
e11c9723f8 | ||
|
|
d2c645bef8 | ||
|
|
2f94b02e15 | ||
|
|
6f1cb93395 | ||
|
|
b310f410a2 | ||
|
|
436ff3080e | ||
|
|
d3f7b1c6f9 | ||
|
|
8a331f4b54 | ||
|
|
bc6eb3e66b | ||
|
|
dd929cca6c | ||
|
|
5987fdce7d | ||
|
|
6879afa61c | ||
|
|
40e965aa06 | ||
|
|
eea8d242e0 | ||
|
|
50b42108eb | ||
|
|
6f375e6c14 | ||
|
|
667bc06965 | ||
|
|
e63d517c9a | ||
|
|
4c297b4524 | ||
|
|
32dadf5928 | ||
|
|
5b6a33088c | ||
|
|
e9f76a32be | ||
|
|
951f958bd9 | ||
|
|
d2888360e2 | ||
|
|
b035d1ffa0 | ||
|
|
0473a3651f | ||
|
|
a9550769f0 | ||
|
|
51c8c771c6 | ||
|
|
7cedd235b5 | ||
|
|
107de04587 | ||
|
|
8787246db7 | ||
|
|
85b54b565e | ||
|
|
8e8522a773 | ||
|
|
6f9e4ecb3b | ||
|
|
5dc2265ee5 | ||
|
|
0ca130156d | ||
|
|
0d64df58f1 | ||
|
|
88d1767184 | ||
|
|
93381f2b69 | ||
|
|
58ab9db1d7 | ||
|
|
e189bb876e | ||
|
|
314f2bff10 | ||
|
|
082b67c44d | ||
|
|
ad7c0a2972 | ||
|
|
931c2c64be | ||
|
|
0a99557aac | ||
|
|
7875b43981 | ||
|
|
f34a9bb56d | ||
|
|
bfeadc0cdf | ||
|
|
ca05c32f6e | ||
|
|
ad129b99fd | ||
|
|
717feb7f70 | ||
|
|
bf0ac34a43 | ||
|
|
403a2d7f23 | ||
|
|
3445c47ad2 | ||
|
|
24a9c52260 | ||
|
|
9d535c6826 | ||
|
|
a2d32c5f06 | ||
|
|
c69582552b | ||
|
|
f0f479d6cf | ||
|
|
ac4dc654fc | ||
|
|
273148c08b | ||
|
|
7960dee431 | ||
|
|
0009075138 | ||
|
|
ca73d3235c | ||
|
|
97b62f95c9 | ||
|
|
1a440e55ce | ||
|
|
fd3b1952b1 | ||
|
|
82c26e2b6c | ||
|
|
f78ecb2bd4 | ||
|
|
b3d3c5fbc9 | ||
|
|
99a9248368 | ||
|
|
2a925f6a9a | ||
|
|
7d57faa958 | ||
|
|
26085cc027 | ||
|
|
216152d946 | ||
|
|
d0ace85bbc | ||
|
|
5b99a6a589 | ||
|
|
8446623607 | ||
|
|
3a1df27d30 | ||
|
|
ae1d1ceede | ||
|
|
f5f31ba1c3 | ||
|
|
d1c99c307e | ||
|
|
021b1fd06e | ||
|
|
edd37ed0e6 | ||
|
|
c2a8a31ede | ||
|
|
891a1f0c5a | ||
|
|
2e5d991d47 | ||
|
|
73d63c2565 | ||
|
|
cafc8a4c25 | ||
|
|
7c5061dcd1 | ||
|
|
f9e69960c4 | ||
|
|
0c12df3eba | ||
|
|
bfb53c14e8 | ||
|
|
2c30b3d017 | ||
|
|
1084410832 | ||
|
|
76c9c77307 | ||
|
|
23eb71d387 | ||
|
|
b8c779903f | ||
|
|
5059eb72cb | ||
|
|
204e178fa4 | ||
|
|
61dc19fb29 | ||
|
|
5c644ca050 | ||
|
|
fb2d4c88c2 | ||
|
|
1fa0731ea7 | ||
|
|
86107d5ef7 | ||
|
|
00f44a1c47 | ||
|
|
24312437eb | ||
|
|
4c9c8f39ae | ||
|
|
88ff63f5c4 | ||
|
|
2066856d15 | ||
|
|
393d72c9fe | ||
|
|
6c18301aa6 | ||
|
|
02f6a20cc1 | ||
|
|
9288aaa152 | ||
|
|
d5ea71dbc1 | ||
|
|
3e1a66eb37 | ||
|
|
9a2b80949b | ||
|
|
883dc69dba | ||
|
|
e3f0b5420a | ||
|
|
987eb7ab5b | ||
|
|
e4118fccd3 | ||
|
|
95db4ca46c | ||
|
|
0cc7ca607e | ||
|
|
71ec39fe34 | ||
|
|
51decd23bd |
No files matched your search
@@ -114,7 +114,12 @@ One issue per curriculum topic; owns the problem sub-issues.
|
||||
```
|
||||
|
||||
`Scheduled` should come from the calendar of record — `apps/api/data/schedule.json`
|
||||
(day → topic issue) — not guessed.
|
||||
(`date → { topics: [issue, …] }`; a doubled day lists two) — not guessed.
|
||||
|
||||
Gate problems (`gate: [...]` in the same file) must exist in the catalog before their
|
||||
Saturday, or the Worker refuses to create the review issue: create each as a
|
||||
`set:optional` problem sub-issue of its natural topic. Being named in the plan keeps it
|
||||
out of the drill pool, so it stays unseen until the gate.
|
||||
|
||||
## Reference tables
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: problem-approach
|
||||
description: Write the `## Approach` section — a Mermaid flowchart that traces the submitted solution — for every docs solution page under docs/ that lacks one, using the repo's exact placement and node conventions. Use when asked to add/implement/fill in missing approaches, draw the approach diagram for a problem page, or when `/problem-approach` is run.
|
||||
---
|
||||
|
||||
# Problem approach diagrams
|
||||
|
||||
Every solution page (`docs/(<phase>)/<topic>/(problems)/<num>_<slug>.mdx`) has a
|
||||
hand-owned section before the script-owned one:
|
||||
|
||||
```
|
||||
frontmatter → ::::warning (maybe) → ### Example N / ### Constraints
|
||||
## Approach ← this skill (Mermaid flowchart of the code below)
|
||||
## Solution ← bun run sync owns this; never edit inside it
|
||||
```
|
||||
|
||||
`sync.ts` regenerates only `## Solution`, so an `## Approach` placed **before** it
|
||||
survives every re-sync; anything placed inside `## Solution` is overwritten on the
|
||||
next `work/` push.
|
||||
|
||||
## The command: fill every missing approach
|
||||
|
||||
1. **Find the pages** (numbered pages with no `## Approach` heading):
|
||||
|
||||
```sh
|
||||
grep -rL '^## Approach' docs --include='[0-9]*_*.mdx'
|
||||
```
|
||||
|
||||
2. **For each page**, read `## Solution` and write the diagram from *that code* — the
|
||||
one on the page, not the textbook algorithm. The reader compares diagram and code
|
||||
line by line; the diagram is a trace, not a lecture.
|
||||
|
||||
3. **Insert** the section between the `### Constraints:` list and `## Solution`,
|
||||
blank-line separated on both sides:
|
||||
|
||||
````md
|
||||
- `-100 <= Node.val <= 100`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
…
|
||||
```
|
||||
|
||||
## Solution
|
||||
````
|
||||
|
||||
4. **Check** each diagram by hand against the syntax rules below (the docs build does
|
||||
not parse Mermaid — a broken diagram fails only in the browser). The site is built
|
||||
from prdlk/cs, which reads `docs/` over HTTP, so preview unpushed pages there
|
||||
against this checkout (its `README` names the local-content switch), then build
|
||||
once at the end:
|
||||
|
||||
```sh
|
||||
cd ../cs && bunx blume build --isolated && rm -rf .blume-verify
|
||||
```
|
||||
|
||||
Pages with two languages (`<CodeGroup>`) get **one** diagram, traced from the
|
||||
Python version. Pages with only JavaScript trace the JavaScript.
|
||||
|
||||
## Diagram conventions
|
||||
|
||||
Reference page: `docs/(01-linear-structures)/two-pointers/(problems)/15_3sum.mdx`. Every existing diagram
|
||||
follows this shape:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["threeSum(nums)"]) --> O["nums.sort() — duplicates become adjacent"]
|
||||
O --> F{"more i in 0..n-1?"}
|
||||
F -- no --> E(["return result"])
|
||||
F -- yes --> D{"nums[i] == nums[i-1]?"}
|
||||
D -- yes --> F
|
||||
D -- no --> P["left = i+1, right = n-1, target = -nums[i]"]
|
||||
P --> W{"left < right?"}
|
||||
W -- no --> F
|
||||
W -- yes --> C["current = nums[left] + nums[right]"]
|
||||
C --> Q{"current vs target"}
|
||||
Q -- equal --> A["append triplet, skip equal neighbours, left += 1, right -= 1"]
|
||||
A --> W
|
||||
Q -- "current < target" --> L["left += 1 — need a bigger sum"]
|
||||
L --> W
|
||||
Q -- "current > target" --> R["right -= 1 — need a smaller sum"]
|
||||
R --> W
|
||||
```
|
||||
|
||||
- `flowchart TD`, two-space indent, one edge per line, edges listed in execution
|
||||
order (entry first, exits as soon as they are reachable).
|
||||
- **Entry** is a stadium holding the signature: `S(["threeSum(nums)"])`. **Every
|
||||
exit** is a stadium: `E(["return result"])`, `R(["return true"])`.
|
||||
- **Statements** are rectangles `["…"]`, in the code's own identifiers and operators
|
||||
(`left += 1`, `freq[n] = (freq[n] or 0) + 1`). Several trivial assignments may share
|
||||
one node, comma-separated.
|
||||
- **Decisions** are diamonds `{"…?"}` with `-- yes -->` / `-- no -->` edges. A
|
||||
three-way branch names the outcomes: `-- equal -->`, `-- "current < target" -->`
|
||||
(quote an edge label whenever it holds `<`, `>`, or spaces around operators).
|
||||
- **Loops** are a decision: `for` → `{"more n in nums?"}`, `while` → the condition
|
||||
verbatim `{"left < right?"}`. `-- no -->` goes to whatever follows the loop; the
|
||||
body's last node edges back to the decision. `continue` is an edge back to the loop
|
||||
decision (`D -- yes --> F` above); `break` is an edge to the node after the loop.
|
||||
- **Recursion** is a statement, not an expanded subgraph: `A["left = invertTree(root.left)"]`.
|
||||
The base case is the first decision after entry.
|
||||
- **Classes** (design problems) use one `subgraph name["method(args)"]` per public
|
||||
method, plus a `state["…"]` subgraph describing the fields, with `state --> method`
|
||||
edges — see `hybrid-structures/(problems)/146_lru-cache.mdx`. Private helpers are named inside the
|
||||
method nodes (`"_remove_node then _add_node — move it beside the tail"`), not drawn.
|
||||
- **Annotate the why with an em dash** inside the node, sparingly (one clause, a few
|
||||
per diagram): `"nums.sort() — duplicates become adjacent"`, `"map = {0: 1} — the
|
||||
empty prefix"`. The dash explains the invariant that makes the step correct — the
|
||||
one thing the code does not say.
|
||||
- **Node ids**: one or two capitals, unique per diagram (`S I L Q C E R W A B`). Class
|
||||
diagrams prefix per method (`G1`, `P1`).
|
||||
|
||||
### Syntax rules (a diagram that breaks the page is worse than none)
|
||||
|
||||
- Wrap **every** label in double quotes — brackets, parentheses, `<`, `>` and `|` in an
|
||||
unquoted label break the parser.
|
||||
- Never put `"` inside a label; use `'` for string literals (`"s == ''?"`).
|
||||
- Never use `|` anywhere — not in node labels, not as `-->|text|` edge syntax; edge
|
||||
labels are always `-- text -->`. Write `or` for a logical or.
|
||||
- Every id an edge references must be declared with a shape the first time it appears.
|
||||
- Balanced: each `subgraph` has an `end`; each fence opens ```` ```mermaid ```` and closes ```` ``` ````.
|
||||
|
||||
### Code that does not run
|
||||
|
||||
Trace it anyway — the diagram documents what was submitted — and put a `::::warning`
|
||||
callout **before** the fence naming exactly why it fails (`sliding-window/(problems)/121_best-time-to-buy-and-sell-stock.mdx`):
|
||||
|
||||
````md
|
||||
## Approach
|
||||
|
||||
::::warning
|
||||
The diagram traces the code below literally. That code does not run: `curr` and `ans` are never initialised, so the first `curr > left` test raises `NameError`.
|
||||
::::
|
||||
|
||||
```mermaid
|
||||
…
|
||||
```
|
||||
````
|
||||
|
||||
Do not fix the code on the page: `## Solution` mirrors `work/1`, and the fix belongs
|
||||
there (then `bun run sync`).
|
||||
|
||||
## Do not
|
||||
|
||||
- Do not write prose approaches, complexity tables or alternative algorithms — the
|
||||
section is one flowchart (plus the warning callout when the code is broken).
|
||||
- Do not touch `## Solution` or anything after it, nor the frontmatter `topics:` /
|
||||
`video:` keys (`bun run sync` / `bun run videos --write` own them).
|
||||
- Do not add an approach to `reference/` pages or `index.mdx` — only numbered
|
||||
solution pages (`title: 'NN. …'`) carry one.
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Report matches without closing anything
|
||||
description: Report matches and would-be releases without closing or releasing anything
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
@@ -61,3 +61,43 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
bun apps/cli/sync-project-fields.ts
|
||||
|
||||
# One timever release per finished topic (every core sub-issue closed) and
|
||||
# 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
|
||||
# push that finishes a topic or review releases it in this run — no waiting
|
||||
# for Close Topics, which chains after this workflow. A problem re-parented
|
||||
# into a Review issue still belongs to its topic (apps/api/src/reviews.ts),
|
||||
# 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:
|
||||
needs: close
|
||||
# Like close-topics: a partially failed close still closed some issues,
|
||||
# and the script only reads state, so run unless the run was cancelled.
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # push tags, create releases
|
||||
issues: read
|
||||
steps:
|
||||
# Full history: each tag targets the last main commit before its topic
|
||||
# finished, and backfilled tags reach far behind HEAD.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
# The tagger of the annotated, backdated tags.
|
||||
- run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# No install step: the script only uses Bun builtins + fetch.
|
||||
- run: bun apps/cli/release-topics.ts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
@@ -1,9 +1,14 @@
|
||||
name: Close Topics
|
||||
|
||||
# No D1 push here, unlike Close Solved: D1 stores no topic completion column.
|
||||
# Phase progress, the gate pools and the charts all derive from the problem
|
||||
# rows, which close-solved already logs — a topic is finished exactly when its
|
||||
# core problems are, so there is nothing left to reconcile.
|
||||
# Closes finished topics AND finished `Review <N> — <Phase>` issues (every
|
||||
# sub-issue closed). Problems moved from a topic into a Review issue still
|
||||
# count toward that topic (apps/cli/topics.ts).
|
||||
#
|
||||
# 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:
|
||||
# Close Solved closes problem issues with GITHUB_TOKEN, and token-driven
|
||||
@@ -19,7 +24,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Report finished topics without closing anything
|
||||
description: Report finished topics and reviews without closing anything
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
@@ -34,7 +39,7 @@ concurrency:
|
||||
jobs:
|
||||
close:
|
||||
# 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')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -5,51 +5,24 @@ on:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# The Worker only. The docs site is built and published from prdlk/cs, which
|
||||
# reads docs/ from this repo over HTTP (Blume's mdxRemote()), so this repo has
|
||||
# no Pages build.
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# The Worker deploy is idempotent (wrangler uploads the whole bundle), so on a
|
||||
# burst of pushes only the last one matters: cancel-in-progress is correct for
|
||||
# both the Pages build and the Worker job.
|
||||
# burst of pushes only the last one matters: cancel-in-progress is correct.
|
||||
concurrency:
|
||||
group: deploy
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22 # Blume requires 22.12+
|
||||
|
||||
- uses: oven-sh/setup-bun@v2 # skip if you use npm/pnpm
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bun run build
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: apps/docs/dist
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
# Deploys on every main push (not path-filtered): apps/api/data/schedule.json
|
||||
# is bundled at deploy time, and re-deploying an unchanged Worker is a no-op.
|
||||
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit) and
|
||||
# CLOUDFLARE_ACCOUNT_ID.
|
||||
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit),
|
||||
# CLOUDFLARE_ACCOUNT_ID, and OPENROUTER_API_KEY (the docs assistant's
|
||||
# POST /ask; the Actions secret is the one copy, mirrored into the Worker).
|
||||
deploy-api:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -62,6 +35,13 @@ jobs:
|
||||
# DST cron guards must hold before the Worker ships.
|
||||
- run: bun run api:test
|
||||
|
||||
- run: printf %s "$OPENROUTER_API_KEY" | bunx wrangler secret put OPENROUTER_API_KEY
|
||||
working-directory: apps/api
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
- run: bun run api:deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
+1
-3
@@ -1,5 +1,6 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
.openlogs
|
||||
tmp/
|
||||
*.log
|
||||
.crush
|
||||
@@ -36,9 +37,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
/CLAUDE.md
|
||||
.blume/
|
||||
/apps/docs/docs/.wrangler/state/v3
|
||||
.blume-verify/
|
||||
|
||||
# api worker
|
||||
apps/api/.dev.vars
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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/`, the docs content under `docs/` (MDX only — the Blume site that publishes it lives in [prdlk/cs](https://github.com/prdlk/cs) and reads `docs/` over HTTP), GitHub Actions that reconcile issues, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
|
||||
|
||||
## Architecture & Data Flow
|
||||
|
||||
@@ -10,10 +10,12 @@ Three owners, one direction of truth:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
|
||||
work[work/ solutions] -->|bun run sync| docs[docs/*.mdx + docs/site.json]
|
||||
docs -->|mdxRemote + site.json fetch| Site[prdlk/cs docs site]
|
||||
work -->|close-solved.yml| PI[problem issues]
|
||||
work -->|close-solved.yml 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 / Review N]
|
||||
GH[GitHub issues = catalog] -->|nightly reconcile| D1
|
||||
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
|
||||
Mail -->|GET /log| D1
|
||||
@@ -33,9 +35,13 @@ flowchart LR
|
||||
- **The picker owns the local review loop; the buckets are its state.** `work/` holds three buckets (`1` = first solve, `3`/`7` = blind re-solves, named after the ladder rungs) and `bun run pick` has three sections — Tab cycles them. Pools are reconciled from the filesystem plus one `git log` (`apps/cli/work.ts`): a first solve dates from the ET date of the commit that added its `work/1` file — the same push that closes the problem issue and starts the +3/+7 windows — and a window stops being owed the moment its bucket holds a file. Uncapped, unlike the review issue's `WINDOW_CAP`: that issue is one day's assignment, the picker is everything still owed. An empty scaffold under `work/1` owes nothing (it is unsolved), which is why `isImplemented()` lives in `apps/cli/source.ts` and is shared with `close-solved.ts`. leetcode-cli reads its output directory from `~/.leetcode/workspaces/<active>/config.json` and nowhere else — no flag, no env var — so `pick.ts` MERGES `workDir` into that file around the child (never replays saved bytes: the CLI owns the file too) and leaves it pointed at `work/1`, then treats the scaffold's existence as the acceptance test, since leetcode-cli exits 0 even when an expired session made it write nothing. The picker itself writes no state: pushing the re-solve is what moves the ladder, via `close-solved` → `/admin/solved` with that file's bucket.
|
||||
- **The README charts are live, never committed.** They are Worker endpoints reading D1 per request (`Cache-Control: max-age=300`, honored by GitHub Camo), so a solved push moves them within ~5 minutes with no commit and no workflow of their own.
|
||||
- **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). `Target Date` is the *only* Project field the Worker mirrors (`apps/api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written. SRS stage and first-attempt result live **only** in D1 — do not re-add them as Project fields. The Project's `Set`/`Difficulty` single-selects are a projection of the issue labels, reconciled by `bun run sync-project-fields` — a best-effort final step of `close-solved.yml`, never the Worker.
|
||||
- **The plan names days; the samplers fill the gaps.** `apps/api/data/schedule.json` maps a date to a `ScheduledDay` (`apps/api/src/srs.ts`): `topics` (one or more topic issues — a doubled day lists two, and the digest renders each with its core list then the optional "if time" list), `problems` (a set day: fixed numbers, rendered like a topic), `gate` (Saturday's blind set, verbatim — `createReviewIssue` refuses to create the issue if any number is not a catalog row, so gate problems must exist as `set:optional` sub-issues of their topic first), or `sweep` (every problem whose last attempt failed, taps to re-log, no drills). Every number a `gate` or `problems` entry names is in `RESERVED`, and the drill/gate samplers never draw it — that is what keeps a planned gate problem unseen until its Saturday. Dates absent from the plan still get the ladder's reviews and drills; a Saturday without a `gate` entry falls back to sampling this week's optional pools.
|
||||
- **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`.
|
||||
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
||||
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first, then the problems closed on the finish day / earlier, with solution links at the tag — problems still open at the finish are left out). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs site's `/changelog` (prdlk/cs `pages/changelog.astro` + `lib/releases.ts`) fetches this repo's releases at its build, dates each by its timever tag, not `published_at` (which the API cannot backdate), tags it `Topic` or `Review` from the release name, and lists the rows of its `### Closed …` tables — so the tag format and that table shape are its cross-repo contract with `render()`.
|
||||
- **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
|
||||
|
||||
@@ -43,9 +49,10 @@ flowchart LR
|
||||
|---|---|
|
||||
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
||||
| `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/`, `islands/`, `public/`). `content/(<category>)/<num>-<slug>.mdx` generated by sync |
|
||||
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (day → topic issue, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
|
||||
| `.github/workflows/` | deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
|
||||
| `docs/` | The docs content, and nothing of the site: no `blume.config.ts`, no `meta.ts`, no components. prdlk/cs publishes it with Blume's `mdxRemote()` source, which fetches `.md`/`.mdx` files only. `docs/(0N-<phase>)/<topic>/(problems)/<num>_<slug>.mdx` is generated by sync; `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is a topic's hand-written landing page, and `index.mdx`/`keywords.mdx`/`progress.mdx` sit at the root. The phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment, and `(problems)` is a second `(group)` inside it, so `tree-dfs/(problems)/226_invert-binary-tree.mdx` routes to `/tree-dfs/invert-binary-tree` (Blume sorts by the `<num>_` prefix and strips it from the route). `docs/site.json` is sync's generated manifest and the site's ONE other input: `folders` (path under `docs/` → `{ title, order, icon?, display? }`, exactly what a `meta.ts` in that folder would say — every topic `display: "page"`, so it is one sidebar row whose panel holds its landing page and a "Problems" section) and `redirects` (the `/<num>` short links). Images are root-absolute (``, `<CarouselImage src='/binary_search.001.png' />`) and served from the site's `public/` — a page-relative path cannot resolve once the MDX is fetched over HTTP. Frontmatter keys beyond Blume's (`topics:`, `video:`, `issue:`) and the MDX components pages use (`<CodeGroup>`, `<Carousel>`/`<CarouselImage>`, the islands) are the site's to declare — add a new one there before a page uses it |
|
||||
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | review | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
|
||||
| `.github/workflows/` | deploy (Worker → Cloudflare with the `OPENROUTER_API_KEY` Actions secret put into it first, on every main push; the docs site deploys from prdlk/cs), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
|
||||
| `.githooks/` | `bun install` points `core.hooksPath` here (root `prepare` script, no husky). Its `pre-commit` hook (run `bun run sync`, stage only what sync changed — pages and `docs/site.json`) was removed in `e63d517`, so nothing runs on commit today: run `bun run sync` yourself before committing a solution, or its page and short link ship a commit later |
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -65,10 +72,14 @@ bun run test # run ONE solution against LeetCode's judge (not a test
|
||||
# most recently tested first, stamped in the db on exit 0)
|
||||
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
|
||||
# hidden; last_submitted stamped in the db on exit 0)
|
||||
bun run sync # work/1 → apps/docs/content/ pages (re-solves are never published)
|
||||
bun run dev|build # Blume docs site (runs in apps/docs/)
|
||||
bun run sync # work/1 → docs/ pages + docs/site.json (folder meta + /<num> short
|
||||
# links; re-solves are never published)
|
||||
bun run videos # docs pages with no `video:` frontmatter → NeetCode video per page
|
||||
# (channel search scraped, no API key; --write adds the
|
||||
# `video:` key; "no NeetCode match" pages stay untouched)
|
||||
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
|
||||
bun run close-topics -- --dry-run
|
||||
bun run release-topics -- --dry-run # prints every would-be topic release body
|
||||
# close-solved also pushes the solved set to the Worker; point it at a local
|
||||
# one and nothing production is touched:
|
||||
SRS_API=http://localhost:8787 SRS_ADMIN_KEY=$LINK_KEY bun run close-solved -- --dry-run
|
||||
@@ -98,24 +109,24 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
||||
## Important Files
|
||||
|
||||
- `apps/api/src/srs.ts` — SRS domain: the ladder (`new → +3 → +7 → retired`; fail resets to `+3`; first-ever log enters at `+3`), `WINDOWS`/`STAGES`/`TEMPERATURES`/`DIFFICULTIES`/`SETS` (the one vocabulary shared by the charts, the `work/<n>` buckets, the review issues and the stats dashboard), ET date math, `workingDay()` (no scheduled date is ever a Sunday), `temperatureOf()` (days since a concept was last exercised → `hot`/`fresh`/`fading`/`cold`, bands derived from `WINDOWS` so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
|
||||
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`.
|
||||
- `apps/api/src/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/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`, and `OPENROUTER_API_KEY`, whose one copy is the repo's Actions secret (deploy.yml puts it before every deploy — rotate it there).
|
||||
- `apps/api/src/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice.
|
||||
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/<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/cli/sync.ts` — work→docs contract: on existing pages only the `## Solution` section itself is script-owned, bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it (`## Approach`, `## Explanation`) are never touched. Never hand-write solution pages or edit inside `## Solution`.
|
||||
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `docs/(0N-<phase>)/<topic>/`, with every problem page in the topic's `(problems)/` group as `<num>_<slug>.mdx` (a page is found by its frontmatter title, wherever it sits, so a layout change is one sync of `rename`s). `docs/site.json` is wholly script-owned and rewritten whenever it differs, because `mdxRemote()` reads no `meta.ts`: its `folders` are what a `meta.ts` per phase and topic folder would carry — a phase `N. Name` at `order: N`, a topic its exact label, curriculum order, `TOPIC_ICONS` icon and `display: "page"` — all derived from the README, so a rename there is a rename on the site; its `redirects` are the `/<num>` → `/<topic>/<slug>` short links. Which tags the site teaches as topics (`topic-landings.ts`) and its Appendix glossary (`appendix.ts`) are the site's, in prdlk/cs: sync no longer checks them. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing through an emptied `(problems)` and topic into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
|
||||
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
|
||||
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, per-topic temperature, gates, the 14-day due queue, and `heat` (every campaign day, zeros and future days included — the docs page windows the tail off it rather than asking a second question). Two readers consume it, the docs island and `bun run stats`, so its shape is both their contract: change all three together. `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
|
||||
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the docs `/progress` page fetches `/api/stats` client-side (`apps/docs/islands/ProgressDashboard.tsx`).
|
||||
- `apps/docs/blume.config.ts` — site base `/leetcode`; `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`).
|
||||
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs site's `/progress` islands (prdlk/cs `lib/stats.ts`) and `bun run stats`, so its shape is a cross-repo contract: change the Worker and both readers together (additive fields a reader ignores are safe). `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
|
||||
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`, `PHASE_ALIASES`). `/api/stats` and `/ask` reflect a requesting origin only when it is in the `DOCS_ORIGIN` var (`apps/api/wrangler.jsonc`, comma-separated: `https://lc.prd.lk,https://prdlk.github.io`) — a new host for the docs site must be added there.
|
||||
- Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
|
||||
|
||||
## Runtime/Tooling Preferences
|
||||
|
||||
- **Bun only**: run scripts with `bun apps/cli/<name>.ts` (or the root `bun run` aliases), install with `bun install --frozen-lockfile`. One workspace (`apps/*`), one root `bun.lock`. Node 22 appears only in `deploy.yml` because Blume requires it.
|
||||
- **Near-zero runtime npm dependencies** — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. The **one** exception is the digest email: `apps/api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. Do not extend this exception to any other module.
|
||||
- **Bun only**: run scripts with `bun apps/cli/<name>.ts` (or the root `bun run` aliases), install with `bun install --frozen-lockfile`. One workspace (`apps/*`), one root `bun.lock`.
|
||||
- **Near-zero runtime npm dependencies** — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. One exception, confined to one surface: the digest email, `apps/api/src/email.tsx`, renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. Do not extend it to any other module. (The docs site's own dependencies are prdlk/cs's.)
|
||||
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
|
||||
|
||||
## Testing & QA
|
||||
|
||||
- Repo-wide: no test framework — `bun run test` submits one solution to LeetCode's judge. Exception: `apps/api/src/srs.test.ts` (`bun:test`) proves the DST cron guards with fixed dates; run via `bun run api:test`.
|
||||
- QA is dry-runs + local state: Worker logic against `wrangler dev` + local D1 with `?dry=1&date=`; reconcilers with `--dry-run`; idempotency check = run twice, `cmp` output.
|
||||
- Docs changes: `bunx blume build --isolated` (from `apps/docs/`) must pass with no new warnings (then `rm -rf apps/docs/.blume-verify`); after sync, review only *created* pages (prose transforms are heuristic).
|
||||
- Docs changes: the site builds in prdlk/cs, which must pass `bunx blume build --isolated` with no new warnings against this repo's `docs/`; after sync, review only *created* pages (prose transforms are heuristic) and `docs/site.json`'s diff.
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/basedprad"><img alt="follow" src="https://shieldcn.dev/x/follow/basedprad.svg" /></a>
|
||||
<a href="https://lc.prad.nu"><img alt="badge" src="https://shieldcn.dev/badge/Read-Documentation.svg?logo=lu%3ABook" /></a>
|
||||
<a href="https://lc.prad.nu/progress"><img alt="gate pass rate" src="https://srs-api.prdlk.workers.dev/badge/gate.svg" /></a>
|
||||
<a href="https://leetcode.com/progress"><img alt="Custom badge" src="https://shieldcn.dev/badge/view-@prdlk.svg?logo=leetcode" /></a>
|
||||
<a href="https://lc.prd.lk"><img alt="badge" src="https://shieldcn.dev/badge/Read-Documentation.svg?logo=lu%3ABook" /></a>
|
||||
</p>
|
||||
|
||||
<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,
|
||||
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>
|
||||
@@ -337,4 +337,3 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">252</td><td><a href="https://leetcode.com/problems/meeting-rooms/">Meeting Rooms I</a></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
GH_PAT=github_pat_or_gho_token_with_issues_and_projects_rw
|
||||
WEBHOOK_SECRET=random_hex_matching_the_repo_webhook
|
||||
LINK_KEY=random_hex_signing_one_tap_links
|
||||
OPENROUTER_API_KEY=sk-or-v1_key_for_the_docs_assistant
|
||||
+13
-3
@@ -14,8 +14,15 @@ Live at `https://srs-api.prdlk.workers.dev`.
|
||||
labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via
|
||||
`/admin/reconcile`); it recomputes from scratch, never invents rows, and
|
||||
never overwrites SRS-owned columns (`stage`, `next_review`).
|
||||
- **The repo owns the schedule** — `data/schedule.json`, human-edited,
|
||||
bundled at deploy. Git history is its audit log.
|
||||
- **The repo owns the plan** — `data/schedule.json`, human-edited,
|
||||
bundled at deploy. Git history is its audit log. Each date is a
|
||||
`ScheduledDay`: `topics` (one or more topic issues), `problems` (a set day,
|
||||
fixed numbers), `review` (a `Review <N> — <Phase>` issue with its problems
|
||||
in solve order; Mon/Tue/Thu/Fri only), `gate` (Saturday's blind set,
|
||||
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
|
||||
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 —
|
||||
@@ -47,7 +54,10 @@ raw `Date` math.
|
||||
Blind drills draw only from topics **already learned**: scheduled in an
|
||||
earlier week (the current week's optional pool is reserved for Saturday's
|
||||
gate) and with at least one core problem on the ladder — a skipped learning
|
||||
day never feeds drills just because its calendar week lapsed.
|
||||
day never feeds drills just because its calendar week lapsed. A Saturday
|
||||
whose plan fixes the `gate` set uses that list as-is; every number must be a
|
||||
catalog row (a `set:optional` sub-issue of its topic), or the review issue is
|
||||
not created — a `/done` on an uncatalogued number could never be logged.
|
||||
|
||||
## Routes
|
||||
|
||||
|
||||
+44
-24
@@ -1,26 +1,46 @@
|
||||
{
|
||||
"2026-08-17": 3,
|
||||
"2026-08-18": 4,
|
||||
"2026-08-24": 5,
|
||||
"2026-08-25": 6,
|
||||
"2026-08-26": 7,
|
||||
"2026-08-27": 8,
|
||||
"2026-08-28": 9,
|
||||
"2026-08-31": 10,
|
||||
"2026-09-01": 11,
|
||||
"2026-09-02": 12,
|
||||
"2026-09-03": 13,
|
||||
"2026-09-04": 14,
|
||||
"2026-09-07": 15,
|
||||
"2026-09-08": 16,
|
||||
"2026-09-09": 17,
|
||||
"2026-09-10": 18,
|
||||
"2026-09-11": 19,
|
||||
"2026-09-14": 20,
|
||||
"2026-09-15": 21,
|
||||
"2026-09-16": 22,
|
||||
"2026-09-17": 23,
|
||||
"2026-09-18": 24,
|
||||
"2026-09-21": 25,
|
||||
"2026-09-22": 26
|
||||
"2026-08-17": { "topics": [3] },
|
||||
"2026-08-18": { "topics": [4] },
|
||||
"2026-08-24": { "topics": [5] },
|
||||
"2026-08-25": { "topics": [6] },
|
||||
"2026-08-26": { "topics": [7] },
|
||||
"2026-08-27": { "topics": [8] },
|
||||
"2026-08-28": { "topics": [9] },
|
||||
"2026-08-31": { "topics": [10] },
|
||||
"2026-09-01": { "topics": [11] },
|
||||
"2026-09-02": { "topics": [12] },
|
||||
"2026-09-03": { "topics": [13] },
|
||||
"2026-09-04": { "topics": [14] },
|
||||
"2026-09-07": { "topics": [15] },
|
||||
"2026-09-08": { "topics": [16] },
|
||||
"2026-09-21": { "topics": [17, 18] },
|
||||
"2026-09-22": { "topics": [19, 20] },
|
||||
"2026-09-23": { "topics": [21] },
|
||||
"2026-09-24": { "topics": [22] },
|
||||
"2026-09-25": { "topics": [23] },
|
||||
"2026-09-26": { "gate": [648, 1091, 802, 547, 90] },
|
||||
"2026-09-28": { "topics": [24] },
|
||||
"2026-09-29": { "topics": [25] },
|
||||
"2026-09-30": { "topics": [26] },
|
||||
"2026-10-01": { "review": { "issue": 225, "title": "Review 1 — Linear Structures", "problems": [205, 974, 209, 34, 42] } },
|
||||
"2026-10-02": { "review": { "issue": 226, "title": "Review 2 — Linear Structures", "problems": [290, 525, 189, 1004, 84] }, "sweep": true },
|
||||
"2026-10-03": { "gate": [] },
|
||||
"2026-10-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] } }
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* POST /ask — the docs assistant's backend.
|
||||
*
|
||||
* The docs are a static GitHub Pages site, and Blume's own assistant route is
|
||||
* server-rendered (it reads the provider key per request), so the site points
|
||||
* `ai.assistant.endpoint` here instead. This speaks the Blume island's wire
|
||||
* contract (`blume/components/islands/hooks.ts`):
|
||||
*
|
||||
* - request: `{ messages: [{ role, content }], page: { path } }` as JSON,
|
||||
* `path` the base-stripped route the reader is on;
|
||||
* - response: the answer as a plain UTF-8 text stream; any non-2xx shows the
|
||||
* island's error notice, and a 503 whose body starts "The assistant is not
|
||||
* configured" shows that body verbatim.
|
||||
*
|
||||
* Validation matches Blume's generated route: 1–40 user/assistant messages of
|
||||
* string content, ≤ 24 000 chars serialized, a 64 KB body, and only role and
|
||||
* content ever reach the model, so the endpoint cannot be repurposed as an
|
||||
* open LLM proxy with a caller's own system prompt.
|
||||
*
|
||||
* Grounding reads what the site already publishes: `llms-full.txt`, every
|
||||
* page's Markdown under `# Title` / `Source: <url>`. The page the reader is on
|
||||
* goes in first, then the best keyword matches, within Blume's default budget
|
||||
* (6 pages, 2 000 chars each, 10 000 total), under Blume's grounded system
|
||||
* prompt — answer only from the excerpts, cite pages as `[Title](/route)`.
|
||||
*
|
||||
* The model call is OpenRouter's OpenAI-compatible chat endpoint over plain
|
||||
* `fetch` (no SDK — see the repo's near-zero-dependency rule), streamed, with
|
||||
* `reasoning.effort: "none"`, for the model the `ASK_MODEL` var names (a
|
||||
* wrangler.jsonc change swaps it); its SSE deltas are re-emitted as bare text. The
|
||||
* reader closing the panel aborts the request, which aborts the model call.
|
||||
*/
|
||||
|
||||
const BODY_LIMIT = 64 * 1024;
|
||||
const MAX_MESSAGES = 40;
|
||||
const MAX_CHARS = 24_000;
|
||||
|
||||
const MAX_RESULTS = 6;
|
||||
const EXCERPT_CHARS = 2000;
|
||||
const CONTEXT_BUDGET = 10_000;
|
||||
/** Lead-in kept before the matched region of a long page. */
|
||||
const EXCERPT_LEAD = 160;
|
||||
/** How long one isolate reuses the parsed corpus (the edge caches the fetch as long). */
|
||||
const CORPUS_TTL_MS = 5 * 60_000;
|
||||
|
||||
const SYSTEM_PROMPT =
|
||||
"You are a helpful documentation assistant for this project, a LeetCode interview-prep campaign: solved problems grouped by topic, with guides to the techniques behind them. Answer the user's question using ONLY the documentation excerpts below. Each excerpt is headed by its page as `## Page Title (/route)`. If the answer is not covered by the excerpts, say you don't know and suggest where in the docs to look — do not invent details. Always cite the pages you drew from, and write every citation as a Markdown link to that page using its route, e.g. [Page Title](/route).";
|
||||
|
||||
/** Words that match everything and rank nothing. */
|
||||
const STOPWORDS = new Set(
|
||||
"a an and are as at be but by can do does for from how i if in is it its me my of on or so that the this to was what when where which who why with you your".split(
|
||||
" ",
|
||||
),
|
||||
);
|
||||
|
||||
interface AskMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
title: string;
|
||||
route: string;
|
||||
body: string;
|
||||
/** Lower-cased title + body, searched per query term. */
|
||||
text: string;
|
||||
titleText: string;
|
||||
}
|
||||
|
||||
// ── corpus ───────────────────────────────────────────────────────
|
||||
|
||||
let corpus: { at: number; sections: Section[] } | undefined;
|
||||
|
||||
/** `llms-full.txt` split into pages, cached per isolate for `CORPUS_TTL_MS`. */
|
||||
async function sections(docsUrl: string): Promise<Section[]> {
|
||||
if (corpus && Date.now() - corpus.at < CORPUS_TTL_MS) return corpus.sections;
|
||||
const res = await fetch(`${docsUrl}/llms-full.txt`, {
|
||||
cf: { cacheTtl: CORPUS_TTL_MS / 1000, cacheEverything: true },
|
||||
});
|
||||
if (!res.ok) throw new Error(`GET llms-full.txt -> ${res.status}`);
|
||||
const full = await res.text();
|
||||
const heads = [...full.matchAll(/^# (.+)\nSource: (\S+)$/gm)];
|
||||
const out = heads.map((head, i): Section => {
|
||||
const start = head.index + head[0].length;
|
||||
const end = heads[i + 1]?.index ?? full.length;
|
||||
const body = full.slice(start, end).replace(/\n-{3,}\s*$/, "").trim();
|
||||
const title = head[1]!.trim();
|
||||
return {
|
||||
title,
|
||||
route: new URL(head[2]!).pathname.replace(/(.)\/+$/, "$1"),
|
||||
body,
|
||||
text: `${title}\n${body}`.toLowerCase(),
|
||||
titleText: title.toLowerCase(),
|
||||
};
|
||||
});
|
||||
corpus = { at: Date.now(), sections: out };
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── retrieval ────────────────────────────────────────────────────
|
||||
|
||||
function terms(text: string): string[] {
|
||||
return [...new Set(text.toLowerCase().match(/[a-z0-9][a-z0-9+#-]*/g) ?? [])].filter(
|
||||
(t) => !STOPWORDS.has(t) && (t.length > 1 || /\d/.test(t)),
|
||||
);
|
||||
}
|
||||
|
||||
function occurrences(haystack: string, needle: string): number {
|
||||
let count = 0;
|
||||
for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + needle.length)) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pages to inject, best first: term frequency damped by log and weighted
|
||||
* by rarity across pages (idf), a title hit counting triple — so "217" or
|
||||
* "sliding window" lands on the page named that rather than every page that
|
||||
* mentions it in passing.
|
||||
*/
|
||||
function rank(all: Section[], query: string[]): Section[] {
|
||||
const idf = new Map(
|
||||
query.map((t) => [t, Math.log(1 + all.length / (1 + all.filter((s) => s.text.includes(t)).length))]),
|
||||
);
|
||||
return all
|
||||
.map((section) => {
|
||||
let score = 0;
|
||||
for (const t of query) {
|
||||
const tf = occurrences(section.text, t);
|
||||
if (tf) score += idf.get(t)! * (Math.log(1 + tf) + (section.titleText.includes(t) ? 3 : 0));
|
||||
}
|
||||
return { section, score };
|
||||
})
|
||||
.filter((r) => r.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, MAX_RESULTS)
|
||||
.map((r) => r.section);
|
||||
}
|
||||
|
||||
/** A page whole when it fits, else `chars` from just before its first query hit. */
|
||||
function excerpt(section: Section, query: string[], chars: number): string {
|
||||
if (section.body.length <= chars) return section.body;
|
||||
const lower = section.body.toLowerCase();
|
||||
const hits = query.map((t) => lower.indexOf(t)).filter((i) => i !== -1);
|
||||
const start = hits.length ? Math.max(0, Math.min(...hits) - EXCERPT_LEAD) : 0;
|
||||
return `${start > 0 ? "…" : ""}${section.body.slice(start, start + chars)}…`;
|
||||
}
|
||||
|
||||
/** The system prompt: the current page, then the ranked pages, within the budget. */
|
||||
async function instructions(env: Env, messages: AskMessage[], path: string | undefined): Promise<string> {
|
||||
const all = await sections(env.DOCS_URL);
|
||||
// A follow-up ("and in Python?") leans on the question before it.
|
||||
const asked = messages.filter((m) => m.role === "user").slice(-2).map((m) => m.content);
|
||||
const query = terms(asked.join(" "));
|
||||
const current = path ? all.find((s) => s.route === path) : undefined;
|
||||
const picked = [...(current ? [current] : []), ...rank(all, query).filter((s) => s !== current)];
|
||||
const parts: string[] = [];
|
||||
let budget = CONTEXT_BUDGET;
|
||||
for (const section of picked) {
|
||||
if (budget < 200) break;
|
||||
const text = excerpt(section, query, Math.min(EXCERPT_CHARS, budget));
|
||||
parts.push(`## ${section.title} (${section.route})\n\n${text}`);
|
||||
budget -= text.length;
|
||||
}
|
||||
return parts.length ? `${SYSTEM_PROMPT}\n\n# Documentation excerpts\n\n${parts.join("\n\n")}` : SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
// ── request handling ─────────────────────────────────────────────
|
||||
|
||||
/** The validated conversation, or a reason it was turned away. */
|
||||
function readMessages(body: unknown): AskMessage[] | undefined {
|
||||
if (!body || typeof body !== "object" || !("messages" in body) || !Array.isArray(body.messages)) return;
|
||||
const raw: unknown[] = body.messages;
|
||||
if (raw.length === 0 || raw.length > MAX_MESSAGES || JSON.stringify(raw).length > MAX_CHARS) return;
|
||||
const messages: AskMessage[] = [];
|
||||
for (const m of raw) {
|
||||
if (!m || typeof m !== "object" || !("role" in m) || !("content" in m)) return;
|
||||
if ((m.role !== "user" && m.role !== "assistant") || typeof m.content !== "string") return;
|
||||
messages.push({ role: m.role, content: m.content });
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
function readPath(body: unknown): string | undefined {
|
||||
if (!body || typeof body !== "object" || !("page" in body)) return;
|
||||
const page = body.page;
|
||||
if (!page || typeof page !== "object" || !("path" in page) || typeof page.path !== "string") return;
|
||||
return page.path.replace(/(.)\/+$/, "$1");
|
||||
}
|
||||
|
||||
/** OpenRouter's SSE (`data: {…choices[0].delta.content…}`) → the answer's bare text. */
|
||||
function textStream(sse: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let buffer = "";
|
||||
return sse.pipeThrough(
|
||||
new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue; // `: OPENROUTER PROCESSING` keep-alives
|
||||
const data = line.slice(6).trim();
|
||||
if (data === "[DONE]") continue;
|
||||
let event: unknown;
|
||||
try {
|
||||
event = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (event && typeof event === "object" && "error" in event) {
|
||||
// After the 200: the stream just ends, and the island shows its
|
||||
// error notice for an empty answer.
|
||||
console.error(`ask: provider error mid-stream: ${JSON.stringify(event.error)}`);
|
||||
continue;
|
||||
}
|
||||
if (!event || typeof event !== "object" || !("choices" in event) || !Array.isArray(event.choices)) continue;
|
||||
const choice: unknown = event.choices[0];
|
||||
if (!choice || typeof choice !== "object" || !("delta" in choice)) continue;
|
||||
const delta = choice.delta;
|
||||
if (!delta || typeof delta !== "object" || !("content" in delta)) continue;
|
||||
const content = delta.content;
|
||||
if (typeof content === "string" && content) controller.enqueue(encoder.encode(content));
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleAsk(request: Request, env: Env): Promise<Response> {
|
||||
// The docs serve from the custom domain and the Pages origin; reflect the
|
||||
// requesting origin only when it is on the allowlist (as /api/stats does).
|
||||
const allowed = env.DOCS_ORIGIN.split(",").map((o) => o.trim());
|
||||
const origin = request.headers.get("origin");
|
||||
const cors = {
|
||||
"access-control-allow-origin": origin && allowed.includes(origin) ? origin : allowed[0]!,
|
||||
"access-control-allow-methods": "POST",
|
||||
"access-control-allow-headers": "content-type",
|
||||
"access-control-max-age": "86400",
|
||||
vary: "Origin",
|
||||
};
|
||||
const text = (body: string, status: number) =>
|
||||
new Response(body, { status, headers: { ...cors, "content-type": "text/plain; charset=utf-8" } });
|
||||
|
||||
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
|
||||
if (request.method !== "POST") return text("POST only.", 405);
|
||||
|
||||
const raw = await request.arrayBuffer();
|
||||
if (raw.byteLength > BODY_LIMIT) return text("Request too large: the body must be at most 64 KB.", 413);
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(new TextDecoder().decode(raw));
|
||||
} catch {
|
||||
body = undefined;
|
||||
}
|
||||
const messages = readMessages(body);
|
||||
if (!messages) return text("Invalid request: send 1-40 user/assistant messages with string content.", 400);
|
||||
if (!env.OPENROUTER_API_KEY) return text("The assistant is not configured: set OPENROUTER_API_KEY.", 503);
|
||||
|
||||
let system: string;
|
||||
try {
|
||||
system = await instructions(env, messages, readPath(body));
|
||||
} catch (err) {
|
||||
// Ungrounded beats unanswered: the prompt still forbids inventing details.
|
||||
console.warn(`ask: grounding unavailable: ${err}`);
|
||||
system = SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${env.OPENROUTER_API_KEY}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: env.ASK_MODEL,
|
||||
stream: true,
|
||||
reasoning: { effort: "none" },
|
||||
messages: [{ role: "system", content: system }, ...messages],
|
||||
}),
|
||||
signal: request.signal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
console.error(`ask: POST /chat/completions -> ${res.status} ${await res.text().catch(() => "")}`);
|
||||
return text("Failed to generate a response.", res.status === 429 ? 429 : 502);
|
||||
}
|
||||
return new Response(textStream(res.body), {
|
||||
headers: { ...cors, "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
|
||||
});
|
||||
}
|
||||
+53
-7
@@ -9,9 +9,16 @@
|
||||
* 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.
|
||||
* 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 { movedFromTopics, readReviews, type Review } from "./reviews.ts";
|
||||
|
||||
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
||||
|
||||
@@ -21,6 +28,37 @@ const DEFER_FROM = "2026-09-28";
|
||||
export interface ReconcileReport {
|
||||
topics: 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. */
|
||||
@@ -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 deferredSeen = 0;
|
||||
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`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||
const 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
|
||||
const m = raw.title.match(TITLE_RE);
|
||||
const m = title.match(TITLE_RE);
|
||||
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;
|
||||
}
|
||||
const difficulty = DIFFICULTIES.find((d) => labels.includes(`diff:${d}`));
|
||||
const set = SETS.find((s) => labels.includes(`set:${s}`));
|
||||
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;
|
||||
}
|
||||
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
|
||||
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++;
|
||||
}
|
||||
@@ -116,5 +162,5 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
||||
for (let i = 0; i < statements.length; i += 50) {
|
||||
await db.batch(statements.slice(i, i + 50));
|
||||
}
|
||||
return { topics: topics.length, problems };
|
||||
return { topics: topics.length, problems, reviewDrift: drift };
|
||||
}
|
||||
+82
-23
@@ -5,14 +5,17 @@
|
||||
* the HTML and the plain-text alternative from that single tree (React Email
|
||||
* `render`), so the two can never drift.
|
||||
*
|
||||
* Retrieval rules: review and drill rows carry number + difficulty only —
|
||||
* never the topic, never a solution link. `DigestRow` has no field for
|
||||
* either, so the rule holds by construction. The learning day's core list is
|
||||
* the only labeled section. At most REVIEW_CAP (3) reviews per day; drills
|
||||
* fill toward DAILY_CAP (6) only when the due queue is light. Overflow is
|
||||
* levelled, not stacked: sendDigest gives every review past the cap a
|
||||
* concrete future date, ≤ REVIEW_CAP per day, oldest first. Sunday is a
|
||||
* two-line rest note.
|
||||
* Retrieval rules: review, drill and sweep rows carry number + difficulty
|
||||
* only — never the topic, never a solution link. `DigestRow` has no field for
|
||||
* either, so the rule holds by construction. The plan's own sections — a
|
||||
* learning day's topics (core, then the optional "if time" list), a set
|
||||
* day's problems, and a review day's `Review <N>` issue — are the only
|
||||
* labeled, linked ones. At most REVIEW_CAP (3)
|
||||
* 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
|
||||
* nothing new. Overflow is levelled, not stacked: sendDigest gives every
|
||||
* review past the cap a concrete future date, ≤ REVIEW_CAP per day, oldest
|
||||
* first. Sunday is a two-line rest note.
|
||||
*
|
||||
* Idempotency: email_log keys sends by ET date — a same-day re-send is a
|
||||
* no-op unless forced; the body itself is deterministic (drill picks are
|
||||
@@ -21,21 +24,25 @@
|
||||
*/
|
||||
import {
|
||||
CAMPAIGN_DAYS,
|
||||
MISS_WHERE,
|
||||
type ProblemRow,
|
||||
RESERVED,
|
||||
REVIEW_CAP,
|
||||
SCHEDULE,
|
||||
type ScheduledDay,
|
||||
addDays,
|
||||
campaignDay,
|
||||
campaignWeek,
|
||||
dueReviews,
|
||||
isoWeek,
|
||||
levelReviews,
|
||||
lookupProblems,
|
||||
pickDrills,
|
||||
prettyDate,
|
||||
streak,
|
||||
weekdayOf,
|
||||
} from "./srs.ts";
|
||||
import { type DigestData, type DigestRow, renderDigest } from "./email.tsx";
|
||||
import { type DigestData, type DigestRow, type TopicRow, renderDigest } from "./email.tsx";
|
||||
import { signLink } from "./links.ts";
|
||||
|
||||
/** Total daily ceiling: drills only fill what the whole due queue leaves. */
|
||||
@@ -63,23 +70,49 @@ async function tapRow(env: Env, p: ProblemRow, date: string, staged: boolean): P
|
||||
};
|
||||
}
|
||||
|
||||
/** A plan-named problem: linked to its issue, ticked once it has entered the ladder. */
|
||||
function topicRow(env: Env, p: ProblemRow): TopicRow {
|
||||
return {
|
||||
lc: p.lc_number,
|
||||
difficulty: p.difficulty,
|
||||
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
|
||||
solved: p.stage !== "new",
|
||||
};
|
||||
}
|
||||
|
||||
/** Problems whose most recent attempt failed — the miss log a sweep day works through. */
|
||||
async function openMisses(db: D1Database): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT p.* FROM problems p
|
||||
JOIN attempts a ON a.lc_number = p.lc_number
|
||||
WHERE ${MISS_WHERE}
|
||||
ORDER BY a.date, p.lc_number`,
|
||||
)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Everything the email needs, read straight out of D1 and the schedule. */
|
||||
export async function collectDigest(env: Env, date: string): Promise<DigestData> {
|
||||
const db = env.DB;
|
||||
const rest = weekdayOf(date) === 0;
|
||||
const plan: ScheduledDay = rest ? {} : (SCHEDULE[date] ?? {});
|
||||
|
||||
// Reviews surface at most REVIEW_CAP; drills fill toward DAILY_CAP only
|
||||
// when the WHOLE due queue is light — a heavy backlog must never trade
|
||||
// review slots for brand-new drill problems.
|
||||
// review slots for brand-new drill problems — and never on a sweep day.
|
||||
const due = rest ? [] : await dueReviews(db, date);
|
||||
const capped = due.slice(0, REVIEW_CAP);
|
||||
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
|
||||
const drills =
|
||||
rest || plan.sweep ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
|
||||
|
||||
const data: DigestData = {
|
||||
day: prettyDate(date),
|
||||
progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`,
|
||||
streak: await streak(db, date),
|
||||
rest,
|
||||
topics: [],
|
||||
reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))),
|
||||
carried: due.length - capped.length,
|
||||
drills: await Promise.all(drills.map((p) => tapRow(env, p, date, false))),
|
||||
@@ -88,30 +121,56 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
|
||||
progressUrl: `${env.DOCS_URL}/progress`,
|
||||
};
|
||||
|
||||
// New topic — the only section allowed to name problems and link them.
|
||||
const topicIssue = rest ? undefined : SCHEDULE[date];
|
||||
if (topicIssue !== undefined) {
|
||||
// New topics — the plan's sections, the only ones allowed to name problems
|
||||
// and link them. Core first; the optional set is "if time", minus anything
|
||||
// the plan has reserved for a gate or a set day.
|
||||
for (const topicIssue of plan.topics ?? []) {
|
||||
const topic = await db
|
||||
.prepare("SELECT name FROM topics WHERE issue = ?")
|
||||
.bind(topicIssue)
|
||||
.first<{ name: string }>();
|
||||
const { results: core } = await db
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
"SELECT * FROM problems WHERE topic_issue = ? AND set_label = 'core' ORDER BY lc_number",
|
||||
"SELECT * FROM problems WHERE topic_issue = ? AND set_label IN ('core', 'optional') ORDER BY lc_number",
|
||||
)
|
||||
.bind(topicIssue)
|
||||
.all<ProblemRow>();
|
||||
data.topic = {
|
||||
data.topics.push({
|
||||
name: topic?.name ?? `#${topicIssue}`,
|
||||
core: core.map((p) => ({
|
||||
lc: p.lc_number,
|
||||
difficulty: p.difficulty,
|
||||
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
|
||||
solved: p.stage !== "new",
|
||||
})),
|
||||
core: results.filter((p) => p.set_label === "core").map((p) => topicRow(env, p)),
|
||||
ifTime: results
|
||||
.filter((p) => p.set_label === "optional" && !RESERVED.has(p.lc_number))
|
||||
.map((p) => topicRow(env, p)),
|
||||
});
|
||||
}
|
||||
|
||||
// A set day assigns problems by number rather than by topic.
|
||||
if (plan.problems) {
|
||||
const { rows, missing } = await lookupProblems(db, plan.problems);
|
||||
if (missing.length) console.warn(`digest ${date}: planned problems not in the catalog: ${missing.join(", ")}`);
|
||||
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
|
||||
// failure, minus the ones today's review list already carries.
|
||||
if (plan.sweep) {
|
||||
const reviewed = new Set(capped.map((p) => p.lc_number));
|
||||
const misses = (await openMisses(db)).filter((p) => !reviewed.has(p.lc_number));
|
||||
data.sweep = await Promise.all(misses.map((p) => tapRow(env, p, date, true)));
|
||||
}
|
||||
|
||||
// Saturday: the review issue already exists (created at midnight ET).
|
||||
if (weekdayOf(date) === 6) {
|
||||
const gate = await db
|
||||
|
||||
+121
-20
@@ -71,6 +71,13 @@ const LAST_SEEN: Record<string, string> = {
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
/** A review or drill line: number + difficulty + one-tap links, nothing else. */
|
||||
@@ -83,7 +90,7 @@ export interface DigestRow {
|
||||
failUrl: string;
|
||||
}
|
||||
|
||||
/** A learning-day core problem — the only rows allowed a link. */
|
||||
/** A plan-named problem — the only rows allowed a link. */
|
||||
export interface TopicRow {
|
||||
lc: number;
|
||||
difficulty: string;
|
||||
@@ -91,18 +98,32 @@ export interface TopicRow {
|
||||
solved: boolean;
|
||||
}
|
||||
|
||||
/** One learning-day topic: the core list, then the optional "if time" list. */
|
||||
export interface TopicSection {
|
||||
name: string;
|
||||
core: TopicRow[];
|
||||
ifTime: TopicRow[];
|
||||
}
|
||||
|
||||
export interface DigestData {
|
||||
/** "Tuesday, August 25" */
|
||||
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;
|
||||
streak: number;
|
||||
rest: boolean;
|
||||
topic?: { name: string; core: TopicRow[] };
|
||||
/** Topics taught today, in plan order — empty on every other day. */
|
||||
topics: TopicSection[];
|
||||
/** A set day's problems, assigned by number rather than by topic. */
|
||||
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 past the daily cap; levelled onto the coming days. */
|
||||
carried: number;
|
||||
drills: DigestRow[];
|
||||
/** A sweep day's miss log: present (even empty) only on a sweep day. */
|
||||
sweep?: DigestRow[];
|
||||
gate?: { week: number; url: string };
|
||||
yesterday: { total: number; failed: number[] };
|
||||
gateRate?: number;
|
||||
@@ -278,10 +299,17 @@ function Block({
|
||||
// ── email ────────────────────────────────────────────────────────
|
||||
|
||||
function DigestEmail({ data }: { data: DigestData }) {
|
||||
const load = data.reviews.length + data.drills.length;
|
||||
const preview = data.rest
|
||||
? "Rest day — nothing to do but rest."
|
||||
: `${load} to work through today${data.topic ? `, starting with ${data.topic.name}` : ""}.`;
|
||||
const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
|
||||
const lead = data.topics[0]
|
||||
? `, starting with ${data.topics[0].name}`
|
||||
: data.review
|
||||
? `, starting with ${data.review.title}`
|
||||
: data.assigned
|
||||
? ", 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 passed = data.yesterday.total - data.yesterday.failed.length;
|
||||
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
|
||||
@@ -310,15 +338,60 @@ function DigestEmail({ data }: { data: DigestData }) {
|
||||
|
||||
{data.rest ? null : (
|
||||
<>
|
||||
{data.topic ? (
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Something new today</Text>
|
||||
{data.topics.map((topic, i) => (
|
||||
<Section key={topic.name} style={{ marginTop: i === 0 ? "28px" : "20px" }}>
|
||||
{i === 0 ? (
|
||||
<Text style={label}>
|
||||
{data.topics.length === 1 ? "Something new today" : "Two new things today"}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
||||
{data.topic.name}
|
||||
{topic.name}
|
||||
</Text>
|
||||
<Text style={hint}>Work through these from the top — a tick means it is already solved.</Text>
|
||||
<TopicTable rows={data.topic.core} />
|
||||
<TopicTable rows={topic.core} />
|
||||
{topic.ifTime.length > 0 ? (
|
||||
<>
|
||||
<Text style={{ ...hint, margin: "12px 0 8px" }}>
|
||||
If time — only once the core is done inside the clock.
|
||||
</Text>
|
||||
<TopicTable rows={topic.ifTime} />
|
||||
</>
|
||||
) : null}
|
||||
</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 ? (
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Today's set</Text>
|
||||
<Text style={hint}>
|
||||
Picked by number, not by topic — all of them, in order, inside the clock.
|
||||
</Text>
|
||||
<TopicTable rows={data.assigned} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{data.sweep ? (
|
||||
<Block
|
||||
title="Sweep — redo every red"
|
||||
note="Every problem still standing on a miss. Nothing new today; clear these first."
|
||||
rows={data.sweep}
|
||||
staged
|
||||
empty="The miss log is clear — nothing is standing on a fail."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Block
|
||||
@@ -358,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. */}
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Every day you showed up</Text>
|
||||
<Link href={data.progressUrl}>
|
||||
<Img
|
||||
src={data.heatmapUrl}
|
||||
alt="Attempt heatmap across the eight-week campaign"
|
||||
alt="Attempt heatmap across the whole campaign"
|
||||
width="552"
|
||||
style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }}
|
||||
/>
|
||||
@@ -409,16 +482,36 @@ function plainDigest(data: DigestData): string {
|
||||
: ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`),
|
||||
];
|
||||
|
||||
const linked = (p: TopicRow) =>
|
||||
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`;
|
||||
|
||||
if (!data.rest) {
|
||||
if (data.topic) {
|
||||
for (const topic of data.topics) {
|
||||
out.push(
|
||||
"",
|
||||
`SOMETHING NEW TODAY — ${data.topic.name}`,
|
||||
`SOMETHING NEW TODAY — ${topic.name}`,
|
||||
"Work through these from the top; the ones marked done you have already solved.",
|
||||
...data.topic.core.map(
|
||||
(p) =>
|
||||
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`,
|
||||
),
|
||||
...topic.core.map(linked),
|
||||
);
|
||||
if (topic.ifTime.length > 0) {
|
||||
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) {
|
||||
out.push(
|
||||
"",
|
||||
"TODAY'S SET",
|
||||
"Picked by number, not by topic — all of them, in order, inside the clock.",
|
||||
...data.assigned.map(linked),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -436,6 +529,14 @@ function plainDigest(data: DigestData): string {
|
||||
"No cold starts today.",
|
||||
],
|
||||
];
|
||||
if (data.sweep) {
|
||||
lists.unshift([
|
||||
"SWEEP — REDO EVERY RED",
|
||||
"Every problem still standing on a miss. Nothing new today; clear these first.",
|
||||
data.sweep,
|
||||
"The miss log is clear — nothing is standing on a fail.",
|
||||
]);
|
||||
}
|
||||
for (const [title, note, rows, empty] of lists) {
|
||||
out.push("", title);
|
||||
if (rows.length === 0) {
|
||||
|
||||
+33
-13
@@ -2,21 +2,28 @@
|
||||
* Saturday review issue: created at midnight ET so the 8 AM digest can link
|
||||
* to it; scored when the issue closes (webhook).
|
||||
*
|
||||
* The gate half is a topic-blind quiz — one unsolved optional problem per
|
||||
* topic scheduled this week (which is why daily drills only draw from
|
||||
* earlier weeks). The recap half is generated data, deliberately NOT a task
|
||||
* list: the week's solved problems are already scheduled by the +3/+7 ladder,
|
||||
* and re-assigning them on Saturday would be massed practice.
|
||||
* 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
|
||||
* number must be a catalog problem, or the issue is not created, because a
|
||||
* `/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
|
||||
* (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
|
||||
* task list: the week's solved problems are already scheduled by the +3/+7
|
||||
* ladder, and re-assigning them on Saturday would be massed practice.
|
||||
*
|
||||
* Label is `review` (the retired Actions system owned `gate`). Creation is
|
||||
* idempotent by week; deferred Hards never appear.
|
||||
* idempotent by week; deferred Hards never appear in a sampled set.
|
||||
*/
|
||||
import {
|
||||
type ProblemRow,
|
||||
RESERVED,
|
||||
type Result,
|
||||
SCHEDULE,
|
||||
campaignWeek,
|
||||
isoWeek,
|
||||
lookupProblems,
|
||||
rng,
|
||||
sample,
|
||||
streak,
|
||||
@@ -32,7 +39,7 @@ function capitalized(difficulty: string): string {
|
||||
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
|
||||
}
|
||||
|
||||
/** Unsolved problems of a topic in a set — never attempted, never drilled. */
|
||||
/** Unsolved problems of a topic in a set — never attempted, drilled, or reserved. */
|
||||
async function freshPool(db: D1Database, topic: number, set: string): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
@@ -44,7 +51,7 @@ async function freshPool(db: D1Database, topic: number, set: string): Promise<Pr
|
||||
)
|
||||
.bind(topic, set)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
return results.filter((p) => !RESERVED.has(p.lc_number));
|
||||
}
|
||||
|
||||
export interface CreateReport {
|
||||
@@ -67,12 +74,25 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
||||
return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` };
|
||||
}
|
||||
|
||||
// Topics scheduled this week; review-only weeks (Sep 23 onward) sample
|
||||
// across every topic that still has unsolved optional problems.
|
||||
// The plan's fixed set wins; otherwise sample this week's topics, and in a
|
||||
// week without topics, across every topic that still has an unsolved pool.
|
||||
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
||||
const planned = SCHEDULE[date]?.gate;
|
||||
if (planned?.length === 0) return { created: false, reason: `the plan skips the week ${week} checkpoint` };
|
||||
if (planned) {
|
||||
const { rows, missing } = await lookupProblems(db, planned);
|
||||
if (missing.length) {
|
||||
return {
|
||||
created: false,
|
||||
reason: `planned gate names problems outside the catalog: LC ${missing.join(", LC ")}`,
|
||||
};
|
||||
}
|
||||
picks.push(...rows.map((p) => ({ p, fallback: false })));
|
||||
} else {
|
||||
let topics = Object.entries(SCHEDULE)
|
||||
.filter(([d]) => campaignWeek(d) === week)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([, t]) => t);
|
||||
.flatMap(([, day]) => day.topics ?? []);
|
||||
if (topics.length === 0) {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
@@ -89,7 +109,6 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
||||
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
|
||||
|
||||
const random = rng(`gate-${iso}`);
|
||||
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
||||
for (const topic of topics) {
|
||||
const optional = await freshPool(db, topic, "optional");
|
||||
if (optional.length > 0) {
|
||||
@@ -101,6 +120,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
||||
// Both pools exhausted: the topic is fully solved; nothing to quiz.
|
||||
}
|
||||
if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` };
|
||||
}
|
||||
|
||||
// Recap: everything attempted this week, plus streak — data, not tasks.
|
||||
const monday = weekMonday(date);
|
||||
@@ -154,7 +174,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
||||
|
||||
const milestone = await db
|
||||
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
|
||||
.bind(topics[0])
|
||||
.bind(picks[0]!.p.topic_issue)
|
||||
.first<{ milestone: number }>();
|
||||
|
||||
const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* Writes are owner-only (webhook author check, HMAC one-tap links, bearer
|
||||
* admin routes); everything else is read-only public.
|
||||
*/
|
||||
import { handleAsk } from "./ask.ts";
|
||||
import { reconcileCatalog } from "./catalog.ts";
|
||||
import { gateBadge, heatmapChart, heatmapPng, ladderChart, progressChart } from "./charts.ts";
|
||||
import { sendDigest } from "./digest.ts";
|
||||
@@ -17,6 +18,7 @@ import { type GitHub, github } from "./github.ts";
|
||||
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
|
||||
import { type Mirror, projectMirror } from "./mirror.ts";
|
||||
import {
|
||||
CAMPAIGN_DAYS,
|
||||
CAMPAIGN_START,
|
||||
type LogOutcome,
|
||||
type Stage,
|
||||
@@ -322,6 +324,8 @@ export default {
|
||||
if (path.startsWith("/admin/") && request.method === "POST") {
|
||||
return await handleAdmin(request, env, path);
|
||||
}
|
||||
// The docs assistant (the static Pages site's `ai.assistant.endpoint`).
|
||||
if (path === "/ask") return await handleAsk(request, env);
|
||||
|
||||
if (path === "/chart/progress.svg") {
|
||||
return new Response(await progressChart(env.DB), { headers: SVG_HEADERS });
|
||||
@@ -330,10 +334,10 @@ export default {
|
||||
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
|
||||
}
|
||||
if (path === "/chart/heatmap.svg") {
|
||||
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, 56), { headers: SVG_HEADERS });
|
||||
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, CAMPAIGN_DAYS), { headers: SVG_HEADERS });
|
||||
}
|
||||
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") {
|
||||
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;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
INTERVAL,
|
||||
RESERVED,
|
||||
SCHEDULE,
|
||||
STAGES,
|
||||
WINDOWS,
|
||||
addDays,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
sample,
|
||||
TEMPERATURES,
|
||||
temperatureOf,
|
||||
topicDate,
|
||||
weekdayOf,
|
||||
workingDay,
|
||||
} from "./srs.ts";
|
||||
@@ -125,6 +128,54 @@ describe("the rest day", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the plan", () => {
|
||||
test("never books the rest day, and gates only on Saturday", () => {
|
||||
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||
expect(isRestDay(date)).toBe(false);
|
||||
if (day.gate) expect(weekdayOf(date)).toBe(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));
|
||||
}
|
||||
});
|
||||
|
||||
test("a topic is taught once; a doubled day answers for both", () => {
|
||||
const seen = new Set<number>();
|
||||
for (const day of Object.values(SCHEDULE)) {
|
||||
for (const topic of day.topics ?? []) {
|
||||
expect(seen.has(topic)).toBe(false);
|
||||
seen.add(topic);
|
||||
}
|
||||
}
|
||||
expect(topicDate(17)).toBe("2026-09-21");
|
||||
expect(topicDate(18)).toBe("2026-09-21");
|
||||
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", () => {
|
||||
for (const day of Object.values(SCHEDULE)) {
|
||||
for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) {
|
||||
expect(RESERVED.has(lc)).toBe(true);
|
||||
}
|
||||
}
|
||||
expect(RESERVED.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ladder", () => {
|
||||
test("two rungs, named after the windows", () => {
|
||||
expect(STAGES).toEqual(["new", "+3", "+7", "retired"]);
|
||||
|
||||
+94
-6
@@ -22,8 +22,8 @@ import scheduleJson from "../data/schedule.json";
|
||||
|
||||
// ── 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_DAYS = 56;
|
||||
|
||||
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "America/New_York",
|
||||
@@ -126,12 +126,50 @@ export function prettyDate(date: string): string {
|
||||
|
||||
// ── the schedule (bundled; humans edit apps/api/data/schedule.json) ───
|
||||
|
||||
export const SCHEDULE: Record<string, number> = scheduleJson;
|
||||
/**
|
||||
* 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
|
||||
* gate set for Saturday's review issue, a fixed problem set (a "B-set" day),
|
||||
* a transfer-phase review day, or a sweep — redo every miss, nothing new.
|
||||
* Absent dates are unplanned: the ladder still runs, the digest just has
|
||||
* nothing new to say.
|
||||
*/
|
||||
export interface ScheduledDay {
|
||||
/** Topic issues taught that day, in the order the digest presents them. */
|
||||
topics?: number[];
|
||||
/** Problems assigned by number instead of by topic. */
|
||||
problems?: number[];
|
||||
/**
|
||||
* 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[];
|
||||
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
|
||||
sweep?: boolean;
|
||||
}
|
||||
|
||||
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. */
|
||||
export function topicDate(topic: number): string | undefined {
|
||||
for (const [date, t] of Object.entries(SCHEDULE)) {
|
||||
if (t === topic) return date;
|
||||
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||
if (day.topics?.includes(topic)) return date;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -142,6 +180,20 @@ export function topicWeek(topic: number): number | undefined {
|
||||
return date === undefined ? undefined : campaignWeek(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every problem the plan names outright — a gate's blind set, a set day's
|
||||
* list, or a review day's. The plan owns these: the samplers (daily drills,
|
||||
* a sampled gate) never draw them, so a gate problem stays unseen until its
|
||||
* Saturday and a set or review day's problems wait for their day.
|
||||
*/
|
||||
export const RESERVED: ReadonlySet<number> = new Set(
|
||||
Object.values(SCHEDULE).flatMap((day) => [
|
||||
...(day.gate ?? []),
|
||||
...(day.problems ?? []),
|
||||
...(day.review?.problems ?? []),
|
||||
]),
|
||||
);
|
||||
|
||||
// ── deterministic sampling ───────────────────────────────────────
|
||||
|
||||
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
|
||||
@@ -248,6 +300,31 @@ export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow
|
||||
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog rows for a list of numbers the plan names, in the plan's order,
|
||||
* plus the numbers the catalog does not know — a planned problem without an
|
||||
* issue can never be logged, so callers decide whether that is fatal.
|
||||
*/
|
||||
export async function lookupProblems(
|
||||
db: D1Database,
|
||||
lcs: number[],
|
||||
): Promise<{ rows: ProblemRow[]; missing: number[] }> {
|
||||
if (lcs.length === 0) return { rows: [], missing: [] };
|
||||
const { results } = await db
|
||||
.prepare(`SELECT * FROM problems WHERE lc_number IN (${lcs.map(() => "?").join(",")})`)
|
||||
.bind(...lcs)
|
||||
.all<ProblemRow>();
|
||||
const byNumber = new Map(results.map((p) => [p.lc_number, p]));
|
||||
const rows: ProblemRow[] = [];
|
||||
const missing: number[] = [];
|
||||
for (const lc of lcs) {
|
||||
const row = byNumber.get(lc);
|
||||
if (row) rows.push(row);
|
||||
else missing.push(lc);
|
||||
}
|
||||
return { rows, missing };
|
||||
}
|
||||
|
||||
/**
|
||||
* What "due" means, in one place: not retired, scheduled on/before ?1, and
|
||||
* past its deferral if it has one. Every caller that asks "what is open on
|
||||
@@ -257,6 +334,15 @@ export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow
|
||||
export const DUE_WHERE = `stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||
AND (defer_until IS NULL OR defer_until <= ?1)`;
|
||||
|
||||
/**
|
||||
* What an open miss means, in one place: a problem `p` whose most recent
|
||||
* attempt (highest id) failed and that has not retired. The sweep day works
|
||||
* these and the stats' "attempting" count is how many there are, so the two
|
||||
* cannot disagree. Expects `problems p` joined to `attempts a` on lc_number.
|
||||
*/
|
||||
export const MISS_WHERE = `a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number)
|
||||
AND a.result = 'fail' AND p.stage != 'retired'`;
|
||||
|
||||
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
||||
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
@@ -358,8 +444,9 @@ export async function levelReviews(db: D1Database, date: string): Promise<number
|
||||
* scheduled in an earlier week (the current week's optional pool is reserved
|
||||
* for Saturday's gate) AND showing learning evidence: at least one of the
|
||||
* topic's core problems has entered the ladder. A skipped learning day never
|
||||
* feeds drills just because its calendar week lapsed. Never repeated,
|
||||
* seeded by date. Boosted topics contribute up to 2 extra.
|
||||
* feeds drills just because its calendar week lapsed. Problems the plan names
|
||||
* outright (RESERVED) are never drawn. Never repeated, seeded by date.
|
||||
* Boosted topics contribute up to 2 extra.
|
||||
*/
|
||||
export async function pickDrills(
|
||||
db: D1Database,
|
||||
@@ -382,6 +469,7 @@ export async function pickDrills(
|
||||
)
|
||||
.all<ProblemRow & { boost: number }>();
|
||||
const pool = results.filter((p) => {
|
||||
if (RESERVED.has(p.lc_number)) return false;
|
||||
const w = topicWeek(p.topic_issue);
|
||||
return w !== undefined && w < week;
|
||||
});
|
||||
|
||||
+90
-3
@@ -8,12 +8,17 @@
|
||||
* srs.ts turns a topic's days-since-last-attempt into a band, so "which
|
||||
* concepts have gone cold" has exactly one definition and the dashboard is
|
||||
* free to colour it however it likes.
|
||||
*
|
||||
* `recent`, `attempting` and `year` feed the docs page's LeetCode-style cards
|
||||
* (recently solved, the solved ring, the heatmap and the submissions chart).
|
||||
* They are additive: the terminal dashboard ignores them.
|
||||
*/
|
||||
import {
|
||||
CAMPAIGN_DAYS,
|
||||
CAMPAIGN_START,
|
||||
DIFFICULTIES,
|
||||
DUE_WHERE,
|
||||
MISS_WHERE,
|
||||
addDays,
|
||||
campaignDay,
|
||||
campaignWeek,
|
||||
@@ -54,6 +59,12 @@ interface TopicStat {
|
||||
temperature: Temperature;
|
||||
}
|
||||
|
||||
/** Days in `year`: the 365 ending today, which always covers the current calendar year to date. */
|
||||
const YEAR_DAYS = 365;
|
||||
|
||||
/** How many problems `recent` lists. */
|
||||
const RECENT_LIMIT = 10;
|
||||
|
||||
async function buildTopics(db: D1Database, today: string): Promise<TopicStat[]> {
|
||||
// Three shapes, three statements: batch() carries one row type, and casting
|
||||
// its results apart would cost more than the round trips it saves.
|
||||
@@ -195,9 +206,9 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
||||
);
|
||||
const queue = dates.map((date, i) => ({ date, due: queueRows[i]?.results[0]?.n ?? 0 }));
|
||||
|
||||
// Attempts per campaign day, every day present. This is the heatmap's data
|
||||
// model — the docs page reads the tail of it for "last 7 days" rather than
|
||||
// asking a second question that could answer differently.
|
||||
// Attempts per campaign day, every day present. This is the README heatmap's
|
||||
// data model — the terminal dashboard lays it out by campaign week and reads
|
||||
// its tail for "last 14 days" rather than asking a second question.
|
||||
const end = addDays(CAMPAIGN_START, CAMPAIGN_DAYS - 1);
|
||||
const { results: heatRows } = await db
|
||||
.prepare(
|
||||
@@ -215,16 +226,92 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
||||
return { date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 };
|
||||
});
|
||||
|
||||
// The ten problems most recently solved, dated by each one's FIRST passing
|
||||
// attempt — when it was completed, not when it was last re-solved (LeetCode's
|
||||
// "Recent AC"). Same-day ties fall back to log order.
|
||||
const { results: recentRows } = await db
|
||||
.prepare(
|
||||
`SELECT p.lc_number AS lc, p.title AS title, p.difficulty AS difficulty,
|
||||
MIN(a.date) AS date, MIN(a.id) AS seq
|
||||
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
|
||||
WHERE a.result = 'pass'
|
||||
GROUP BY p.lc_number
|
||||
ORDER BY date DESC, seq DESC
|
||||
LIMIT ?1`,
|
||||
)
|
||||
.bind(RECENT_LIMIT)
|
||||
.all<{ lc: number; title: string; difficulty: string; date: string; seq: number }>();
|
||||
const recent = recentRows.map(({ lc, title, difficulty, date }) => ({ lc, title, difficulty, date }));
|
||||
|
||||
// Problems still standing on a failure — the sweep day's miss log, counted.
|
||||
const missRow = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM problems p
|
||||
JOIN attempts a ON a.lc_number = p.lc_number
|
||||
WHERE ${MISS_WHERE}`,
|
||||
)
|
||||
.first<{ n: number }>();
|
||||
|
||||
// The past year, one entry per day (zeros included), split by difficulty two
|
||||
// ways: every logged attempt, and every first solve — the same first-pass
|
||||
// date `recent` uses. `heat` stays campaign-scoped (the terminal dashboard
|
||||
// lays it out by campaign week); this is the calendar-scoped view the docs
|
||||
// heatmap and submissions chart window and bucket for themselves.
|
||||
const yearStart = addDays(today, -(YEAR_DAYS - 1));
|
||||
const { results: attemptRows } = await db
|
||||
.prepare(
|
||||
`SELECT a.date AS date, p.difficulty AS difficulty,
|
||||
COUNT(*) AS n,
|
||||
SUM(CASE WHEN a.result = 'pass' THEN 1 ELSE 0 END) AS passes
|
||||
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
|
||||
WHERE a.date BETWEEN ?1 AND ?2
|
||||
GROUP BY a.date, p.difficulty`,
|
||||
)
|
||||
.bind(yearStart, today)
|
||||
.all<{ date: string; difficulty: string; n: number; passes: number | null }>();
|
||||
const { results: solvedRows } = await db
|
||||
.prepare(
|
||||
`SELECT date, difficulty, COUNT(*) AS n
|
||||
FROM (SELECT MIN(a.date) AS date, p.difficulty AS difficulty
|
||||
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
|
||||
WHERE a.result = 'pass'
|
||||
GROUP BY p.lc_number)
|
||||
WHERE date BETWEEN ?1 AND ?2
|
||||
GROUP BY date, difficulty`,
|
||||
)
|
||||
.bind(yearStart, today)
|
||||
.all<{ date: string; difficulty: string; n: number }>();
|
||||
const year = Array.from({ length: YEAR_DAYS }, (_, i) => ({
|
||||
date: addDays(yearStart, i),
|
||||
passes: 0,
|
||||
attempts: Object.fromEntries(DIFFICULTIES.map((d) => [d, 0])),
|
||||
solved: Object.fromEntries(DIFFICULTIES.map((d) => [d, 0])),
|
||||
}));
|
||||
const yearIndex = new Map(year.map((d, i) => [d.date, i]));
|
||||
for (const row of attemptRows) {
|
||||
const day = year[yearIndex.get(row.date) ?? -1];
|
||||
if (!day || !(row.difficulty in day.attempts)) continue;
|
||||
day.attempts[row.difficulty] = row.n;
|
||||
day.passes += row.passes ?? 0;
|
||||
}
|
||||
for (const row of solvedRows) {
|
||||
const day = year[yearIndex.get(row.date) ?? -1];
|
||||
if (day && row.difficulty in day.solved) day.solved[row.difficulty] = row.n;
|
||||
}
|
||||
|
||||
return {
|
||||
generated: new Date().toISOString(),
|
||||
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
|
||||
phases: [...phases.values()],
|
||||
ladder,
|
||||
difficulty,
|
||||
attempting: missRow?.n ?? 0,
|
||||
topics: await buildTopics(db, today),
|
||||
gates,
|
||||
streak: await streak(db, today),
|
||||
queue,
|
||||
heat,
|
||||
recent,
|
||||
year,
|
||||
};
|
||||
}
|
||||
@@ -32,10 +32,15 @@
|
||||
"REPO": "prdlk/leetcode",
|
||||
"FROM_EMAIL": "srs@prdlk.com",
|
||||
"TO_EMAIL": "prnk28@gmail.com",
|
||||
"DOCS_URL": "https://prdlk.github.io/leetcode",
|
||||
"DOCS_ORIGIN": "https://prdlk.github.io,https://lc.prad.nu",
|
||||
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev"
|
||||
"DOCS_URL": "https://lc.prd.lk",
|
||||
"DOCS_ORIGIN": "https://lc.prd.lk,https://prdlk.github.io",
|
||||
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev",
|
||||
// The docs assistant's OpenRouter model (POST /ask). `:free` variants share
|
||||
// an upstream pool and answer 429 when it is saturated.
|
||||
"ASK_MODEL": "qwen/qwen3.8-27b:free"
|
||||
}
|
||||
|
||||
// Secrets (wrangler secret put): GH_PAT, WEBHOOK_SECRET, LINK_KEY.
|
||||
// OPENROUTER_API_KEY (the docs assistant, POST /ask) is the repo's Actions
|
||||
// secret of that name, put by deploy.yml before every deploy.
|
||||
}
|
||||
+60
-47
@@ -1,24 +1,34 @@
|
||||
#!/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
|
||||
* problem carries exactly one `set:` label — `set:core` is required, while
|
||||
* `set:optional` and `set:deferred` are extra credit. A topic is finished when
|
||||
* every one of its core sub-issues is closed; optional/deferred state is
|
||||
* ignored, matching the day rule in each topic body ("Core problems first").
|
||||
* 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
|
||||
* 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
|
||||
*
|
||||
* 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 { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
import { completion, coreSet, readProblems } from "./topics.ts";
|
||||
|
||||
const DRY =
|
||||
process.argv.includes("--dry-run") ||
|
||||
@@ -53,44 +63,8 @@ for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=ope
|
||||
}
|
||||
topics.sort((a, b) => a.number - b.number);
|
||||
|
||||
// ── core sub-issue state per topic ───────────────────────────────
|
||||
|
||||
interface Core {
|
||||
/** Core sub-issues, closed and open alike, lowest number first. */
|
||||
all: number[];
|
||||
/** The core sub-issues still open — non-empty means the topic stays open. */
|
||||
pending: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a topic's core sub-issue state. The sub_issues payload carries the full
|
||||
* issue objects (state + labels), so no per-problem follow-up request is needed.
|
||||
*/
|
||||
async function readCore(topic: number): Promise<Core> {
|
||||
const all: number[] = [];
|
||||
const pending: number[] = [];
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||
if (!("state" in raw) || typeof raw.state !== "string") continue;
|
||||
if (!("labels" in raw) || !Array.isArray(raw.labels)) continue;
|
||||
|
||||
const core = raw.labels.some(
|
||||
(label) =>
|
||||
label &&
|
||||
typeof label === "object" &&
|
||||
"name" in label &&
|
||||
label.name === "set:core",
|
||||
);
|
||||
if (!core) continue;
|
||||
|
||||
all.push(raw.number);
|
||||
if (raw.state === "open") pending.push(raw.number);
|
||||
}
|
||||
all.sort((a, b) => a - b);
|
||||
pending.sort((a, b) => a - b);
|
||||
return { all, pending };
|
||||
}
|
||||
const reviews = await readReviews(gh);
|
||||
const moved = movedFromTopics(reviews);
|
||||
|
||||
// ── reconcile ────────────────────────────────────────────────────
|
||||
|
||||
@@ -98,7 +72,7 @@ const report: string[][] = [];
|
||||
let closed = 0;
|
||||
|
||||
for (const topic of topics) {
|
||||
const core = await readCore(topic.number);
|
||||
const core = coreSet(await readProblems(gh, topic.number, moved));
|
||||
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,
|
||||
@@ -132,19 +106,58 @@ for (const topic of topics) {
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
const rows = [["topic", "title", "core", "status"], ...report];
|
||||
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(
|
||||
`\n${topics.length} open topics · ` +
|
||||
`${DRY ? `would close ${finished.length}` : `closed ${closed}`}`,
|
||||
`\n${topics.length} open topics · ${DRY ? `would close ${topicsDone}` : `closed ${closed}`}` +
|
||||
`\n${reviewReport.length} open reviews · ${DRY ? `would close ${reviewsDone}` : `closed ${reviewsClosed}`}`,
|
||||
);
|
||||
|
||||
await writeStepSummary(
|
||||
`### close-topics${DRY ? " (dry run)" : ""}\n\n` +
|
||||
`${topics.length} open topic issues, ${finished.length} with a complete core set.\n\n` +
|
||||
(report.length ? `${markdownTable(rows)}\n` : "No open topics.\n"),
|
||||
`${topics.length} open topic issues, ${topicsDone} with a complete core set.\n\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"),
|
||||
);
|
||||
@@ -8,12 +8,14 @@
|
||||
"test": "bun ./test.ts",
|
||||
"submit": "bun ./submit.ts",
|
||||
"sync": "bun ./sync.ts",
|
||||
"videos": "bun ./videos.ts",
|
||||
"close-solved": "bun ./close-solved.ts",
|
||||
"close-topics": "bun ./close-topics.ts"
|
||||
"close-topics": "bun ./close-topics.ts",
|
||||
"release-topics": "bun ./release-topics.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^1.7.0",
|
||||
"@night-slayer18/leetcode-cli": "^3.5.0",
|
||||
"@night-slayer18/leetcode-cli": "^3.5.2",
|
||||
"@types/bun": "^1.3.14"
|
||||
}
|
||||
}
|
||||
Executable
+377
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* "Finished" is topics.ts's rule — a topic when every core problem is closed,
|
||||
* a review when every one of its sub-issues is — the same one close-topics.ts
|
||||
* closes the issue on, and the version is the instant the LAST required
|
||||
* problem closed. Keying off the sub-issues rather than the topic/review issue
|
||||
* lets this run as the tail of Close Solved: the push that closes the final
|
||||
* required problem releases it in the same run, without waiting for
|
||||
* 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 issue's problems
|
||||
* that had closed by then (a topic's sub-issues plus those since re-parented
|
||||
* into Review issues, or a review's sub-issues in solve order — see
|
||||
* 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.
|
||||
* The docs site (prdlk/cs `lib/releases.ts`) reads these `### Closed …`
|
||||
* tables back for its `/changelog` timeline, so their row shape is its contract.
|
||||
*
|
||||
* The tag is annotated and backdated (tagger date = finish), so
|
||||
* `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
|
||||
* (the API has no field for it), so the docs changelog dates each entry from
|
||||
* this timever tag instead (prdlk/cs `lib/releases.ts`)
|
||||
* — the tag format is that contract.
|
||||
*
|
||||
* Reconciles instead of reacting: every finished topic or review without a
|
||||
* release gets one, so a first run backfills the whole campaign; an existing
|
||||
* release keeps its tag and commit and only has its body rewritten when
|
||||
* 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
|
||||
* 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
|
||||
* reused, not re-created.
|
||||
*
|
||||
* bun apps/cli/release-topics.ts # tag, push, release, fix bodies
|
||||
* bun apps/cli/release-topics.ts --dry-run # print the would-be releases
|
||||
*
|
||||
* Auth: GH_TOKEN / GITHUB_TOKEN, else `gh auth token` (needs contents: write).
|
||||
* Git: a clone with full history, an `origin` it may push tags to, and a
|
||||
* committer identity (the tagger).
|
||||
*/
|
||||
import { etDate } from "../api/src/srs.ts";
|
||||
import { movedFromTopics, readReviews, type Problem } from "../api/src/reviews.ts";
|
||||
import { github } from "./github.ts";
|
||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
import { completion, coreSet, labelValue, readProblems } from "./topics.ts";
|
||||
import { ROOT, parseWorkPath } from "./work.ts";
|
||||
|
||||
const DRY =
|
||||
process.argv.includes("--dry-run") ||
|
||||
process.env.DRY_RUN === "1" ||
|
||||
process.env.DRY_RUN === "true";
|
||||
|
||||
/** The docs site (prdlk/cs `blume.config.ts` `deployment.site`); it publishes sync's `/<num>` short links. */
|
||||
const DOCS_SITE = "https://lc.prd.lk";
|
||||
const TIMEVER = "https://gist.github.com/twolfson/de1b004dd22536b8e668";
|
||||
|
||||
const gh = await github();
|
||||
|
||||
function git(...args: string[]): string {
|
||||
const run = Bun.spawnSync(["git", ...args], { cwd: ROOT });
|
||||
if (run.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${run.stderr.toString().trim()}`);
|
||||
return run.stdout.toString().trim();
|
||||
}
|
||||
|
||||
// ── finished topics and reviews ──────────────────────────────────
|
||||
|
||||
/** A topic or Review issue, as far as its release reads it. */
|
||||
interface Issue {
|
||||
number: number;
|
||||
title: string;
|
||||
milestone: string | null;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Narrow one /issues element; the `topic` label filter cannot exclude PRs. */
|
||||
function readTopic(value: unknown): Issue | undefined {
|
||||
if (!value || typeof value !== "object") return;
|
||||
if ("pull_request" in value) return;
|
||||
if (!("number" in value) || typeof value.number !== "number") return;
|
||||
if (!("title" in value) || typeof value.title !== "string") return;
|
||||
const milestone =
|
||||
"milestone" in value &&
|
||||
value.milestone &&
|
||||
typeof value.milestone === "object" &&
|
||||
"title" in value.milestone &&
|
||||
typeof value.milestone.title === "string"
|
||||
? value.milestone.title
|
||||
: null;
|
||||
const body = "body" in value && typeof value.body === "string" ? value.body : "";
|
||||
return { number: value.number, title: value.title, milestone, body };
|
||||
}
|
||||
|
||||
interface Finished {
|
||||
kind: "topic" | "review";
|
||||
issue: Issue;
|
||||
/** Topic: every problem it owns, by number. Review: its sub-issues, in solve order. */
|
||||
problems: Problem[];
|
||||
/** The set whose last close finished it: a topic's core, a review's every problem. */
|
||||
required: number[];
|
||||
finishedAt: string;
|
||||
}
|
||||
|
||||
// Closed and open issues alike: the rules read the sub-issues, so a topic or
|
||||
// review finished moments ago (close-topics not yet run) releases now.
|
||||
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`)) {
|
||||
const issue = readTopic(raw);
|
||||
if (!issue) continue;
|
||||
const problems = await readProblems(gh, issue.number, moved);
|
||||
const core = coreSet(problems);
|
||||
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.
|
||||
finished.sort((a, b) => a.finishedAt.localeCompare(b.finishedAt) || a.issue.number - b.issue.number);
|
||||
|
||||
// ── existing releases ────────────────────────────────────────────
|
||||
|
||||
/** 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 }>();
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/releases`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if (!("id" in raw) || typeof raw.id !== "number") continue;
|
||||
if (!("tag_name" in raw) || typeof raw.tag_name !== "string") continue;
|
||||
if (!("body" in raw) || typeof raw.body !== "string") continue;
|
||||
const name = "name" in raw && typeof raw.name === "string" ? raw.name : "";
|
||||
const m = raw.body.match(/<!-- release-topics: #(\d+) -->/);
|
||||
if (m) released.set(Number(m[1]), { id: raw.id, tag: raw.tag_name, name, body: raw.body });
|
||||
}
|
||||
|
||||
// ── timever ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Timever of an instant. The API reports whole seconds, so SUBTIME is the
|
||||
* tiebreak: two topics or reviews finishing in the same second (one push
|
||||
* closing both last problems) get consecutive nanoseconds, in (instant,
|
||||
* issue) order.
|
||||
*/
|
||||
const tags = new Map<number, string>(); // issue -> tag
|
||||
{
|
||||
let prev = "";
|
||||
let ns = 0;
|
||||
for (const f of finished) {
|
||||
const iso = new Date(f.finishedAt).toISOString(); // 2026-09-24T18:36:38.000Z
|
||||
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;
|
||||
prev = base;
|
||||
tags.set(f.issue.number, `${base}.${String(ns).padStart(9, "0")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── release body ─────────────────────────────────────────────────
|
||||
|
||||
const ET_WHEN = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "America/New_York",
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const ET_DAY = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "America/New_York",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
/** Topic body sections worth carrying into the changelog (skipped while still `TODO`). */
|
||||
const NOTES = ["Trigger", "Invariant", "Trap"] as const;
|
||||
|
||||
function bodyField(body: string, field: string): string | undefined {
|
||||
return body.match(new RegExp(`^\\*\\*${field}:\\*\\*\\s*(.+)$`, "m"))?.[1]?.trim();
|
||||
}
|
||||
|
||||
function bodySection(body: string, heading: string): string | undefined {
|
||||
const m = body.match(new RegExp(`^### ${heading}\\n([\\s\\S]*?)(?=^### |(?![\\s\\S]))`, "m"));
|
||||
const text = m?.[1]?.trim();
|
||||
return text && text !== "TODO" ? text : undefined;
|
||||
}
|
||||
|
||||
function render(f: Finished, tag: string, sha: string, files: Map<number, string[]>): string {
|
||||
const { issue, problems } = f;
|
||||
const finishDay = etDate(new Date(f.finishedAt));
|
||||
// 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 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 row = (p: Problem): string[] => {
|
||||
const lc = Number(p.title.match(/^LC (\d+)/)?.[1]);
|
||||
const code = closedBy(p)
|
||||
? (files.get(lc) ?? []).map(
|
||||
(path) => `[${path.slice(path.lastIndexOf(".") + 1)}](https://github.com/${gh.repo}/blob/${tag}/${encodeURI(path)})`,
|
||||
)
|
||||
: [];
|
||||
return [
|
||||
`#${p.number}`,
|
||||
p.title,
|
||||
labelValue(p, "set:") ?? "—",
|
||||
labelValue(p, "diff:") ?? "—",
|
||||
closedBy(p) ? ET_DAY.format(new Date(p.closedAt!)) : "—",
|
||||
code.length ? [...code, `[docs](${DOCS_SITE}/${lc})`].join(" · ") : "—",
|
||||
];
|
||||
};
|
||||
const table = (list: Problem[]) =>
|
||||
markdownTable([["Issue", "Problem", "Set", "Difficulty", "Closed (ET)", "Solution"], ...list.map(row)]);
|
||||
|
||||
// Blume's changelog derives each page's meta description from the opening
|
||||
// prose, stripping markdown and line breaks without adding a space — so lead
|
||||
// with one self-contained sentence and keep the fields on one `·` line.
|
||||
const when = ET_WHEN.format(new Date(f.finishedAt));
|
||||
const summary =
|
||||
`Finished ${when} ET: all ${f.required.length} ${f.kind === "topic" ? "core " : ""}problems closed, ` +
|
||||
`${onDay.length} ${onDay.length === 1 ? "problem" : "problems"} closed that day.`;
|
||||
const fields: string[] = [];
|
||||
const scheduled = bodyField(issue.body, "Scheduled");
|
||||
if (scheduled) fields.push(`**Scheduled:** ${scheduled}`);
|
||||
const phase = f.kind === "topic" ? issue.milestone : bodyField(issue.body, "Phase");
|
||||
if (phase) fields.push(`**Phase:** ${phase}`);
|
||||
fields.push(`**${f.kind === "topic" ? "Topic" : "Review"}:** #${issue.number}`);
|
||||
const buildsOn = bodyField(issue.body, "Builds on");
|
||||
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.
|
||||
// One marker for both kinds: issue numbers never collide.
|
||||
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)}`);
|
||||
const notes = NOTES.flatMap((h) => {
|
||||
const text = bodySection(issue.body, h);
|
||||
return text ? [`**${h}.** ${text}`] : [];
|
||||
});
|
||||
if (notes.length) sections.push(`### Notes\n\n${notes.join("\n\n")}`);
|
||||
sections.push(
|
||||
`---\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.`,
|
||||
);
|
||||
return `${sections.join("\n\n")}\n`;
|
||||
}
|
||||
|
||||
// ── reconcile ────────────────────────────────────────────────────
|
||||
|
||||
const latest = finished.at(-1)?.issue.number;
|
||||
const report: string[][] = [];
|
||||
const previews: string[] = [];
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const f of finished) {
|
||||
const { issue } = f;
|
||||
const finishedEt = etDate(new Date(f.finishedAt));
|
||||
const existing = released.get(issue.number);
|
||||
// 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
|
||||
// fetch it when missing — read-only, so a dry run does it too.
|
||||
const tag = existing?.tag ?? tags.get(issue.number)!;
|
||||
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}`);
|
||||
}
|
||||
const sha = existing
|
||||
? git("rev-parse", `${tag}^{commit}`)
|
||||
: git("rev-list", "-1", "--first-parent", `--before=${f.finishedAt}`, "HEAD");
|
||||
if (!sha) {
|
||||
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "ERROR: no commit before finish"]);
|
||||
continue;
|
||||
}
|
||||
// LC number -> its first-solve files in the tagged tree. Keyed by file name,
|
||||
// like firstSolved(): tags before the bucket split see `work/<Difficulty>/…`,
|
||||
// and the work/3 + work/7 re-solves are not the solve that closed the issue.
|
||||
const files = new Map<number, string[]>();
|
||||
for (const path of git("ls-tree", "-r", "--name-only", sha, "--", "work").split("\n")) {
|
||||
const bucket = parseWorkPath(path.slice("work/".length))?.bucket;
|
||||
const lc = path.match(/\/(\d+)\.[^/]+\.(?:js|py)$/)?.[1];
|
||||
if (!lc || (bucket && bucket !== 1)) continue;
|
||||
files.set(Number(lc), [...(files.get(Number(lc)) ?? []), path]);
|
||||
}
|
||||
const body = render(f, tag, sha, files);
|
||||
const name = issue.title;
|
||||
|
||||
if (existing) {
|
||||
if (existing.body === body && existing.name === name) {
|
||||
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "released"]);
|
||||
} else if (DRY) {
|
||||
previews.push(`## ${name} (${tag})\n\n${body}`);
|
||||
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "would update"]);
|
||||
} else {
|
||||
await gh.api(`/repos/${gh.repo}/releases/${existing.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name, body }),
|
||||
});
|
||||
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "updated"]);
|
||||
updated++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DRY) {
|
||||
previews.push(`## ${name} (${tag})\n\n${body}`);
|
||||
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "would release"]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const remote = git("ls-remote", "--tags", "origin", `refs/tags/${tag}`);
|
||||
if (!remote) {
|
||||
const local = Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT });
|
||||
if (local.exitCode !== 0) {
|
||||
const tagged = Bun.spawnSync(
|
||||
[
|
||||
"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 } },
|
||||
);
|
||||
if (tagged.exitCode !== 0) throw new Error(`git tag ${tag}: ${tagged.stderr.toString().trim()}`);
|
||||
}
|
||||
git("push", "origin", `refs/tags/${tag}`);
|
||||
}
|
||||
await gh.api(`/repos/${gh.repo}/releases`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
tag_name: tag,
|
||||
name,
|
||||
body,
|
||||
make_latest: issue.number === latest ? "true" : "false",
|
||||
}),
|
||||
});
|
||||
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "released now"]);
|
||||
created++;
|
||||
}
|
||||
|
||||
// ── report ───────────────────────────────────────────────────────
|
||||
|
||||
if (previews.length) console.log(`${previews.join("\n")}\n`);
|
||||
const rows = [["issue", "title", "finished (ET)", "tag", "status"], ...report];
|
||||
printTable(rows);
|
||||
const would = (status: string) => report.filter((r) => r[4] === status).length;
|
||||
console.log(
|
||||
`\n${finished.length} finished topics and reviews · ` +
|
||||
(DRY
|
||||
? `would release ${would("would release")}, update ${would("would update")}`
|
||||
: `released ${created}, updated ${updated}`),
|
||||
);
|
||||
|
||||
await writeStepSummary(
|
||||
`### release-topics${DRY ? " (dry run)" : ""}\n\n` +
|
||||
`${finished.length} finished topics and reviews, ${released.size} already released.\n\n` +
|
||||
(report.length ? `${markdownTable(rows)}\n` : "Nothing finished.\n") +
|
||||
(previews.length ? `\n${previews.join("\n")}` : ""),
|
||||
);
|
||||
+22
-8
@@ -30,17 +30,27 @@ function lcNumber(rel: string): number | undefined {
|
||||
}
|
||||
|
||||
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
|
||||
export async function runOnSolution(command: "test" | "submit", message: string): Promise<never> {
|
||||
export async function runOnSolution(
|
||||
command: "test" | "submit",
|
||||
message: string,
|
||||
): Promise<never> {
|
||||
const db = problemDb();
|
||||
|
||||
const scanned = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })];
|
||||
const scanned = [
|
||||
...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync(
|
||||
{ cwd: WORK_DIR },
|
||||
),
|
||||
];
|
||||
|
||||
// register creation times so files older than the db still sort correctly
|
||||
for (const f of scanned) {
|
||||
const num = lcNumber(f);
|
||||
if (num === undefined) continue;
|
||||
const stat = statSync(join(WORK_DIR, f));
|
||||
db.ensureSolution(num, new Date(stat.birthtimeMs || stat.mtimeMs).toISOString());
|
||||
db.ensureSolution(
|
||||
num,
|
||||
new Date(stat.birthtimeMs || stat.mtimeMs).toISOString(),
|
||||
);
|
||||
}
|
||||
const activity = db.solutionActivity();
|
||||
|
||||
@@ -51,8 +61,7 @@ export async function runOnSolution(command: "test" | "submit", message: string)
|
||||
return (command === "test" ? row?.lastTested : row?.createdAt) ?? undefined;
|
||||
};
|
||||
|
||||
const files = scanned
|
||||
.sort((a, b) => {
|
||||
const files = scanned.sort((a, b) => {
|
||||
const ka = sortKey(a);
|
||||
const kb = sortKey(b);
|
||||
if (ka !== kb) {
|
||||
@@ -64,7 +73,9 @@ export async function runOnSolution(command: "test" | "submit", message: string)
|
||||
});
|
||||
|
||||
if (files.length === 0) {
|
||||
log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`);
|
||||
log.error(
|
||||
`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -74,11 +85,14 @@ export async function runOnSolution(command: "test" | "submit", message: string)
|
||||
maxItems: 12,
|
||||
options: files.map((f) => {
|
||||
const parsed = parseWorkPath(f);
|
||||
const stage = parsed?.bucket === 1 ? "first solve" : `${parsed?.bucket}-day review`;
|
||||
const stage =
|
||||
parsed?.bucket === 1 ? "first solve" : `${parsed?.bucket}-day review`;
|
||||
return {
|
||||
value: f,
|
||||
label: basename(f),
|
||||
hint: parsed ? `${stage} · ${parsed.difficulty} · ${parsed.category}` : f,
|
||||
hint: parsed
|
||||
? `${stage} · ${parsed.difficulty} · ${parsed.category}`
|
||||
: f,
|
||||
};
|
||||
}),
|
||||
filter: (search, option) => {
|
||||
|
||||
+376
-24
@@ -1,25 +1,81 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Sync work/1 leetcode solutions into apps/docs/content/ pages.
|
||||
* Sync work/1 leetcode solutions into docs/ pages, plus docs/site.json.
|
||||
*
|
||||
* - New problems get a full page (frontmatter, badge, warning, examples,
|
||||
* docs/ is content only — MDX pages and one generated JSON manifest, no Blume
|
||||
* config. The docs site itself (config, components, islands, the Appendix)
|
||||
* lives in prdlk/cs and reads docs/ over HTTP with Blume's `mdxRemote()`
|
||||
* source, which fetches `.md`/`.mdx` and nothing else: no `meta.ts`. So the
|
||||
* sidebar meta a folder `meta.ts` used to carry, and the `/<num>` short links,
|
||||
* travel in `docs/site.json` instead, which the site fetches at the same ref.
|
||||
*
|
||||
* - New problems get a full page (frontmatter, warning, examples,
|
||||
* constraints, solution) in the gold-standard format.
|
||||
* - Already-ported pages only get their `## Solution` section regenerated,
|
||||
* so hand-curated prose is never touched.
|
||||
* - Already-ported pages only get their script-owned parts regenerated — the
|
||||
* frontmatter `icon:` (`PAGE_ICON`) and the `## Solution` section — so
|
||||
* hand-curated prose is never touched.
|
||||
* - When a problem is solved in both JavaScript and Python the solution
|
||||
* renders as a <CodeGroup> (Python first); single-language solutions
|
||||
* render as a plain fence.
|
||||
* - Every page carries LeetCode's own topic tags as frontmatter `topics:`
|
||||
* (`{ name, slug }` per tag, LeetCode's order), fetched from the GraphQL
|
||||
* `topicTags` once — when the page is created, or on any later run that
|
||||
* finds the key missing — and never refreshed after, so a sync with nothing
|
||||
* new makes no request. Delete the key to refetch it. The page itself
|
||||
* renders no badges: the site's MDX `wrapper` (prdlk/cs
|
||||
* `components/ProblemBadges.astro`) turns `topics:` and the `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.
|
||||
*
|
||||
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`).
|
||||
* A problem outside the plan (warm-ups) has no topic, so it groups under the
|
||||
* data-structure CATEGORY its file sits under in
|
||||
* `work/1/<Difficulty>/<Category>/` instead.
|
||||
*
|
||||
* Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`.
|
||||
* The phase folder is a Blume `(group)` — a sidebar section adding no route
|
||||
* segment — while the topic folder is a real segment
|
||||
* (`/tree-dfs/invert-binary-tree`), so a hand-written `index.mdx` in it is the
|
||||
* topic's landing page at `/tree-dfs` and Blume links the sidebar group row to
|
||||
* it. The topic's problem pages sit one level down in a `(problems)` group,
|
||||
* `tree-dfs/(problems)/226_invert-binary-tree.mdx`: no route segment either,
|
||||
* and Blume strips the `<num>_` prefix from the route while sorting by it, so
|
||||
* the URL is still `/tree-dfs/invert-binary-tree`. Sync never writes or moves
|
||||
* an `index.mdx`, and never removes a folder that still holds one. An
|
||||
* unplanned category group has no phase and sits at the docs/ root.
|
||||
*
|
||||
* `docs/site.json` is wholly script-owned and rewritten whenever it differs:
|
||||
* - `folders`: Blume folder meta keyed by folder path under docs/ — exactly
|
||||
* the key a `meta.ts` in that folder would have. A phase is `N. Name` in
|
||||
* phase order; a topic is its exact name (the folder slug loses `/` and
|
||||
* case), curriculum order, `TOPIC_ICONS` icon and `display: "page"`, so it
|
||||
* is one sidebar row drilling into its own panel. `(problems)` has none:
|
||||
* Blume's humanized "Problems" is the label.
|
||||
* - `redirects`: one `{ from: "/<num>", to: "/<topic>/<slug>" }` per page, in
|
||||
* number order — exact root-relative paths; Blume prepends the deployment
|
||||
* base itself.
|
||||
*
|
||||
* Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves
|
||||
* of problems whose page already exists (see work.ts), and a fresh scaffold
|
||||
* there would otherwise wipe the page's `## Solution`.
|
||||
*/
|
||||
import { mkdir, rename } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { mkdir, readdir, rename, rmdir } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
|
||||
import { github } from "./github.ts";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..", "..");
|
||||
const WORK = join(ROOT, "work", "1");
|
||||
const DOCS = join(ROOT, "apps", "docs", "content");
|
||||
const DOCS = join(ROOT, "docs");
|
||||
const MANIFEST = join(DOCS, "site.json");
|
||||
|
||||
type Lang = "js" | "py";
|
||||
|
||||
@@ -158,24 +214,240 @@ function parseHeader(header: string): Header {
|
||||
|
||||
// ── README topic map ─────────────────────────────────────────────
|
||||
|
||||
/** A campaign phase: `## Phase N — Name · Weeks …` in the README. */
|
||||
interface Phase {
|
||||
order: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** A campaign topic: `<strong>NN. Name</strong>` in the README table. */
|
||||
interface Topic {
|
||||
order?: number; // README topic number; absent for an unplanned problem's category group
|
||||
name: string;
|
||||
phase?: Phase; // absent for an unplanned category group
|
||||
}
|
||||
|
||||
/** README phase names → the short labels the overview page uses. */
|
||||
const PHASE_ALIASES: Record<string, string> = {
|
||||
"Nodal & Grid Structures": "Nodal & Grid",
|
||||
"Hierarchical Structures": "Hierarchical",
|
||||
"Relational Structures": "Relational",
|
||||
"Decision-Space Exploration": "Decision Space",
|
||||
};
|
||||
|
||||
const ROMAN = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"];
|
||||
|
||||
const TOPIC_ALIASES: Record<string, string> = {
|
||||
"Hash-Based Lookup": "Hash Table",
|
||||
};
|
||||
|
||||
async function readmeTopics(): Promise<Map<number, string>> {
|
||||
/**
|
||||
* Sidebar icon per group, keyed by the (post-alias) topic name — the same key an
|
||||
* unplanned problem's category group uses, so `Stack` the category and `Stack`
|
||||
* the topic share one row. Lucide names; Blume resolves them at build time and
|
||||
* reports a diagnostic for a typo, but an unmapped group is caught here first.
|
||||
*/
|
||||
const TOPIC_ICONS: Record<string, string> = {
|
||||
// README topics, in curriculum order
|
||||
"Hash Table": "hash",
|
||||
"Prefix Sum": "sigma",
|
||||
"Two Pointers": "arrow-left-right",
|
||||
"Sliding Window": "app-window",
|
||||
"Binary Search": "search",
|
||||
Stack: "layers",
|
||||
"Monotonic Stack": "trending-up",
|
||||
"Linked List": "link",
|
||||
"Hybrid Structures": "combine",
|
||||
"Matrix Index Math": "grid-3x3",
|
||||
"Tree DFS": "folder-tree",
|
||||
"Tree BFS": "network",
|
||||
"Binary Search Tree": "git-fork",
|
||||
"Heap / Priority Queue": "triangle",
|
||||
Trie: "spell-check",
|
||||
"Graph DFS": "waypoints",
|
||||
"Graph BFS": "radar",
|
||||
"Topological Sort": "workflow",
|
||||
"Union-Find": "merge",
|
||||
Backtracking: "undo-2",
|
||||
"1-D Dynamic Programming": "columns-3",
|
||||
"Multi-D / Grid DP": "table",
|
||||
Greedy: "coins",
|
||||
Intervals: "calendar-range",
|
||||
// work/1 categories that can surface as an unplanned group
|
||||
Array: "brackets",
|
||||
String: "type",
|
||||
Tree: "network",
|
||||
"Depth-First Search": "folder-tree",
|
||||
};
|
||||
|
||||
/** LC number -> topic. A problem listed under two topics keeps the first. */
|
||||
async function readmeTopics(): Promise<Map<number, Topic>> {
|
||||
const md = await Bun.file(join(ROOT, "README.md")).text();
|
||||
const map = new Map<number, string>();
|
||||
let topic = "";
|
||||
const map = new Map<number, Topic>();
|
||||
let phase: Phase | undefined;
|
||||
let topic: Topic | null = null;
|
||||
for (const line of md.split("\n")) {
|
||||
const t = line.match(/<strong>\d+\.\s*([^<]+)<\/strong>/);
|
||||
if (t) topic = TOPIC_ALIASES[t[1]!.trim()] ?? t[1]!.trim();
|
||||
const p = line.match(/^## Phase ([IVX]+) — (.+?) · /);
|
||||
if (p) {
|
||||
const name = p[2]!.replaceAll("&", "&");
|
||||
phase = { order: ROMAN.indexOf(p[1]!) + 1, name: PHASE_ALIASES[name] ?? name };
|
||||
}
|
||||
const t = line.match(/<strong>(\d+)\.\s*([^<]+)<\/strong>/);
|
||||
if (t) topic = { order: Number(t[1]), name: TOPIC_ALIASES[t[2]!.trim()] ?? t[2]!.trim(), phase };
|
||||
if (!topic) continue;
|
||||
const n = line.match(/<td align="center">(\d+)<\/td>/);
|
||||
if (n) map.set(Number(n[1]), topic);
|
||||
if (n && !map.has(Number(n[1]))) map.set(Number(n[1]), topic);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder slug for a topic or phase name. Blume reads a leading `<digits>-` on a
|
||||
* folder as a sidebar-order prefix and strips it from the route (`1-d-…` →
|
||||
* `/d-…`), so a slug that would start that way keeps the digits glued to the
|
||||
* next token (`1d-dynamic-programming`).
|
||||
*/
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.replace(/^(\d+)-/, "$1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Content folder for a topic, relative to `DOCS`: `(0N-<phase>)/<topic>` for a
|
||||
* planned topic (the phase is a `(group)`, so the route is still `/<topic>`),
|
||||
* bare `<topic>` for an unplanned category group.
|
||||
*/
|
||||
function topicDir(topic: Topic): string {
|
||||
const slug = slugify(topic.name);
|
||||
return topic.phase ? `${phaseDir(topic.phase)}/${slug}` : slug;
|
||||
}
|
||||
|
||||
function phaseDir(phase: Phase): string {
|
||||
return `(${String(phase.order).padStart(2, "0")}-${slugify(phase.name)})`;
|
||||
}
|
||||
|
||||
/** A sidebar group's Blume folder meta, as `docs/site.json` carries it. */
|
||||
interface FolderMeta {
|
||||
title: string;
|
||||
order: number;
|
||||
icon?: string;
|
||||
display?: "page";
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder meta for a phase `(group)`: the overview page's heading, `N. Name`,
|
||||
* in phase order. Derived like everything in the manifest, so a README rename
|
||||
* renames the heading — and lands in a new folder (`phaseDir`) anyway.
|
||||
*/
|
||||
function phaseMeta(phase: Phase): FolderMeta {
|
||||
return { title: `${phase.order}. ${phase.name}`, order: phase.order };
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder meta for a topic: exact label, curriculum order, the sidebar icon,
|
||||
* and `display: "page"` so the topic is one row under its phase heading that
|
||||
* drills into its own panel (the landing page, then its `(problems)` list).
|
||||
* An unplanned category group (warm-ups) has no README number and sorts at 0,
|
||||
* ahead of phase 1, so the sidebar opens on the basics.
|
||||
*/
|
||||
function groupMeta(topic: Topic): FolderMeta {
|
||||
const icon = TOPIC_ICONS[topic.name];
|
||||
if (!icon) console.warn(`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`);
|
||||
return { title: topic.name, order: topic.order ?? 0, ...(icon ? { icon } : {}), display: "page" };
|
||||
}
|
||||
|
||||
// ── LeetCode topic tags ──────────────────────────────────────────
|
||||
|
||||
/** One of LeetCode's topic tags. `slug` is not derivable from `name` (`Graph Theory` → `graph`). */
|
||||
interface TopicTag {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The problem's topic tags, in LeetCode's order — or undefined, warned, when
|
||||
* LeetCode cannot answer (offline commit, unknown slug): the page is written
|
||||
* without `topics:` and the next sync fills it in.
|
||||
*/
|
||||
async function fetchTopicTags(sol: Solution): Promise<TopicTag[] | undefined> {
|
||||
try {
|
||||
const res = await fetch("https://leetcode.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "Mozilla/5.0",
|
||||
referer: `https://leetcode.com/problems/${sol.slug}/`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: "query topicTags($titleSlug: String!) { question(titleSlug: $titleSlug) { topicTags { name slug } } }",
|
||||
variables: { titleSlug: sol.slug },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`POST /graphql -> ${res.status} ${res.statusText}`);
|
||||
const data = (await res.json()) as { data?: { question?: { topicTags?: TopicTag[] } | null } };
|
||||
const tags = data.data?.question?.topicTags;
|
||||
if (!Array.isArray(tags)) throw new Error("POST /graphql -> no such question");
|
||||
return tags.map(({ name, slug }) => ({ name, slug }));
|
||||
} catch (err) {
|
||||
console.warn(`no topics for ${sol.num}.${sol.slug}: ${err instanceof Error ? err.message : err}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The frontmatter `topics:` block, one flow mapping per tag (JSON strings are valid YAML). */
|
||||
function topicsYaml(tags: TopicTag[]): string {
|
||||
if (tags.length === 0) return "topics: []";
|
||||
return ["topics:", ...tags.map((t) => ` - { name: ${JSON.stringify(t.name)}, slug: ${JSON.stringify(t.slug)} }`)].join("\n");
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
// ── inline-code formatting heuristics ────────────────────────────
|
||||
|
||||
/** Identifiers worth backticking in prose, harvested from the code. */
|
||||
@@ -277,7 +549,10 @@ function warningText(h: Header): string {
|
||||
);
|
||||
}
|
||||
|
||||
function renderPage(sol: Solution, topic: string): string {
|
||||
/** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */
|
||||
const PAGE_ICON = "dot";
|
||||
|
||||
function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string | undefined): string {
|
||||
const h = sol.header;
|
||||
const ids = identifiers(sol);
|
||||
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
|
||||
@@ -287,13 +562,14 @@ function renderPage(sol: Solution, topic: string): string {
|
||||
"---",
|
||||
`title: '${h.num}. ${h.title.replaceAll("'", "''")}'`,
|
||||
/[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`,
|
||||
`icon: ${PAGE_ICON}`,
|
||||
...(tags ? [topicsYaml(tags)] : []),
|
||||
...(issue ? [`issue: ${JSON.stringify(issue)}`] : []),
|
||||
"sidebar:",
|
||||
` label: '${h.title.replaceAll("'", "''")}'`,
|
||||
` badge: '${h.difficulty}'`,
|
||||
"---",
|
||||
"",
|
||||
`<Badge variant="accent">${topic}</Badge>`,
|
||||
"",
|
||||
);
|
||||
|
||||
const warning = warningText(h);
|
||||
@@ -350,6 +626,11 @@ for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
||||
}
|
||||
|
||||
const topics = await readmeTopics();
|
||||
// An unplanned problem whose work/ category is also a README topic name (a
|
||||
// `Hash Table` warm-up) files under that topic, in its phase, not a second
|
||||
// root-level `hash-table/`.
|
||||
const topicByName = new Map<string, Topic>();
|
||||
for (const topic of topics.values()) topicByName.set(topic.name, topic);
|
||||
|
||||
/** Insert a number-free `sidebar.label` into existing frontmatter when missing. */
|
||||
function ensureLabel(page: string, h: Header): string {
|
||||
@@ -359,10 +640,39 @@ function ensureLabel(page: string, h: Header): string {
|
||||
return page.replace(/^sidebar:$/m, `sidebar:\n${label}`);
|
||||
}
|
||||
|
||||
/** Set the frontmatter's top-level `icon:` to `PAGE_ICON`, inserting it before `sidebar:`. */
|
||||
function ensureIcon(page: string): string {
|
||||
const fm = page.match(/^---\n[\s\S]*?\n---/);
|
||||
if (!fm) return page;
|
||||
const line = `icon: ${PAGE_ICON}`;
|
||||
const next = /^icon: .*$/m.test(fm[0])
|
||||
? fm[0].replace(/^icon: .*$/m, line)
|
||||
: fm[0].replace(/^sidebar:$/m, `${line}\nsidebar:`);
|
||||
return next + page.slice(fm[0].length);
|
||||
}
|
||||
|
||||
/** Insert `topics:` before `sidebar:` when the frontmatter lacks it; an existing list is kept as is. */
|
||||
async function ensureTopics(page: string, sol: Solution): Promise<string> {
|
||||
const fm = page.match(/^---\n[\s\S]*?\n---/);
|
||||
if (!fm || /^topics:/m.test(fm[0])) return page;
|
||||
const tags = await fetchTopicTags(sol);
|
||||
if (!tags) return page;
|
||||
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
|
||||
* heading that is not inside a code fence, or end of file. Sections a human
|
||||
* appends after the solution (`## Explanation`, …) survive regeneration.
|
||||
* appends after the solution survive regeneration.
|
||||
*/
|
||||
function solutionEnd(page: string, from: number): number {
|
||||
const lines = page.slice(from).split("\n");
|
||||
@@ -387,15 +697,23 @@ for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).s
|
||||
|
||||
const report: [string, string, string][] = [];
|
||||
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
|
||||
const folders = new Map<string, FolderMeta>(); // folder under docs/ -> its sidebar meta, for site.json
|
||||
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
|
||||
const redirects: { from: string; to: string }[] = []; // one per page, numeric order
|
||||
|
||||
for (const sol of ordered) {
|
||||
const langs = Object.keys(sol.code).sort().join("+");
|
||||
const workRef = `${sol.num}.${sol.slug} (${langs})`;
|
||||
// (category) group folder + leetcode-number prefix: numeric sidebar order, both stripped from the URL.
|
||||
const group = `(${sol.category.toLowerCase().replaceAll(" ", "-")})`;
|
||||
const name = `${group}/${sol.num}-${sol.slug}.mdx`;
|
||||
// (0N-<phase>)/<topic>/(problems)/ folder (the topic is a real route segment,
|
||||
// so its index.mdx is the landing page; `(problems)` adds none) +
|
||||
// leetcode-number prefix: numeric sidebar order, prefix stripped from the URL.
|
||||
const topic = topics.get(sol.num) ?? topicByName.get(sol.category) ?? { name: sol.category };
|
||||
const dir = topicDir(topic);
|
||||
if (!folders.has(dir)) folders.set(dir, groupMeta(topic));
|
||||
if (topic.phase) folders.set(phaseDir(topic.phase), phaseMeta(topic.phase));
|
||||
const name = `${dir}/(problems)/${sol.num}_${sol.slug}.mdx`;
|
||||
const existing = pages.get(sol.num);
|
||||
await mkdir(join(DOCS, group), { recursive: true });
|
||||
await mkdir(join(DOCS, dirname(name)), { recursive: true });
|
||||
|
||||
if (existing) {
|
||||
const page = await Bun.file(join(DOCS, existing)).text();
|
||||
@@ -406,10 +724,11 @@ for (const sol of ordered) {
|
||||
}
|
||||
const tail = page.slice(solutionEnd(page, at));
|
||||
const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
|
||||
const next = ensureLabel(body, sol.header);
|
||||
const next = await ensureIssue(await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol), sol);
|
||||
const actions: string[] = [];
|
||||
if (existing !== name) {
|
||||
await rename(join(DOCS, existing), join(DOCS, name));
|
||||
vacated.add(dirname(existing));
|
||||
actions.push("moved");
|
||||
}
|
||||
if (next !== page) {
|
||||
@@ -418,10 +737,43 @@ for (const sol of ordered) {
|
||||
}
|
||||
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
|
||||
} else {
|
||||
const topic = topics.get(sol.num) ?? sol.category;
|
||||
await Bun.write(join(DOCS, name), renderPage(sol, topic));
|
||||
await Bun.write(join(DOCS, name), renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol)));
|
||||
report.push([workRef, name, "created"]);
|
||||
}
|
||||
// The route drops the phase `(group)`, `(problems)` and the number prefix.
|
||||
redirects.push({ from: `/${sol.num}`, to: `/${slugify(topic.name)}/${sol.slug}` });
|
||||
}
|
||||
|
||||
// The site's one generated input besides the pages; script-owned, rewritten
|
||||
// only when it differs. Folders sorted by path so a diff reads by location.
|
||||
{
|
||||
const manifest = `${JSON.stringify(
|
||||
{ folders: Object.fromEntries([...folders].sort(([a], [b]) => (a < b ? -1 : 1))), redirects },
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
if (!(await Bun.file(MANIFEST).exists()) || (await Bun.file(MANIFEST).text()) !== manifest) {
|
||||
await Bun.write(MANIFEST, manifest);
|
||||
report.push([`${folders.size} folders, ${redirects.length} redirects`, "docs/site.json", "written"]);
|
||||
}
|
||||
}
|
||||
|
||||
// A folder every page left behind is dead: drop it, then the folders above it
|
||||
// that emptied too (`(problems)`, its topic, the phase). A folder that still
|
||||
// holds anything (a hand-written index.mdx, another topic) stops the climb.
|
||||
for (const start of vacated) {
|
||||
for (let dir = start; dir !== "."; dir = dirname(dir)) {
|
||||
if (folders.has(dir)) break;
|
||||
let left: string[];
|
||||
try {
|
||||
left = await readdir(join(DOCS, dir));
|
||||
} catch {
|
||||
break; // already removed via a sibling's climb
|
||||
}
|
||||
if (left.length) break;
|
||||
await rmdir(join(DOCS, dir));
|
||||
report.push(["", dir, "removed (empty)"]);
|
||||
}
|
||||
}
|
||||
|
||||
const w0 = Math.max(...report.map((r) => r[0].length), 9);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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
|
||||
* release-topics.ts (publishes the topic's timever release) so the two can
|
||||
* 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
|
||||
* 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
|
||||
* 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().
|
||||
*/
|
||||
import type { Problem } from "../api/src/reviews.ts";
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
/** Completion of a required set: a topic's core problems, or every problem of a review. */
|
||||
export interface Core {
|
||||
/** The required problems, closed and open alike, in the order given. */
|
||||
all: number[];
|
||||
/** The required problems still open — non-empty means the issue stays open. */
|
||||
pending: number[];
|
||||
/** When the last required problem closed; null until the set is finished (never, if empty). */
|
||||
finishedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every problem a topic owns: its sub-issues plus the ones moved from it into
|
||||
* 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,
|
||||
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`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||
if (!("state" in raw) || typeof raw.state !== "string") continue;
|
||||
if (!("labels" in raw) || !Array.isArray(raw.labels)) continue;
|
||||
const closedAt =
|
||||
raw.state === "closed" && "closed_at" in raw && typeof raw.closed_at === "string"
|
||||
? raw.closed_at
|
||||
: null;
|
||||
const labels = raw.labels.flatMap((label: unknown) =>
|
||||
label && typeof label === "object" && "name" in label && typeof label.name === "string"
|
||||
? [label.name]
|
||||
: [],
|
||||
);
|
||||
problems.push({ number: raw.number, title: raw.title, closedAt, labels });
|
||||
}
|
||||
return problems.sort((a, b) => a.number - b.number);
|
||||
}
|
||||
|
||||
/** The value of a `prefix:value` label (`set:` → `core`), if the problem has one. */
|
||||
export function labelValue(problem: Problem, prefix: string): string | undefined {
|
||||
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 {
|
||||
return completion(problems.filter((p) => labelValue(p, "set:") === "core"));
|
||||
}
|
||||
|
||||
/** 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.
|
||||
const finishedAt =
|
||||
required.length && !pending.length
|
||||
? required.map((p) => p.closedAt!).reduce((a, b) => (a > b ? a : b))
|
||||
: null;
|
||||
return { all: required.map((p) => p.number), pending, finishedAt };
|
||||
}
|
||||
Executable
+171
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Find the NeetCode explanation video for every docs page that has none.
|
||||
*
|
||||
* A finished solution page carries frontmatter `video: <YouTube id>` —
|
||||
* NeetCode's walkthrough of the problem — which the site's MDX `wrapper`
|
||||
* (prdlk/cs `components/ProblemBadges.astro`) renders as the red
|
||||
* "Explanation" badge beside the topic badges. `sync.ts` never writes that key
|
||||
* (it owns `icon:`, `topics:` and `## Solution`), so pages accumulate without
|
||||
* one. This script lists every numbered page lacking `video:` and looks each
|
||||
* up on NeetCode's two channels, main channel first, accepting only a title
|
||||
* that names the page's exact LC number (`… - Leetcode 226 - …`) and is not a
|
||||
* #short. No YouTube API key: the channel search page embeds its results as
|
||||
* `ytInitialData` JSON, which is all we parse. Ranking is the channel's own —
|
||||
* the first exact-number match wins.
|
||||
*
|
||||
* bun apps/cli/videos.ts # report: page → video (or "no match")
|
||||
* bun apps/cli/videos.ts --write # also add `video:` to the frontmatter
|
||||
*
|
||||
* --write inserts the key before `sidebar:`. A page with no match is left
|
||||
* untouched, so re-runs are no-ops once every page has its video.
|
||||
*/
|
||||
import { join } from "node:path";
|
||||
|
||||
import { printTable } from "./report.ts";
|
||||
import { ROOT } from "./work.ts";
|
||||
|
||||
const DOCS = join(ROOT, "docs");
|
||||
const WRITE = process.argv.includes("--write");
|
||||
|
||||
/** Channel handles searched in order; the first exact match wins. */
|
||||
const CHANNELS = ["NeetCode", "NeetCodeIO"] as const;
|
||||
|
||||
interface Video {
|
||||
id: string;
|
||||
title: string;
|
||||
channel: string;
|
||||
}
|
||||
|
||||
// ── YouTube channel search (no API key) ──────────────────────────
|
||||
|
||||
const HEADERS = { "accept-language": "en-US,en;q=0.9", "user-agent": "Mozilla/5.0" };
|
||||
|
||||
/**
|
||||
* The `var ytInitialData = {…};</script>` blob of a YouTube page, parsed.
|
||||
* YouTube intermittently answers 200 with a page that lacks the blob or cuts
|
||||
* it short, so a bad page is retried a few times before it is an error.
|
||||
*/
|
||||
async function initialData(url: string): Promise<unknown> {
|
||||
let reason = "";
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if (attempt) await Bun.sleep(500 * attempt);
|
||||
const res = await fetch(url, { headers: HEADERS });
|
||||
if (!res.ok) {
|
||||
reason = `status ${res.status}`;
|
||||
continue;
|
||||
}
|
||||
const html = await res.text();
|
||||
// Slice rather than regex: the blob is ~1 MB, and a lazy `.*?` across it
|
||||
// is what made the first cut of this script fail at random.
|
||||
const open = html.search(/ytInitialData\s*=\s*\{/);
|
||||
const close = open === -1 ? -1 : html.indexOf("</script>", open);
|
||||
if (close === -1) {
|
||||
reason = `no ytInitialData in ${html.length}-byte page`;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(html.slice(html.indexOf("{", open), close).replace(/;\s*$/, ""));
|
||||
} catch {
|
||||
reason = "truncated ytInitialData";
|
||||
}
|
||||
}
|
||||
throw new Error(`GET ${url} -> ${reason}`);
|
||||
}
|
||||
|
||||
/** Every videoRenderer in a channel search page, in the page's own order. */
|
||||
async function channelSearch(channel: string, query: string): Promise<Video[]> {
|
||||
const url = `https://www.youtube.com/@${channel}/search?query=${encodeURIComponent(query)}`;
|
||||
const data = await initialData(url);
|
||||
|
||||
const videos: Video[] = [];
|
||||
const walk = (node: unknown): void => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if ("videoRenderer" in node) {
|
||||
const v = node.videoRenderer;
|
||||
if (v && typeof v === "object" && "videoId" in v && typeof v.videoId === "string") {
|
||||
const runs =
|
||||
"title" in v && v.title && typeof v.title === "object" && "runs" in v.title
|
||||
? v.title.runs
|
||||
: undefined;
|
||||
const title = Array.isArray(runs)
|
||||
? runs.map((r) => (r && typeof r === "object" && "text" in r ? String(r.text) : "")).join("")
|
||||
: "";
|
||||
videos.push({ id: v.videoId, title, channel });
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const child of Object.values(node)) walk(child);
|
||||
};
|
||||
walk(data);
|
||||
return videos;
|
||||
}
|
||||
|
||||
/** The LC number a NeetCode title names (`… - Leetcode 226 - …`), if any. */
|
||||
function titledNumber(title: string): number | undefined {
|
||||
const m = title.match(/\bleetcode\s*#?\s*(\d+)\b/i);
|
||||
return m ? Number(m[1]) : undefined;
|
||||
}
|
||||
|
||||
async function findVideo(num: number): Promise<Video | undefined> {
|
||||
for (const channel of CHANNELS) {
|
||||
const hit = (await channelSearch(channel, `leetcode ${num}`)).find(
|
||||
(v) => titledNumber(v.title) === num && !/#shorts\b/i.test(v.title),
|
||||
);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── docs pages ───────────────────────────────────────────────────
|
||||
|
||||
interface Page {
|
||||
num: number;
|
||||
rel: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** The page's frontmatter block, `---` to `---`. */
|
||||
const FRONTMATTER = /^---\n[\s\S]*?\n---/;
|
||||
|
||||
/** Numbered solution pages without a `video:` key, by LC number. */
|
||||
async function pagesMissingVideo(): Promise<Page[]> {
|
||||
const pages: Page[] = [];
|
||||
for (const rel of await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))) {
|
||||
const text = await Bun.file(join(DOCS, rel)).text();
|
||||
const t = text.match(/^title: '(\d+)\./m);
|
||||
const fm = text.match(FRONTMATTER)?.[0] ?? "";
|
||||
if (t && !/^video:/m.test(fm)) pages.push({ num: Number(t[1]), rel, text });
|
||||
}
|
||||
return pages.sort((a, b) => a.num - b.num);
|
||||
}
|
||||
|
||||
// ── main ─────────────────────────────────────────────────────────
|
||||
|
||||
const missing = await pagesMissingVideo();
|
||||
if (missing.length === 0) {
|
||||
console.log("every solution page has a video");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const rows: string[][] = [["docs page", "video", "title", WRITE ? "status" : ""]];
|
||||
let found = 0;
|
||||
for (const page of missing) {
|
||||
const video = await findVideo(page.num);
|
||||
if (!video) {
|
||||
rows.push([page.rel, "—", "no NeetCode match", WRITE ? "skipped" : ""]);
|
||||
continue;
|
||||
}
|
||||
found++;
|
||||
let status = "";
|
||||
if (WRITE) {
|
||||
const next = page.text.replace(FRONTMATTER, (fm) => fm.replace(/^sidebar:$/m, `video: ${JSON.stringify(video.id)}\nsidebar:`));
|
||||
await Bun.write(join(DOCS, page.rel), next);
|
||||
status = "written";
|
||||
}
|
||||
rows.push([page.rel, `https://youtu.be/${video.id}`, `${video.title} (${video.channel})`, status]);
|
||||
}
|
||||
|
||||
if (!WRITE) for (const row of rows) row.pop();
|
||||
printTable(rows);
|
||||
console.log(`\n${found}/${missing.length} page(s) matched${WRITE ? `, ${found} written` : ""}`);
|
||||
@@ -1 +0,0 @@
|
||||
.blume-verify/
|
||||
@@ -1,21 +0,0 @@
|
||||
import { defineConfig } from "blume";
|
||||
|
||||
export default defineConfig({
|
||||
title: "Leetcode",
|
||||
description: "Documentation for LeetCode solutions",
|
||||
deployment: {
|
||||
site: "https://prdlk.github.io",
|
||||
base: "/leetcode",
|
||||
},
|
||||
content: {
|
||||
root: "content",
|
||||
},
|
||||
navigation: {
|
||||
tabs: [
|
||||
{
|
||||
label: "Reference",
|
||||
path: "/reference",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
---
|
||||
title: '1365. How Many Numbers Are Smaller Than the Current Number'
|
||||
description: Given the array nums, for each nums[i] find out how many numbers in the array are smaller than it. That is, for each nums[i] you have to count the number of valid j's such that j != i and nums[j] < nums[i]
|
||||
sidebar:
|
||||
label: 'How Many Numbers Are Smaller Than the Current Number'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [8,1,2,2,3]`
|
||||
- Output: `[4,0,1,1,3]`
|
||||
- Explanation: For `nums[0]=8` there exist four smaller numbers than it (`1`, `2`, `2` and `3`). For `nums[1]=1` does not exist any smaller number than it. For `nums[2]=2` there exist one smaller number than it (`1`). For `nums[3]=2` there exist one smaller number than it (`1`). For `nums[4]=3` there exist three smaller numbers than it (`1`, `2` and `2`).
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [6,5,4,8]`
|
||||
- Output: `[2,1,0,3]`
|
||||
|
||||
### Example 3:
|
||||
- Input: `nums = [7,7,7,7]`
|
||||
- Output: `[0,0,0,0]`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `2 <= nums.length <= 500`
|
||||
- `0 <= nums[i] <= 100`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["smallerNumbersThanCurrent(nums)"]) --> F["freq: count every value in nums"]
|
||||
F --> O["sorted = keys of freq, ascending"]
|
||||
O --> I["count = 0, smaller = {}"]
|
||||
I --> L{"more num in sorted?"}
|
||||
L -- yes --> A["smaller[num] = count — everything already passed is smaller"]
|
||||
A --> B["count += freq[num]"]
|
||||
B --> L
|
||||
L -- no --> M(["return nums.map(n => smaller[n])"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
/**
|
||||
* @param {number[]} nums
|
||||
* @return {number[]}
|
||||
*/
|
||||
|
||||
var smallerNumbersThanCurrent = function (nums) {
|
||||
// Step 1: Begin by initializing a [Frequency Map]()
|
||||
const freq = {};
|
||||
for (let n of nums) freq[n] = (freq[n] || 0) + 1;
|
||||
|
||||
// Step 2: Sort the numbers by ascending order
|
||||
const sorted = Object.keys(freq).sort((a, b) => a - b);
|
||||
|
||||
// Step 3: Init a count of numbers smaller than the active number
|
||||
let count = 0;
|
||||
|
||||
// Step 4: Init a map to track number of values smaller for each number
|
||||
const smaller = {};
|
||||
|
||||
// Step 5: Iterate over the sorted list
|
||||
for (let num of sorted) {
|
||||
// Set count for active number
|
||||
smaller[num] = count;
|
||||
|
||||
// Update the count by frequency
|
||||
count += freq[num];
|
||||
}
|
||||
|
||||
// Step 6: Use original list and find number of smaller values than it
|
||||
return nums.map((n) => smaller[n]);
|
||||
};
|
||||
```
|
||||
@@ -1,69 +0,0 @@
|
||||
---
|
||||
title: '1413. Minimum Value to Get Positive Step by Step Sum'
|
||||
description: Given an array of integers nums, you start with an initial positive value startValue
|
||||
sidebar:
|
||||
label: 'Minimum Value to Get Positive Step by Step Sum'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [-3,2,-3,4,2]`
|
||||
- Output: `5`
|
||||
- Explanation: If you choose `startValue = 4`, in the third iteration your step by step sum is less than `1`.
|
||||
|
||||
```
|
||||
step by step sum
|
||||
startValue = 4 | startValue = 5 | nums
|
||||
(4 -3 ) = 1 | (5 -3 ) = 2 | -3
|
||||
(1 +2 ) = 3 | (2 +2 ) = 4 | 2
|
||||
(3 -3 ) = 0 | (4 -3 ) = 1 | -3
|
||||
(0 +4 ) = 4 | (1 +4 ) = 5 | 4
|
||||
(4 +2 ) = 6 | (5 +2 ) = 7 | 2
|
||||
```
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [1,2]`
|
||||
- Output: `1`
|
||||
- Explanation: Minimum start value should be positive.
|
||||
|
||||
### Example 3:
|
||||
- Input: `nums = [1,-2,-3]`
|
||||
- Output: `5`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= nums.length <= 100`
|
||||
- `-100 <= nums[i] <= 100`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["minStartValue(nums)"]) --> I["prefix = [nums[0]]"]
|
||||
I --> L{"more i in 1..n-1?"}
|
||||
L -- yes --> P["prefix.push(prefix[i-1] + nums[i])"]
|
||||
P --> L
|
||||
L -- no --> M["min = smallest running total"]
|
||||
M --> R(["return max(1, 1 - min) — lift the dip to 1"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
/**
|
||||
* @param {number[]} nums
|
||||
* @return {number}
|
||||
*/
|
||||
var minStartValue = function(nums) {
|
||||
let prefix = [nums[0]];
|
||||
// Make prefix sum start at 1 after initializing seed value
|
||||
for (let i = 1; i < nums.length; i++){
|
||||
prefix.push(prefix[i - 1] + nums[i]);
|
||||
}
|
||||
|
||||
let min = Math.min(...prefix);
|
||||
return Math.max(1, 1 - min);
|
||||
};
|
||||
```
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
title: '1426. Counting Elements'
|
||||
description: Given an integer array arr, count how many elements x there are, such that x + 1 is also in arr. If there are duplicates in arr, count them separately
|
||||
sidebar:
|
||||
label: 'Counting Elements'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `arr = [1,2,3]`
|
||||
- Output: `2`
|
||||
- Explanation: `1` and `2` are counted cause `2` and `3` are in `arr`.
|
||||
|
||||
### Example 2:
|
||||
- Input: `arr = [1,1,3,3,5,5,7,7]`
|
||||
- Output: `0`
|
||||
- Explanation: No numbers are counted, cause there is no `2`, `4`, `6`, or `8` in `arr`.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= arr.length <= 1000`
|
||||
- `0 <= arr[i] <= 1000`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["countElements(arr)"]) --> I["arrSet = Set(arr), count = 0"]
|
||||
I --> L{"more n in arr?"}
|
||||
L -- no --> E(["return count"])
|
||||
L -- yes --> H{"arrSet has n + 1?"}
|
||||
H -- yes --> C["count++"]
|
||||
C --> L
|
||||
H -- no --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
/**
|
||||
* @param {number[]} arr
|
||||
* @return {number}
|
||||
*/
|
||||
var countElements = function(arr) {
|
||||
let arrSet = new Set(arr);
|
||||
let count = 0;
|
||||
|
||||
for (let n of arr) {
|
||||
let sum = n + 1;
|
||||
if (arrSet.has(sum)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
```
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
title: '1480. Running Sum of 1d Array'
|
||||
description: Given an array nums. We define a running sum of an array as runningSum[i] = sum(nums[0]…nums[i])
|
||||
sidebar:
|
||||
label: 'Running Sum of 1d Array'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,2,3,4]`
|
||||
- Output: `[1,3,6,10]`
|
||||
- Explanation: Running sum is obtained as follows: `[1, 1+2, 1+2+3, 1+2+3+4]`.
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [1,1,1,1,1]`
|
||||
- Output: `[1,2,3,4,5]`
|
||||
- Explanation: Running sum is obtained as follows: `[1, 1+1, 1+1+1, 1+1+1+1, 1+1+1+1+1]`.
|
||||
|
||||
### Example 3:
|
||||
- Input: `nums = [3,1,2,10,1]`
|
||||
- Output: `[3,4,6,16,17]`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= nums.length <= 1000`
|
||||
- `-10^6 <= nums[i] <= 10^6`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["runningSum(nums)"]) --> I["prefix = [nums[0]]"]
|
||||
I --> L{"more i in 1..n-1?"}
|
||||
L -- yes --> P["prefix.push(prefix[i-1] + nums[i])"]
|
||||
P --> L
|
||||
L -- no --> E(["return prefix"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
/**
|
||||
* @param {number[]} nums
|
||||
* @return {number[]}
|
||||
*/
|
||||
var runningSum = function(nums) {
|
||||
let prefix = [nums[0]];
|
||||
for (let i = 1; i < nums.length; i++){
|
||||
prefix.push(prefix[i - 1] + nums[i]);
|
||||
}
|
||||
return prefix;
|
||||
};
|
||||
```
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
title: '1636. Sort Array by Increasing Frequency'
|
||||
description: Given an array of integers nums, sort the array in increasing order based on the frequency of the values. If multiple values have the same frequency, sort them in decreasing order
|
||||
sidebar:
|
||||
label: 'Sort Array by Increasing Frequency'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,1,2,2,2,3]`
|
||||
- Output: `[3,1,1,2,2,2]`
|
||||
- Explanation: `'3'` has a frequency of `1`, `'1'` has a frequency of `2`, and `'2'` has a frequency of `3`.
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [2,3,1,3,2]`
|
||||
- Output: `[1,3,3,2,2]`
|
||||
- Explanation: `'2'` and `'3'` both have a frequency of `2`, so they are sorted in decreasing order.
|
||||
|
||||
### Example 3:
|
||||
- Input: `nums = [-1,1,-6,4,5,-6,1,4,1]`
|
||||
- Output: `[5,-1,4,4,-6,-6,1,1,1]`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= nums.length <= 100`
|
||||
- `-100 <= nums[i] <= 100`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["frequencySort(nums)"]) --> F["freq: count every value in nums"]
|
||||
F --> C["sort nums with a two-key comparator"]
|
||||
C --> K1["primary: freq[a] - freq[b] — rarer first"]
|
||||
C --> K2["tie-break: b - a — larger value first"]
|
||||
K1 --> R(["return nums"])
|
||||
K2 --> R
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
/**
|
||||
* @param {number[]} nums
|
||||
* @return {number[]}
|
||||
*/
|
||||
var frequencySort = function (nums) {
|
||||
const freq = {};
|
||||
for (let n of nums) freq[n] = (freq[n] || 0) + 1;
|
||||
return nums.sort((a, b) => freq[a] - freq[b] || b - a);
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="Evq1SfUbhBg" title="Sort Array by Increasing Frequency - Leetcode 1636 - Python" />
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
title: '268. Missing Number'
|
||||
description: Given an array nums containing n distinct numbers in the range [0, n], return the only number in the range that is missing from the array
|
||||
sidebar:
|
||||
label: 'Missing Number'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
::::warning
|
||||
Could you implement a solution using only O(1) extra space complexity and O(n) runtime complexity?
|
||||
::::
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [3,0,1]`
|
||||
- Output: `2`
|
||||
- Explanation: `n = 3` since there are `3` numbers, so all numbers are in the range `[0,3]`. `2` is the missing number in the range since it does not appear in `nums`.
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [0,1]`
|
||||
- Output: `2`
|
||||
- Explanation: `n = 2` since there are `2` numbers, so all numbers are in the range `[0,2]`. `2` is the missing number in the range since it does not appear in `nums`.
|
||||
|
||||
### Example 3:
|
||||
- Input: `nums = [9,6,4,2,3,5,7,0,1]`
|
||||
- Output: `8`
|
||||
- Explanation: `n = 9` since there are `9` numbers, so all numbers are in the range `[0,9]`. `8` is the missing number in the range since it does not appear in `nums`.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `n == nums.length`
|
||||
- `1 <= n <= 10^4`
|
||||
- `0 <= nums[i] <= n`
|
||||
- All the numbers of `nums` are unique.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["missingNumber(nums)"]) --> I["numSet = Set(nums), expectedCount = n + 1"]
|
||||
I --> L{"more i in 0..expectedCount-1?"}
|
||||
L -- yes --> Q{"numSet has i?"}
|
||||
Q -- no --> R(["return i"])
|
||||
Q -- yes --> L
|
||||
L -- no --> E(["return -1"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
/**
|
||||
* @param {number[]} nums
|
||||
* @return {number}
|
||||
*/
|
||||
var missingNumber = function(nums) {
|
||||
const numSet = new Set(nums);
|
||||
const expectedCount = nums.length + 1;
|
||||
|
||||
for (let i = 0; i < expectedCount; i++){
|
||||
if(!numSet.has(i)){
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="WnPLSRLSANE" title="Missing Number - Blind 75 - Leetcode 268 - Python" />
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
title: Introduction
|
||||
description: Welcome to your new Blume docs.
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||

|
||||
|
||||
Welcome to **Blume** — markdown-first docs powered by Astro and Vite.
|
||||
|
||||
Edit `docs/index.mdx` to get started, then run `blume dev`.
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
title: Progress
|
||||
description: Live SRS campaign dashboard — phases, ladder, review queue, gates, and recent attempts, fetched from the SRS Worker.
|
||||
---
|
||||
|
||||
Live view of the 56-day campaign. Everything below is fetched client-side
|
||||
from the SRS Worker, so it is always current — no rebuild required.
|
||||
|
||||
<ProgressDashboard />
|
||||
@@ -1,103 +0,0 @@
|
||||
---
|
||||
title: Sliding Window
|
||||
description: 'Sliding window is a specialized two-pointer family where the pointers define a contiguous interval and you maintain information about that entire interval as it moves.'
|
||||
---
|
||||
|
||||
:::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.
|
||||
|
||||
A problem belongs to this family when:
|
||||
|
||||
- the answer depends on the subarray or substring between left and right
|
||||
- you can update the window state incrementally as elements enter or leave
|
||||
- you maintain a condition that tells you whether the window is valid
|
||||
|
||||
## What is an Invariant?
|
||||
|
||||
|
||||
An invariant is a statement that stays true before and after every iteration.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
:::tip
|
||||
Here the window expands until invalid, then shrinks until valid again.
|
||||
:::
|
||||
|
||||

|
||||
|
||||
|
||||
### 3. Frequency-tracking windows
|
||||
|
||||
:::tip
|
||||
The difficulty comes from keeping the bookkeeping correct as characters enter and leave.
|
||||
:::
|
||||
|
||||

|
||||
|
||||
## How Sliding Window Differs From Other Two-Pointer Families
|
||||
|
||||
Compare it with Container With Most Water:
|
||||
|
||||
- In sliding window, the interval itself has meaning.
|
||||
- In container, the interval is just the remaining search space.
|
||||
|
||||
Compare it with Remove Duplicates:
|
||||
|
||||
- In sliding window, both pointers describe a live window.
|
||||
- In compaction, the left part of the array is already the answer prefix.
|
||||
|
||||
## A Practical Template
|
||||
|
||||
A common variable-size sliding window has this shape:
|
||||
|
||||
1. Add the new rightmost element.
|
||||
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.
|
||||
|
||||
As you work through this section, keep asking:
|
||||
|
||||
:::warning
|
||||
What invariant does my window maintain, and at what moment is it safe to update the answer?
|
||||
:::
|
||||
@@ -1,177 +0,0 @@
|
||||
# todo — draining the review backlog
|
||||
|
||||
every solution in `work/1` owes a blind re-solve at `work/3` and a second one at `work/7`. both buckets are empty, so 36 problems owe 72 re-solves and not one has been started. snapshot: `reviewqueues("2026-08-31")` from `apps/cli/work.ts` — what `bun run pick` shows — cross-checked against `/api/stats`.
|
||||
|
||||
at 10 problems a day *including* the campaign's own load, 72 re-solves do not fit in one week: this week absorbs 38, the `+3` rung finishes **sat sep 5** (campaign day 20), the last `+7` clears **fri sep 18** (day 33), and normal srs resumes **sat sep 19** — see [when standard srs resumes](#when-standard-srs-resumes). saturday gates are deliberately left out of the count.
|
||||
|
||||
## rules this plan follows
|
||||
|
||||
- **≤ 10 problems a day, everything counted.** each day's new core set and the on-time reviews it earns are booked first; the backlog takes what is left — 7 slots on a light day, 2 on a heavy one.
|
||||
- **coverage before depth.** all 36 `+3` re-solves come first — one blind pass over everything — before any problem gets its `+7`.
|
||||
- **oldest first.** lc 387 and lc 1365 have been sitting 50 days.
|
||||
- **≥ 3 days between a problem's own `+3` and `+7`.** two re-solves in one day test nothing.
|
||||
- **sunday is not a slot.** `workingday()` never mints one; neither does this list.
|
||||
- **re-solve blind, then push.** `bun run pick` (tab cycles to the 3-day and 7-day sections) scaffolds straight into `work/3`/`work/7`. do not open `work/1` first. pushing the file is the log: `close-solved.yml` posts the bucket to `/admin/solved` and d1 moves the problem up the ladder.
|
||||
|
||||
## this week
|
||||
|
||||
### mon aug 31 — 3 new + 7 backlog = 10/10
|
||||
|
||||
*topic 08 — linked list ([#10](https://github.com/prdlk/leetcode/issues/10), closed today)*
|
||||
|
||||
| bucket | problem | difficulty | where | why |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `work/1` | [lc 21 · merge two sorted lists](https://github.com/prdlk/leetcode/issues/78) | easy | linked list | done today |
|
||||
| `work/1` | [lc 206 · reverse linked list](https://github.com/prdlk/leetcode/issues/77) | easy | linked list | done today |
|
||||
| `work/1` | [lc 141 · linked list cycle](https://github.com/prdlk/leetcode/issues/79) | easy | hash table | done today |
|
||||
| `work/3` | lc 387 · first unique character in a string | easy | hash table | 50d stale — first solved 2026-07-12 |
|
||||
| `work/3` | lc 1365 · how many numbers are smaller than the current number | easy | array | 50d stale — first solved 2026-07-12 |
|
||||
| `work/3` | lc 268 · missing number | easy | array | 14d stale — first solved 2026-08-17 |
|
||||
| `work/3` | lc 344 · reverse string | easy | two pointers | 14d stale — first solved 2026-08-17 |
|
||||
| `work/3` | lc 1426 · counting elements | easy | array | 14d stale — first solved 2026-08-17 |
|
||||
| `work/3` | lc 1832 · check if the sentence is pangram | easy | hash table | 14d stale — first solved 2026-08-17 |
|
||||
| `work/3` | [lc 15 · 3sum](https://github.com/prdlk/leetcode/issues/43) | medium | array | 13d stale — first solved 2026-08-18 |
|
||||
|
||||
### tue sep 1 — 2 new + 8 backlog = 10/10
|
||||
|
||||
*topic 09 — hybrid structures ([#11](https://github.com/prdlk/leetcode/issues/11))*
|
||||
|
||||
| bucket | problem | difficulty | where | why |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `work/1` | [lc 146 · lru cache](https://github.com/prdlk/leetcode/issues/87) | medium | topic 09 — hybrid structures | new core problem |
|
||||
| `work/1` | [lc 2 · add two numbers](https://github.com/prdlk/leetcode/issues/88) | medium | topic 09 — hybrid structures | new core problem |
|
||||
| `work/3` | [lc 49 · group anagrams](https://github.com/prdlk/leetcode/issues/30) | medium | array | 13d stale — first solved 2026-08-18 |
|
||||
| `work/3` | [lc 125 · valid palindrome](https://github.com/prdlk/leetcode/issues/41) | easy | two pointers | 13d stale — first solved 2026-08-18 |
|
||||
| `work/3` | [lc 167 · two sum ii - input array is sorted](https://github.com/prdlk/leetcode/issues/42) | medium | array | 13d stale — first solved 2026-08-18 |
|
||||
| `work/3` | [lc 217 · contains duplicate](https://github.com/prdlk/leetcode/issues/27) | easy | array | 13d stale — first solved 2026-08-18 |
|
||||
| `work/3` | [lc 238 · product of array except self](https://github.com/prdlk/leetcode/issues/37) | medium | array | 13d stale — first solved 2026-08-18 |
|
||||
| `work/3` | [lc 242 · valid anagram](https://github.com/prdlk/leetcode/issues/28) | easy | hash table | 13d stale — first solved 2026-08-18 |
|
||||
| `work/3` | [lc 303 · range sum query - immutable](https://github.com/prdlk/leetcode/issues/36) | easy | array | 13d stale — first solved 2026-08-18 |
|
||||
| `work/3` | [lc 560 · subarray sum equals k](https://github.com/prdlk/leetcode/issues/38) | medium | array | 13d stale — first solved 2026-08-18 |
|
||||
|
||||
### wed sep 2 — 3 new + 7 backlog = 10/10
|
||||
|
||||
*topic 10 — matrix index math ([#12](https://github.com/prdlk/leetcode/issues/12))*
|
||||
|
||||
| bucket | problem | difficulty | where | why |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `work/1` | [lc 48 · rotate image](https://github.com/prdlk/leetcode/issues/91) | medium | topic 10 — matrix index math | new core problem |
|
||||
| `work/1` | [lc 54 · spiral matrix](https://github.com/prdlk/leetcode/issues/92) | medium | topic 10 — matrix index math | new core problem |
|
||||
| `work/1` | [lc 73 · set matrix zeroes](https://github.com/prdlk/leetcode/issues/93) | medium | topic 10 — matrix index math | new core problem |
|
||||
| `work/3` | lc 1413 · minimum value to get positive step by step sum | easy | array | 13d stale — first solved 2026-08-18 |
|
||||
| `work/3` | lc 1480 · running sum of 1d array | easy | array | 13d stale — first solved 2026-08-18 |
|
||||
| `work/3` | [lc 33 · search in rotated sorted array](https://github.com/prdlk/leetcode/issues/60) | medium | array | 12d stale — first solved 2026-08-19 |
|
||||
| `work/3` | [lc 704 · binary search](https://github.com/prdlk/leetcode/issues/58) | easy | array | 12d stale — first solved 2026-08-19 |
|
||||
| `work/3` | [lc 875 · koko eating bananas](https://github.com/prdlk/leetcode/issues/59) | medium | array | 12d stale — first solved 2026-08-19 |
|
||||
| `work/3` | [lc 977 · squares of a sorted array](https://github.com/prdlk/leetcode/issues/46) | easy | array | 12d stale — first solved 2026-08-19 |
|
||||
| `work/3` | [lc 121 · best time to buy and sell stock](https://github.com/prdlk/leetcode/issues/50) | easy | array | 11d stale — first solved 2026-08-20 |
|
||||
|
||||
### thu sep 3 — 3 new + 3 on-time reviews + 4 backlog = 10/10
|
||||
|
||||
*topic 11 — tree dfs ([#13](https://github.com/prdlk/leetcode/issues/13))*
|
||||
|
||||
| bucket | problem | difficulty | where | why |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `work/1` | [lc 226 · invert binary tree](https://github.com/prdlk/leetcode/issues/96) | easy | topic 11 — tree dfs | new core problem |
|
||||
| `work/1` | [lc 104 · maximum depth of binary tree](https://github.com/prdlk/leetcode/issues/97) | easy | topic 11 — tree dfs | new core problem |
|
||||
| `work/1` | [lc 236 · lowest common ancestor of a binary tree](https://github.com/prdlk/leetcode/issues/98) | medium | topic 11 — tree dfs | new core problem |
|
||||
| `work/3` | [lc 21 · merge two sorted lists](https://github.com/prdlk/leetcode/issues/78) | easy | linked list | on time — solved mon aug 31 |
|
||||
| `work/3` | [lc 206 · reverse linked list](https://github.com/prdlk/leetcode/issues/77) | easy | linked list | on time — solved mon aug 31 |
|
||||
| `work/3` | [lc 141 · linked list cycle](https://github.com/prdlk/leetcode/issues/79) | easy | hash table | on time — solved mon aug 31 |
|
||||
| `work/3` | [lc 1 · two sum](https://github.com/prdlk/leetcode/issues/29) | easy | array | 7d stale — first solved 2026-08-24 |
|
||||
| `work/3` | [lc 11 · container with most water](https://github.com/prdlk/leetcode/issues/44) | medium | array | 7d stale — first solved 2026-08-24 |
|
||||
| `work/3` | [lc 392 · is subsequence](https://github.com/prdlk/leetcode/issues/45) | easy | two pointers | 7d stale — first solved 2026-08-24 |
|
||||
| `work/3` | [lc 3 · longest substring without repeating characters](https://github.com/prdlk/leetcode/issues/51) | medium | hash table | 6d stale — first solved 2026-08-25 |
|
||||
|
||||
### fri sep 4 — 3 new + 2 on-time reviews + 5 backlog = 10/10
|
||||
|
||||
*topic 12 — tree bfs ([#14](https://github.com/prdlk/leetcode/issues/14))*
|
||||
|
||||
| bucket | problem | difficulty | where | why |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `work/1` | [lc 102 · binary tree level order traversal](https://github.com/prdlk/leetcode/issues/106) | medium | topic 12 — tree bfs | new core problem |
|
||||
| `work/1` | [lc 199 · binary tree right side view](https://github.com/prdlk/leetcode/issues/107) | medium | topic 12 — tree bfs | new core problem |
|
||||
| `work/1` | [lc 1448 · count good nodes in binary tree](https://github.com/prdlk/leetcode/issues/108) | medium | topic 12 — tree bfs | new core problem |
|
||||
| `work/3` | [lc 146 · lru cache](https://github.com/prdlk/leetcode/issues/87) | medium | topic 09 — hybrid structures | on time — solved tue sep 1 |
|
||||
| `work/3` | [lc 2 · add two numbers](https://github.com/prdlk/leetcode/issues/88) | medium | topic 09 — hybrid structures | on time — solved tue sep 1 |
|
||||
| `work/3` | [lc 424 · longest repeating character replacement](https://github.com/prdlk/leetcode/issues/52) | medium | hash table | 6d stale — first solved 2026-08-25 |
|
||||
| `work/3` | [lc 567 · permutation in string](https://github.com/prdlk/leetcode/issues/53) | medium | hash table | 6d stale — first solved 2026-08-25 |
|
||||
| `work/3` | [lc 35 · search insert position](https://github.com/prdlk/leetcode/issues/63) | easy | array | 5d stale — first solved 2026-08-26 |
|
||||
| `work/3` | [lc 74 · search a 2d matrix](https://github.com/prdlk/leetcode/issues/61) | medium | array | 5d stale — first solved 2026-08-26 |
|
||||
| `work/3` | [lc 153 · find minimum in rotated sorted array](https://github.com/prdlk/leetcode/issues/62) | medium | array | 5d stale — first solved 2026-08-26 |
|
||||
|
||||
### sat sep 5 — 3 on-time reviews + 7 backlog = 10/10
|
||||
|
||||
*no new topic — saturday.*
|
||||
|
||||
| bucket | problem | difficulty | where | why |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `work/3` | [lc 48 · rotate image](https://github.com/prdlk/leetcode/issues/91) | medium | topic 10 — matrix index math | on time — solved wed sep 2 |
|
||||
| `work/3` | [lc 54 · spiral matrix](https://github.com/prdlk/leetcode/issues/92) | medium | topic 10 — matrix index math | on time — solved wed sep 2 |
|
||||
| `work/3` | [lc 73 · set matrix zeroes](https://github.com/prdlk/leetcode/issues/93) | medium | topic 10 — matrix index math | on time — solved wed sep 2 |
|
||||
| `work/3` | [lc 20 · valid parentheses](https://github.com/prdlk/leetcode/issues/67) | easy | string | 4d stale — first solved 2026-08-27 |
|
||||
| `work/3` | [lc 150 · evaluate reverse polish notation](https://github.com/prdlk/leetcode/issues/69) | medium | array | 4d stale — first solved 2026-08-27 |
|
||||
| `work/3` | [lc 155 · min stack](https://github.com/prdlk/leetcode/issues/68) | medium | stack | 4d stale — first solved 2026-08-27 |
|
||||
| `work/3` | [lc 739 · daily temperatures](https://github.com/prdlk/leetcode/issues/72) | medium | array | 3d stale — first solved 2026-08-28 |
|
||||
| `work/3` | [lc 853 · car fleet](https://github.com/prdlk/leetcode/issues/73) | medium | array | 3d stale — first solved 2026-08-28 |
|
||||
| `work/7` | lc 387 · first unique character in a string | easy | hash table | `+3` was mon aug 31 |
|
||||
| `work/7` | lc 1365 · how many numbers are smaller than the current number | easy | array | `+3` was mon aug 31 |
|
||||
|
||||
### sun sep 6 — rest day
|
||||
|
||||
nothing. by design.
|
||||
|
||||
## carry-over
|
||||
|
||||
the 34 re-solves that do not fit this week, at the same cap and the same rules. booked = that day's new core set plus the on-time reviews it owes.
|
||||
|
||||
| day | topic | booked | backlog re-solves | load |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **mon sep 7** | topic 13 — binary search tree | 10 | *none — the day is already full* | 10/10 |
|
||||
| **tue sep 8** | topic 14 — heap / priority queue | 7 | `work/7` — lc 268, lc 344, lc 1426 | 10/10 |
|
||||
| **wed sep 9** | topic 15 — trie | 5 | `work/7` — lc 1832, lc 15, lc 49, lc 125, lc 167 | 10/10 |
|
||||
| **thu sep 10** | topic 16 — graph dfs | 9 | `work/7` — lc 217 | 10/10 |
|
||||
| **fri sep 11** | topic 17 — graph bfs | 8 | `work/7` — lc 238, lc 242 | 10/10 |
|
||||
| **sat sep 12** | *none — saturday* | 2 | `work/7` — lc 303, lc 560, lc 1413, lc 1480, lc 33, lc 704, lc 875, lc 977 | 10/10 |
|
||||
| **mon sep 14** | topic 18 — topological sort | 10 | *none — the day is already full* | 10/10 |
|
||||
| **tue sep 15** | topic 19 — union-find | 5 | `work/7` — lc 121, lc 1, lc 11, lc 392, lc 3 | 10/10 |
|
||||
| **wed sep 16** | topic 20 — backtracking | 5 | `work/7` — lc 424, lc 567, lc 35, lc 74, lc 153 | 10/10 |
|
||||
| **thu sep 17** | topic 21 — 1-d dynamic programming | 8 | `work/7` — lc 20, lc 150 | 10/10 |
|
||||
| **fri sep 18** | topic 22 — multi-d / grid dp | 7 | `work/7` — lc 155, lc 739, lc 853 | 10/10 |
|
||||
|
||||
## when standard srs resumes
|
||||
|
||||
- **sat sep 5** (campaign day 20) — the `+3` rung is empty. every one of the 36 has been recalled blind once; nothing is unretrieved any more.
|
||||
- **fri sep 18** (campaign day 33) — the last backlog `+7` lands. `work/3` and `work/7` hold a file for all 36, so `reviewqueues()` owes nothing; the 28 that have a d1 row are `retired` on the ladder, and the 8 listed at the bottom of this file never had one.
|
||||
- **sat sep 19 onward — standard srs.** the only reviews left are the ones the campaign itself minted: solve a problem, its `+3` and `+7` come back on `workingday(solve + 3)` and `workingday(solve + 7)`, the digest asks for ≤ `review_cap` (3) a day, `levelreviews()` pushes any excess forward, and `bun run pick`'s 3-day and 7-day sections show only what is due. no dated plan needed after this — the digest *is* the plan.
|
||||
|
||||
campaign-native load per working day once the backlog is gone, all of it inside the cap:
|
||||
|
||||
| day | topic | new | `work/3` | `work/7` | load |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| **sat sep 19** | *review-only* | 0 | 3 | 0 | 3/10 |
|
||||
| **mon sep 21** | topic 23 — greedy | 3 | 5 | 2 | 10/10 |
|
||||
| **tue sep 22** | topic 24 — intervals | 3 | 1 | 2 | 6/10 |
|
||||
| **wed sep 23** | *review-only* | 0 | 0 | 3 | 3/10 |
|
||||
| **thu sep 24** | *review-only* | 0 | 3 | 3 | 6/10 |
|
||||
| **fri sep 25** | *review-only* | 0 | 3 | 3 | 6/10 |
|
||||
| **sat sep 26** | *review-only* | 0 | 0 | 0 | 0/10 |
|
||||
| **mon sep 28** | *review-only* | 0 | 0 | 3 | 3/10 |
|
||||
| **tue sep 29** | *review-only* | 0 | 0 | 3 | 3/10 |
|
||||
|
||||
two things to watch there. **mon sep 21 is 10/10 on campaign load alone** — topic 23's core set, two `+7`s, and five of the six `+3`s that land there (thursday's three slide off sunday onto monday, friday's three land natively, and the sixth is pushed to tuesday exactly as `levelreviews()` would) — so one slipped day re-opens a backlog. and from **wed sep 30** the ladder runs dry: `apps/api/data/schedule.json` books its last topic tue sep 22, so the review-only weeks that close the campaign are drills, optional sets and the weekly gates, none of which this file counts.
|
||||
|
||||
## not reviews
|
||||
|
||||
three july scaffolds in `work/1` are still empty, so they owe no re-solve — they are unsolved problems, and `bun run pick` still lists them in its new-problem section:
|
||||
|
||||
| problem | difficulty |
|
||||
| --- | --- |
|
||||
| [lc 347 · top k frequent elements](https://github.com/prdlk/leetcode/issues/31) | medium |
|
||||
| lc 451 · sort characters by frequency | medium |
|
||||
| lc 1636 · sort array by increasing frequency | easy |
|
||||
|
||||
## what this plan exposes
|
||||
|
||||
- **8 of the 36 have no problem issue** (387, 1365, 268, 344, 1426, 1832, 1413, 1480) — pre-campaign solves. the catalog reconcile never invents rows, so d1 does not know them and no digest can ever surface them. `bun run pick` is the only thing that will ask for these reviews.
|
||||
- **`/api/stats` reports a stage that does not exist**: `ladder: { new: 128, "+3": 3, "+7": 0, retired: 0, "+2": 30 }`. `+2` is not in `stages` (`apps/api/src/srs.ts`), so 30 rows sit on a pre-`0002` rung — which is why the digest queue plateaus at 32 due instead of tracking the tables above. that wants a migration, not a re-solve.
|
||||
@@ -1,428 +0,0 @@
|
||||
/**
|
||||
* Progress dashboard island — the expanded, always-current version of the
|
||||
* daily email footer. Rendered on /progress; fetches campaign state from the
|
||||
* SRS Worker at page load, entirely client-side, so `blume build` never
|
||||
* depends on the API being up. API_BASE is a placeholder stamped in after the
|
||||
* Worker deploys — do not inline it anywhere else.
|
||||
*
|
||||
* Invariants: the component never throws on malformed API data (a shape guard
|
||||
* downgrades to the error state), and the first frame is always the loading
|
||||
* state so server and client render identically.
|
||||
*/
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
|
||||
export const client = "load";
|
||||
|
||||
const API_BASE = "https://srs-api.prdlk.workers.dev";
|
||||
|
||||
// ── /api/stats response shape ────────────────────────────────────
|
||||
|
||||
// Mirrors STAGES in apps/api/src/srs.ts — the ladder rungs, in order. The
|
||||
// Worker ships every rung in `ladder`, zeros included.
|
||||
const STAGES = ["new", "+3", "+7", "retired"] as const;
|
||||
|
||||
type Stage = (typeof STAGES)[number];
|
||||
|
||||
interface Stats {
|
||||
generated: string;
|
||||
campaign: { day: number; week: number; start: string; days: number };
|
||||
phases: {
|
||||
milestone: number;
|
||||
name: string;
|
||||
core_done: number;
|
||||
core_total: number;
|
||||
optional_done: number;
|
||||
optional_total: number;
|
||||
deferred_done: number;
|
||||
deferred_total: number;
|
||||
}[];
|
||||
ladder: Record<Stage, number>;
|
||||
/** week is the 1-based campaign week — the Worker converts from ISO. */
|
||||
gates: {
|
||||
week: number;
|
||||
issue: number | null;
|
||||
pass_rate: number | null;
|
||||
closed_on: string | null;
|
||||
}[];
|
||||
streak: number;
|
||||
queue: { date: string; due: number }[];
|
||||
/** Every campaign day, zeros included; this page reads only the tail. */
|
||||
heat: { date: string; attempts: number; passes: number }[];
|
||||
}
|
||||
|
||||
function isStats(value: unknown): value is Stats {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof v.campaign === "object" &&
|
||||
v.campaign !== null &&
|
||||
typeof v.ladder === "object" &&
|
||||
v.ladder !== null &&
|
||||
Array.isArray(v.phases) &&
|
||||
Array.isArray(v.gates) &&
|
||||
Array.isArray(v.queue) &&
|
||||
Array.isArray(v.heat) &&
|
||||
typeof v.streak === "number"
|
||||
);
|
||||
}
|
||||
|
||||
// ── formatting ───────────────────────────────────────────────────
|
||||
|
||||
/** Legacy gates stored integer percent; the Worker may emit a 0–1 fraction. */
|
||||
function percent(rate: number): string {
|
||||
return `${Math.round(rate <= 1 ? rate * 100 : rate)}%`;
|
||||
}
|
||||
|
||||
/** "2026-08-24" → "Mon 24" without timezone drift. */
|
||||
function dayLabel(iso: string): string {
|
||||
const d = new Date(`${iso}T00:00:00`);
|
||||
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
|
||||
return `${weekday} ${iso.slice(8)}`;
|
||||
}
|
||||
|
||||
// ── shared styles ────────────────────────────────────────────────
|
||||
// Theme tokens only, with two deliberate exceptions: the phase-bar sets get
|
||||
// fixed hues (accent-blue core, yellow optional, red deferred — they must be
|
||||
// distinguishable, not shades of one colour), and the last-7-days heatmap
|
||||
// reuses the GREENS ramp from apps/api/src/charts.ts so the page matches the
|
||||
// README/email heatmaps (level 0 stays a theme token so light mode works).
|
||||
|
||||
const card: CSSProperties = {
|
||||
border: "1px solid var(--blume-border)",
|
||||
borderRadius: "var(--blume-radius)",
|
||||
padding: "0.75rem 1rem",
|
||||
marginBottom: "1rem",
|
||||
};
|
||||
|
||||
const muted: CSSProperties = {
|
||||
color: "var(--blume-muted-foreground)",
|
||||
fontSize: "0.85em",
|
||||
};
|
||||
|
||||
const heading: CSSProperties = {
|
||||
fontWeight: 600,
|
||||
marginBottom: "0.5rem",
|
||||
};
|
||||
|
||||
function Bar({ done, total }: { done: number; total: number }) {
|
||||
const ratio = total > 0 ? Math.min(done / total, 1) : 0;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "10rem",
|
||||
maxWidth: "40vw",
|
||||
height: "0.5rem",
|
||||
borderRadius: "var(--blume-radius)",
|
||||
background: "var(--blume-muted)",
|
||||
verticalAlign: "middle",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
width: `${ratio * 100}%`,
|
||||
height: "100%",
|
||||
background: "var(--blume-accent)",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── sections ─────────────────────────────────────────────────────
|
||||
|
||||
function Header({ stats }: { stats: Stats }) {
|
||||
const { campaign, streak } = stats;
|
||||
return (
|
||||
<div style={{ ...card, display: "flex", gap: "1.5rem", flexWrap: "wrap" }}>
|
||||
<span>
|
||||
<strong>
|
||||
Day {campaign.day}/{campaign.days}
|
||||
</strong>
|
||||
</span>
|
||||
<span>Week {campaign.week}</span>
|
||||
<span>
|
||||
Streak: <strong>{streak}</strong> day{streak === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span style={muted}>started {campaign.start}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One stacked bar per phase: solved core / optional / deferred segments over
|
||||
// a muted track sized by the phase's total problem count, so both phase size
|
||||
// and progress read at a glance.
|
||||
const SETS = [
|
||||
["core", "var(--blume-accent)"],
|
||||
["optional", "#d29922"],
|
||||
["deferred", "#e5534b"],
|
||||
] as const;
|
||||
|
||||
function Phases({ stats }: { stats: Stats }) {
|
||||
const maxTotal = Math.max(
|
||||
1,
|
||||
...stats.phases.map((p) => p.core_total + p.optional_total + p.deferred_total),
|
||||
);
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={heading}>Phases</div>
|
||||
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap", marginBottom: "0.6rem" }}>
|
||||
{SETS.map(([set, color]) => (
|
||||
<span key={set} style={muted}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "0.65rem",
|
||||
height: "0.65rem",
|
||||
borderRadius: "2px",
|
||||
background: color,
|
||||
marginRight: "0.35rem",
|
||||
}}
|
||||
/>
|
||||
{set}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{stats.phases.map((p) => {
|
||||
const total = p.core_total + p.optional_total + p.deferred_total;
|
||||
const done = p.core_done + p.optional_done + p.deferred_done;
|
||||
return (
|
||||
<div
|
||||
key={p.milestone}
|
||||
title={`core ${p.core_done}/${p.core_total} · optional ${p.optional_done}/${p.optional_total} · deferred ${p.deferred_done}/${p.deferred_total}`}
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.75rem", padding: "0.25rem 0" }}
|
||||
>
|
||||
<span style={{ width: "9.5rem", flexShrink: 0 }}>{p.name}</span>
|
||||
<span style={{ flex: 1 }}>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
width: `${(total / maxTotal) * 100}%`,
|
||||
height: "0.65rem",
|
||||
borderRadius: "var(--blume-radius)",
|
||||
background: "var(--blume-muted)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{SETS.map(([set, color]) => {
|
||||
const solved = p[`${set}_done`];
|
||||
return solved > 0 ? (
|
||||
<span
|
||||
key={set}
|
||||
style={{ width: `${(solved / total) * 100}%`, background: color }}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ ...muted, width: "3.5rem", textAlign: "right", flexShrink: 0 }}>
|
||||
{done}/{total}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Ladder({ stats }: { stats: Stats }) {
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={heading}>SRS ladder</div>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{STAGES.map((stage) => (
|
||||
<span
|
||||
key={stage}
|
||||
style={{
|
||||
background: "var(--blume-muted)",
|
||||
borderRadius: "var(--blume-radius)",
|
||||
padding: "0.35rem 0.75rem",
|
||||
}}
|
||||
>
|
||||
<span style={muted}>{stage}</span>{" "}
|
||||
<strong>{stats.ladder[stage] ?? 0}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Queue({ stats }: { stats: Stats }) {
|
||||
const max = Math.max(1, ...stats.queue.map((q) => q.due));
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={heading}>Review queue — next 14 days</div>
|
||||
<table style={{ borderCollapse: "collapse" }}>
|
||||
<tbody>
|
||||
{stats.queue.map((q) => (
|
||||
<tr key={q.date}>
|
||||
<td style={{ ...muted, padding: "0.1rem 1rem 0.1rem 0" }}>
|
||||
{dayLabel(q.date)}
|
||||
</td>
|
||||
<td style={{ padding: "0.1rem 0.75rem 0.1rem 0" }}>
|
||||
<Bar done={q.due} total={max} />
|
||||
</td>
|
||||
<td>{q.due}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Gates({ stats }: { stats: Stats }) {
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={heading}>Gate log</div>
|
||||
{stats.gates.length === 0 ? (
|
||||
<span style={muted}>No gates yet — the first weekly review opens Saturday night.</span>
|
||||
) : (
|
||||
stats.gates.map((g) => (
|
||||
<div
|
||||
key={g.week}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
alignItems: "baseline",
|
||||
flexWrap: "wrap",
|
||||
padding: "0.15rem 0",
|
||||
}}
|
||||
>
|
||||
<span style={{ width: "4.5rem", flexShrink: 0 }}>Week {g.week}</span>
|
||||
{g.issue === null ? (
|
||||
<span style={muted}>no review issue</span>
|
||||
) : (
|
||||
<a
|
||||
href={`https://github.com/prdlk/leetcode/issues/${g.issue}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
#{g.issue}
|
||||
</a>
|
||||
)}
|
||||
{g.pass_rate === null ? (
|
||||
<span style={muted}>in progress</span>
|
||||
) : (
|
||||
<span>
|
||||
{percent(g.pass_rate)} passed
|
||||
{g.closed_on && <span style={muted}> · closed {g.closed_on}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div style={{ ...muted, marginTop: "0.4rem" }}>
|
||||
One gate per campaign week — scored when its review issue closes.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// GREENS ramp from apps/api/src/charts.ts; level 0 swapped for the theme's
|
||||
// muted token so an empty day looks right in both colour schemes.
|
||||
const GREENS = [
|
||||
"var(--blume-muted)",
|
||||
"#0e4429",
|
||||
"#006d32",
|
||||
"#26a641",
|
||||
"#39d353",
|
||||
];
|
||||
|
||||
function Recent({ stats }: { stats: Stats }) {
|
||||
// heat spans the whole campaign, future days and all. The window wanted here
|
||||
// is the seven days ending today, and campaign.day is that day, 1-based.
|
||||
const week = stats.heat.slice(Math.max(0, stats.campaign.day - 7), stats.campaign.day);
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={heading}>Last 7 days</div>
|
||||
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
|
||||
{week.map((r) => (
|
||||
<span
|
||||
key={r.date}
|
||||
title={`${r.date}: ${r.passes}/${r.attempts} passed`}
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
width: "2.6rem",
|
||||
height: "2rem",
|
||||
borderRadius: "var(--blume-radius)",
|
||||
background: GREENS[Math.min(r.attempts, 4)],
|
||||
}}
|
||||
/>
|
||||
<span style={{ ...muted, display: "block", marginTop: "0.25rem", fontSize: "0.75em" }}>
|
||||
{dayLabel(r.date)}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ ...muted, marginTop: "0.5rem", display: "flex", alignItems: "center", gap: "0.25rem" }}>
|
||||
<span>Less</span>
|
||||
{GREENS.map((color) => (
|
||||
<span
|
||||
key={color}
|
||||
style={{ display: "inline-block", width: "0.65rem", height: "0.65rem", borderRadius: "2px", background: color }}
|
||||
/>
|
||||
))}
|
||||
<span>More · colour = attempts, hover for passes</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── dashboard ────────────────────────────────────────────────────
|
||||
|
||||
type Load =
|
||||
| { phase: "loading" }
|
||||
| { phase: "error" }
|
||||
| { phase: "ready"; stats: Stats };
|
||||
|
||||
export default function ProgressDashboard() {
|
||||
const [load, setLoad] = useState<Load>({ phase: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch(`${API_BASE}/api/stats`, { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((body: unknown) => {
|
||||
setLoad(isStats(body) ? { phase: "ready", stats: body } : { phase: "error" });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setLoad({ phase: "error" });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
if (load.phase === "loading") {
|
||||
return <p style={muted}>Loading progress…</p>;
|
||||
}
|
||||
if (load.phase === "error") {
|
||||
return (
|
||||
<p style={muted}>
|
||||
API unreachable — stats are served live from the SRS Worker and it did
|
||||
not respond.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const { stats } = load;
|
||||
return (
|
||||
<div>
|
||||
<Header stats={stats} />
|
||||
<Phases stats={stats} />
|
||||
<Ladder stats={stats} />
|
||||
<Queue stats={stats} />
|
||||
<Gates stats={stats} />
|
||||
<Recent stats={stats} />
|
||||
<p style={muted}>Generated {stats.generated}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "blume dev",
|
||||
"build": "blume build",
|
||||
"doctor": "blume doctor"
|
||||
},
|
||||
"dependencies": {
|
||||
"blume": "^1.5.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LeetCode Dependency Spine</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--color-paper: #020202;
|
||||
--color-ink: #f2f2f2;
|
||||
--color-muted: #989898;
|
||||
--color-accent: #449df0;
|
||||
--font-sans: 'Inter', system-ui, sans-serif;
|
||||
--font-serif: 'Instrument Serif', serif;
|
||||
--font-mono: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--color-paper);
|
||||
color: var(--color-ink);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.frame { max-width: 1360px; width: 100%; }
|
||||
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--font-serif);
|
||||
font-size: clamp(1.5rem, 2.4vw + 0.75rem, 2rem);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.15;
|
||||
color: var(--color-ink);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
svg { width: 100%; min-width: 900px; display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="frame">
|
||||
<p class="eyebrow">prdlk / leetcode · dependency spine</p>
|
||||
<h1>How the 24 topics build on each other</h1>
|
||||
|
||||
<svg viewBox="0 0 1472 648" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="dependency-spine-title dependency-spine-desc">
|
||||
<title id="dependency-spine-title">LeetCode topic dependency spine</title>
|
||||
<desc id="dependency-spine-desc">Dependency graph of 24 LeetCode study topics across five phases, showing which topics build on which, with Tree DFS and 1-D dynamic programming as the two central hubs.</desc>
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#989898"/></marker>
|
||||
<marker id="arrow-accent" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#449df0"/></marker>
|
||||
<marker id="arrow-link" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#6ab0f5"/></marker>
|
||||
</defs>
|
||||
|
||||
<rect width="100%" height="100%" fill="#020202"/>
|
||||
|
||||
<!-- ============ zones (painted first) ============ -->
|
||||
<rect x="40" y="40" width="368" height="312" rx="8" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
|
||||
<rect x="56" y="44" width="168" height="12" rx="2" fill="#020202"/>
|
||||
<text x="60" y="53" fill="rgba(242,242,242,0.40)" font-size="7" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">PHASE I — LINEAR STRUCTURES</text>
|
||||
|
||||
<rect x="456" y="40" width="368" height="312" rx="8" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
|
||||
<rect x="472" y="44" width="152" height="12" rx="2" fill="#020202"/>
|
||||
<text x="476" y="53" fill="rgba(242,242,242,0.40)" font-size="7" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">PHASE III — HIERARCHICAL</text>
|
||||
|
||||
<rect x="456" y="384" width="368" height="168" rx="8" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
|
||||
<rect x="472" y="388" width="144" height="12" rx="2" fill="#020202"/>
|
||||
<text x="476" y="397" fill="rgba(242,242,242,0.40)" font-size="7" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">PHASE II — NODAL & GRID</text>
|
||||
|
||||
<rect x="872" y="40" width="560" height="312" rx="8" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
|
||||
<rect x="888" y="44" width="264" height="12" rx="2" fill="#020202"/>
|
||||
<text x="892" y="53" fill="rgba(242,242,242,0.40)" font-size="7" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">PHASES IV–V — RELATIONAL & DECISION-SPACE</text>
|
||||
|
||||
<!-- ============ arrows (before boxes) ============ -->
|
||||
<!-- same-row horizontals -->
|
||||
<line x1="200" y1="168" x2="248" y2="168" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 3 → 4 -->
|
||||
<line x1="200" y1="240" x2="248" y2="240" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 6 → 7 -->
|
||||
<line x1="200" y1="312" x2="248" y2="312" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 1 → 2 -->
|
||||
<line x1="616" y1="440" x2="664" y2="440" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 8 → 9 -->
|
||||
<line x1="1032" y1="96" x2="1080" y2="96" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 16 → 18 -->
|
||||
<line x1="1224" y1="240" x2="1272" y2="240" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 21 → 22 -->
|
||||
<line x1="1224" y1="312" x2="1272" y2="312" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 23 → 24 -->
|
||||
<line x1="616" y1="84" x2="888" y2="84" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 11 → 16 -->
|
||||
<line x1="616" y1="312" x2="888" y2="312" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 12 → 17 -->
|
||||
<!-- same-column verticals -->
|
||||
<line x1="544" y1="120" x2="544" y2="144" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 11 → 13 -->
|
||||
<line x1="960" y1="120" x2="960" y2="144" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 16 → 19 -->
|
||||
<line x1="1152" y1="120" x2="1152" y2="144" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 18 → 20 -->
|
||||
<line x1="1152" y1="192" x2="1152" y2="216" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 20 → 21 -->
|
||||
<line x1="1152" y1="264" x2="1152" y2="288" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 21 → 23 -->
|
||||
<!-- rounded right-angle elbows -->
|
||||
<path d="M200,96 H416 Q424,96 424,104 V160 Q424,168 432,168 H472" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 5 → 13 -->
|
||||
<path d="M616,96 H640 Q648,96 648,104 V160 Q648,168 656,168 H664" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 11 → 14 -->
|
||||
<path d="M616,108 H624 Q632,108 632,116 V232 Q632,240 640,240 H664" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 11 → 15 -->
|
||||
<path d="M392,184 H432 Q440,184 440,192 V504 Q440,512 448,512 H472" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 4 → 10 -->
|
||||
<path d="M128,336 V560 Q128,568 136,568 H728 Q736,568 736,560 V464" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 1 → 9 -->
|
||||
|
||||
<!-- ============ nodes ============ -->
|
||||
<!-- 05 Binary Search — entry -->
|
||||
<rect x="56" y="72" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="56" y="72" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
|
||||
<rect x="64" y="78" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="76" y="87" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">05</text>
|
||||
<text x="128" y="104" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Binary Search</text>
|
||||
|
||||
<!-- 03 Two Pointers — entry -->
|
||||
<rect x="56" y="144" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="56" y="144" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
|
||||
<rect x="64" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="76" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">03</text>
|
||||
<text x="128" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Two Pointers</text>
|
||||
|
||||
<!-- 04 Sliding Window -->
|
||||
<rect x="248" y="144" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="248" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="256" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="268" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">04</text>
|
||||
<text x="320" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Sliding Window</text>
|
||||
|
||||
<!-- 06 Stack — entry -->
|
||||
<rect x="56" y="216" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="56" y="216" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
|
||||
<rect x="64" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="76" y="231" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">06</text>
|
||||
<text x="128" y="248" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Stack</text>
|
||||
|
||||
<!-- 07 Monotonic Stack -->
|
||||
<rect x="248" y="216" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="248" y="216" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="256" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="268" y="231" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">07</text>
|
||||
<text x="320" y="248" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Monotonic Stack</text>
|
||||
|
||||
<!-- 01 Hash-Based Lookup — entry -->
|
||||
<rect x="56" y="288" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="56" y="288" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
|
||||
<rect x="64" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="76" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">01</text>
|
||||
<text x="128" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Hash Lookup</text>
|
||||
|
||||
<!-- 02 Prefix Sum -->
|
||||
<rect x="248" y="288" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="248" y="288" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="256" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="268" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">02</text>
|
||||
<text x="320" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Prefix Sum</text>
|
||||
|
||||
<!-- 11 Tree DFS — FOCAL -->
|
||||
<rect x="472" y="72" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="472" y="72" width="144" height="48" rx="6" fill="rgba(68,157,240,0.12)" stroke="#449df0" stroke-width="1.2"/>
|
||||
<rect x="480" y="78" width="24" height="12" rx="2" fill="transparent" stroke="rgba(68,157,240,0.50)" stroke-width="0.8"/>
|
||||
<text x="492" y="87" fill="#449df0" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">11</text>
|
||||
<text x="544" y="104" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Tree DFS</text>
|
||||
|
||||
<!-- 13 Binary Search Tree -->
|
||||
<rect x="472" y="144" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="472" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="480" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="492" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">13</text>
|
||||
<text x="544" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Binary Search Tree</text>
|
||||
|
||||
<!-- 14 Heap -->
|
||||
<rect x="664" y="144" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="664" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="672" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="684" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">14</text>
|
||||
<text x="736" y="170" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Heap</text>
|
||||
<text x="736" y="184" fill="#989898" font-size="9" font-family="'IBM Plex Mono', monospace" text-anchor="middle">priority queue</text>
|
||||
|
||||
<!-- 15 Trie -->
|
||||
<rect x="664" y="216" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="664" y="216" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="672" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="684" y="231" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">15</text>
|
||||
<text x="736" y="248" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Trie</text>
|
||||
|
||||
<!-- 12 Tree BFS — entry -->
|
||||
<rect x="472" y="288" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="472" y="288" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
|
||||
<rect x="480" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="492" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">12</text>
|
||||
<text x="544" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Tree BFS</text>
|
||||
|
||||
<!-- 08 Linked List — entry -->
|
||||
<rect x="472" y="416" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="472" y="416" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
|
||||
<rect x="480" y="422" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="492" y="431" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">08</text>
|
||||
<text x="544" y="448" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Linked List</text>
|
||||
|
||||
<!-- 09 Hybrid Structures -->
|
||||
<rect x="664" y="416" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="664" y="416" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="672" y="422" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="684" y="431" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">09</text>
|
||||
<text x="736" y="448" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Hybrid Structures</text>
|
||||
|
||||
<!-- 10 Matrix Index Math -->
|
||||
<rect x="472" y="488" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="472" y="488" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="480" y="494" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="492" y="503" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">10</text>
|
||||
<text x="544" y="520" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Matrix Index Math</text>
|
||||
|
||||
<!-- 16 Graph DFS -->
|
||||
<rect x="888" y="72" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="888" y="72" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="896" y="78" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="908" y="87" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">16</text>
|
||||
<text x="960" y="104" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Graph DFS</text>
|
||||
|
||||
<!-- 18 Topological Sort -->
|
||||
<rect x="1080" y="72" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="1080" y="72" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="1088" y="78" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="1100" y="87" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">18</text>
|
||||
<text x="1152" y="104" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Topological Sort</text>
|
||||
|
||||
<!-- 19 Union-Find -->
|
||||
<rect x="888" y="144" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="888" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="896" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="908" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">19</text>
|
||||
<text x="960" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Union-Find</text>
|
||||
|
||||
<!-- 20 Backtracking -->
|
||||
<rect x="1080" y="144" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="1080" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="1088" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="1100" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">20</text>
|
||||
<text x="1152" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Backtracking</text>
|
||||
|
||||
<!-- 21 1-D DP — FOCAL -->
|
||||
<rect x="1080" y="216" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="1080" y="216" width="144" height="48" rx="6" fill="rgba(68,157,240,0.12)" stroke="#449df0" stroke-width="1.2"/>
|
||||
<rect x="1088" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(68,157,240,0.50)" stroke-width="0.8"/>
|
||||
<text x="1100" y="231" fill="#449df0" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">21</text>
|
||||
<text x="1152" y="242" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">1-D DP</text>
|
||||
<text x="1152" y="256" fill="#989898" font-size="9" font-family="'IBM Plex Mono', monospace" text-anchor="middle">dynamic programming</text>
|
||||
|
||||
<!-- 22 Multi-D / Grid DP -->
|
||||
<rect x="1272" y="216" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="1272" y="216" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="1280" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="1292" y="231" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">22</text>
|
||||
<text x="1344" y="242" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Multi-D / Grid DP</text>
|
||||
|
||||
<!-- 17 Graph BFS -->
|
||||
<rect x="888" y="288" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="888" y="288" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="896" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="908" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">17</text>
|
||||
<text x="960" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Graph BFS</text>
|
||||
|
||||
<!-- 23 Greedy -->
|
||||
<rect x="1080" y="288" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="1080" y="288" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="1088" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="1100" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">23</text>
|
||||
<text x="1152" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Greedy</text>
|
||||
|
||||
<!-- 24 Intervals -->
|
||||
<rect x="1272" y="288" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="1272" y="288" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="1280" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="1292" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">24</text>
|
||||
<text x="1344" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Intervals</text>
|
||||
|
||||
<!-- ============ legend ============ -->
|
||||
<line x1="40" y1="600" x2="1432" y2="600" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
|
||||
<text x="40" y="620" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">LEGEND</text>
|
||||
|
||||
<rect x="160" y="608" width="20" height="12" rx="2" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
|
||||
<text x="188" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">ENTRY — NO PREREQS</text>
|
||||
|
||||
<rect x="360" y="608" width="20" height="12" rx="2" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<text x="388" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">TOPIC</text>
|
||||
|
||||
<rect x="480" y="608" width="20" height="12" rx="2" fill="rgba(68,157,240,0.12)" stroke="#449df0" stroke-width="1.2"/>
|
||||
<text x="508" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">FOCAL HUB</text>
|
||||
|
||||
<line x1="624" y1="614" x2="656" y2="614" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/>
|
||||
<text x="668" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">BUILDS ON</text>
|
||||
|
||||
<rect x="792" y="608" width="20" height="12" rx="2" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
|
||||
<text x="820" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">PHASE ZONE</text>
|
||||
</svg>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,244 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 1472 648" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="dependency-spine-title dependency-spine-desc">
|
||||
<title id="dependency-spine-title">LeetCode topic dependency spine</title>
|
||||
<desc id="dependency-spine-desc">Dependency graph of 24 LeetCode study topics across five phases, showing which topics build on which, with Tree DFS and 1-D dynamic programming as the two central hubs.</desc>
|
||||
<defs>
|
||||
<style>@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500;600&display=swap');</style>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#989898"/></marker>
|
||||
<marker id="arrow-accent" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#449df0"/></marker>
|
||||
<marker id="arrow-link" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#6ab0f5"/></marker>
|
||||
</defs>
|
||||
|
||||
<rect width="100%" height="100%" fill="#020202"/>
|
||||
|
||||
<!-- ============ zones (painted first) ============ -->
|
||||
<rect x="40" y="40" width="368" height="312" rx="8" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
|
||||
<rect x="56" y="44" width="168" height="12" rx="2" fill="#020202"/>
|
||||
<text x="60" y="53" fill="rgba(242,242,242,0.40)" font-size="7" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">PHASE I — LINEAR STRUCTURES</text>
|
||||
|
||||
<rect x="456" y="40" width="368" height="312" rx="8" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
|
||||
<rect x="472" y="44" width="152" height="12" rx="2" fill="#020202"/>
|
||||
<text x="476" y="53" fill="rgba(242,242,242,0.40)" font-size="7" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">PHASE III — HIERARCHICAL</text>
|
||||
|
||||
<rect x="456" y="384" width="368" height="168" rx="8" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
|
||||
<rect x="472" y="388" width="144" height="12" rx="2" fill="#020202"/>
|
||||
<text x="476" y="397" fill="rgba(242,242,242,0.40)" font-size="7" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">PHASE II — NODAL & GRID</text>
|
||||
|
||||
<rect x="872" y="40" width="560" height="312" rx="8" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
|
||||
<rect x="888" y="44" width="264" height="12" rx="2" fill="#020202"/>
|
||||
<text x="892" y="53" fill="rgba(242,242,242,0.40)" font-size="7" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">PHASES IV–V — RELATIONAL & DECISION-SPACE</text>
|
||||
|
||||
<!-- ============ arrows (before boxes) ============ -->
|
||||
<!-- same-row horizontals -->
|
||||
<line x1="200" y1="168" x2="248" y2="168" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 3 → 4 -->
|
||||
<line x1="200" y1="240" x2="248" y2="240" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 6 → 7 -->
|
||||
<line x1="200" y1="312" x2="248" y2="312" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 1 → 2 -->
|
||||
<line x1="616" y1="440" x2="664" y2="440" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 8 → 9 -->
|
||||
<line x1="1032" y1="96" x2="1080" y2="96" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 16 → 18 -->
|
||||
<line x1="1224" y1="240" x2="1272" y2="240" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 21 → 22 -->
|
||||
<line x1="1224" y1="312" x2="1272" y2="312" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 23 → 24 -->
|
||||
<line x1="616" y1="84" x2="888" y2="84" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 11 → 16 -->
|
||||
<line x1="616" y1="312" x2="888" y2="312" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 12 → 17 -->
|
||||
<!-- same-column verticals -->
|
||||
<line x1="544" y1="120" x2="544" y2="144" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 11 → 13 -->
|
||||
<line x1="960" y1="120" x2="960" y2="144" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 16 → 19 -->
|
||||
<line x1="1152" y1="120" x2="1152" y2="144" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 18 → 20 -->
|
||||
<line x1="1152" y1="192" x2="1152" y2="216" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 20 → 21 -->
|
||||
<line x1="1152" y1="264" x2="1152" y2="288" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 21 → 23 -->
|
||||
<!-- rounded right-angle elbows -->
|
||||
<path d="M200,96 H416 Q424,96 424,104 V160 Q424,168 432,168 H472" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 5 → 13 -->
|
||||
<path d="M616,96 H640 Q648,96 648,104 V160 Q648,168 656,168 H664" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 11 → 14 -->
|
||||
<path d="M616,108 H624 Q632,108 632,116 V232 Q632,240 640,240 H664" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 11 → 15 -->
|
||||
<path d="M392,184 H432 Q440,184 440,192 V504 Q440,512 448,512 H472" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 4 → 10 -->
|
||||
<path d="M128,336 V560 Q128,568 136,568 H728 Q736,568 736,560 V464" fill="none" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/> <!-- 1 → 9 -->
|
||||
|
||||
<!-- ============ nodes ============ -->
|
||||
<!-- 05 Binary Search — entry -->
|
||||
<rect x="56" y="72" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="56" y="72" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
|
||||
<rect x="64" y="78" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="76" y="87" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">05</text>
|
||||
<text x="128" y="104" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Binary Search</text>
|
||||
|
||||
<!-- 03 Two Pointers — entry -->
|
||||
<rect x="56" y="144" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="56" y="144" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
|
||||
<rect x="64" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="76" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">03</text>
|
||||
<text x="128" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Two Pointers</text>
|
||||
|
||||
<!-- 04 Sliding Window -->
|
||||
<rect x="248" y="144" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="248" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="256" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="268" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">04</text>
|
||||
<text x="320" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Sliding Window</text>
|
||||
|
||||
<!-- 06 Stack — entry -->
|
||||
<rect x="56" y="216" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="56" y="216" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
|
||||
<rect x="64" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="76" y="231" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">06</text>
|
||||
<text x="128" y="248" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Stack</text>
|
||||
|
||||
<!-- 07 Monotonic Stack -->
|
||||
<rect x="248" y="216" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="248" y="216" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="256" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="268" y="231" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">07</text>
|
||||
<text x="320" y="248" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Monotonic Stack</text>
|
||||
|
||||
<!-- 01 Hash-Based Lookup — entry -->
|
||||
<rect x="56" y="288" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="56" y="288" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
|
||||
<rect x="64" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="76" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">01</text>
|
||||
<text x="128" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Hash Lookup</text>
|
||||
|
||||
<!-- 02 Prefix Sum -->
|
||||
<rect x="248" y="288" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="248" y="288" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="256" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="268" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">02</text>
|
||||
<text x="320" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Prefix Sum</text>
|
||||
|
||||
<!-- 11 Tree DFS — FOCAL -->
|
||||
<rect x="472" y="72" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="472" y="72" width="144" height="48" rx="6" fill="rgba(68,157,240,0.12)" stroke="#449df0" stroke-width="1.2"/>
|
||||
<rect x="480" y="78" width="24" height="12" rx="2" fill="transparent" stroke="rgba(68,157,240,0.50)" stroke-width="0.8"/>
|
||||
<text x="492" y="87" fill="#449df0" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">11</text>
|
||||
<text x="544" y="104" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Tree DFS</text>
|
||||
|
||||
<!-- 13 Binary Search Tree -->
|
||||
<rect x="472" y="144" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="472" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="480" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="492" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">13</text>
|
||||
<text x="544" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Binary Search Tree</text>
|
||||
|
||||
<!-- 14 Heap -->
|
||||
<rect x="664" y="144" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="664" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="672" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="684" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">14</text>
|
||||
<text x="736" y="170" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Heap</text>
|
||||
<text x="736" y="184" fill="#989898" font-size="9" font-family="'IBM Plex Mono', monospace" text-anchor="middle">priority queue</text>
|
||||
|
||||
<!-- 15 Trie -->
|
||||
<rect x="664" y="216" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="664" y="216" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="672" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="684" y="231" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">15</text>
|
||||
<text x="736" y="248" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Trie</text>
|
||||
|
||||
<!-- 12 Tree BFS — entry -->
|
||||
<rect x="472" y="288" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="472" y="288" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
|
||||
<rect x="480" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="492" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">12</text>
|
||||
<text x="544" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Tree BFS</text>
|
||||
|
||||
<!-- 08 Linked List — entry -->
|
||||
<rect x="472" y="416" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="472" y="416" width="144" height="48" rx="6" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
|
||||
<rect x="480" y="422" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="492" y="431" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">08</text>
|
||||
<text x="544" y="448" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Linked List</text>
|
||||
|
||||
<!-- 09 Hybrid Structures -->
|
||||
<rect x="664" y="416" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="664" y="416" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="672" y="422" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="684" y="431" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">09</text>
|
||||
<text x="736" y="448" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Hybrid Structures</text>
|
||||
|
||||
<!-- 10 Matrix Index Math -->
|
||||
<rect x="472" y="488" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="472" y="488" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="480" y="494" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="492" y="503" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">10</text>
|
||||
<text x="544" y="520" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Matrix Index Math</text>
|
||||
|
||||
<!-- 16 Graph DFS -->
|
||||
<rect x="888" y="72" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="888" y="72" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="896" y="78" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="908" y="87" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">16</text>
|
||||
<text x="960" y="104" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Graph DFS</text>
|
||||
|
||||
<!-- 18 Topological Sort -->
|
||||
<rect x="1080" y="72" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="1080" y="72" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="1088" y="78" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="1100" y="87" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">18</text>
|
||||
<text x="1152" y="104" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Topological Sort</text>
|
||||
|
||||
<!-- 19 Union-Find -->
|
||||
<rect x="888" y="144" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="888" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="896" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="908" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">19</text>
|
||||
<text x="960" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Union-Find</text>
|
||||
|
||||
<!-- 20 Backtracking -->
|
||||
<rect x="1080" y="144" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="1080" y="144" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="1088" y="150" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="1100" y="159" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">20</text>
|
||||
<text x="1152" y="176" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Backtracking</text>
|
||||
|
||||
<!-- 21 1-D DP — FOCAL -->
|
||||
<rect x="1080" y="216" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="1080" y="216" width="144" height="48" rx="6" fill="rgba(68,157,240,0.12)" stroke="#449df0" stroke-width="1.2"/>
|
||||
<rect x="1088" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(68,157,240,0.50)" stroke-width="0.8"/>
|
||||
<text x="1100" y="231" fill="#449df0" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">21</text>
|
||||
<text x="1152" y="242" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">1-D DP</text>
|
||||
<text x="1152" y="256" fill="#989898" font-size="9" font-family="'IBM Plex Mono', monospace" text-anchor="middle">dynamic programming</text>
|
||||
|
||||
<!-- 22 Multi-D / Grid DP -->
|
||||
<rect x="1272" y="216" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="1272" y="216" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="1280" y="222" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="1292" y="231" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">22</text>
|
||||
<text x="1344" y="242" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Multi-D / Grid DP</text>
|
||||
|
||||
<!-- 17 Graph BFS -->
|
||||
<rect x="888" y="288" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="888" y="288" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="896" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="908" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">17</text>
|
||||
<text x="960" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Graph BFS</text>
|
||||
|
||||
<!-- 23 Greedy -->
|
||||
<rect x="1080" y="288" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="1080" y="288" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="1088" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="1100" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">23</text>
|
||||
<text x="1152" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Greedy</text>
|
||||
|
||||
<!-- 24 Intervals -->
|
||||
<rect x="1272" y="288" width="144" height="48" rx="6" fill="#020202"/>
|
||||
<rect x="1272" y="288" width="144" height="48" rx="6" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<rect x="1280" y="294" width="24" height="12" rx="2" fill="transparent" stroke="rgba(242,242,242,0.30)" stroke-width="0.8"/>
|
||||
<text x="1292" y="303" fill="#989898" font-size="7" font-family="'IBM Plex Mono', monospace" text-anchor="middle" letter-spacing="0.08em">24</text>
|
||||
<text x="1344" y="320" fill="#f2f2f2" font-size="12" font-weight="600" font-family="'Inter', sans-serif" text-anchor="middle">Intervals</text>
|
||||
|
||||
<!-- ============ legend ============ -->
|
||||
<line x1="40" y1="600" x2="1432" y2="600" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
|
||||
<text x="40" y="620" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.14em">LEGEND</text>
|
||||
|
||||
<rect x="160" y="608" width="20" height="12" rx="2" fill="rgba(152,152,152,0.10)" stroke="#6f6f6f" stroke-width="1"/>
|
||||
<text x="188" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">ENTRY — NO PREREQS</text>
|
||||
|
||||
<rect x="360" y="608" width="20" height="12" rx="2" fill="#0d0d0d" stroke="#f2f2f2" stroke-width="1"/>
|
||||
<text x="388" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">TOPIC</text>
|
||||
|
||||
<rect x="480" y="608" width="20" height="12" rx="2" fill="rgba(68,157,240,0.12)" stroke="#449df0" stroke-width="1.2"/>
|
||||
<text x="508" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">FOCAL HUB</text>
|
||||
|
||||
<line x1="624" y1="614" x2="656" y2="614" stroke="#989898" stroke-width="1" marker-end="url(#arrow)"/>
|
||||
<text x="668" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">BUILDS ON</text>
|
||||
|
||||
<rect x="792" y="608" width="20" height="12" rx="2" fill="rgba(242,242,242,0.02)" stroke="rgba(242,242,242,0.10)" stroke-width="0.8"/>
|
||||
<text x="820" y="618" fill="#989898" font-size="8" font-family="'IBM Plex Mono', monospace" letter-spacing="0.06em">PHASE ZONE</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 23 KiB |
@@ -1,66 +0,0 @@
|
||||
<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background (REQUIRED) -->
|
||||
<rect width="800" height="450" fill="#f8f9fa" rx="10"/>
|
||||
|
||||
<!-- Title and Subtitle -->
|
||||
<text x="400" y="45" font-family="Arial, sans-serif" font-size="26" font-weight="bold" fill="#2c3e50" text-anchor="middle">Sliding Window: Fixed Size</text>
|
||||
<text x="400" y="75" font-family="Arial, sans-serif" font-size="16" fill="#7f8c8d" text-anchor="middle">Window of size K=3 moving through the array.</text>
|
||||
|
||||
<!-- Step 1: Initial window -->
|
||||
<g transform="translate(150, 130)">
|
||||
<text x="0" y="-20" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">1. Current window (Sum = 10)</text>
|
||||
|
||||
<!-- Array -->
|
||||
<g transform="translate(0, 20)">
|
||||
<rect x="0" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||
<text x="30" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">2</text>
|
||||
|
||||
<rect x="70" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||
<text x="100" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">3</text>
|
||||
|
||||
<rect x="140" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||
<text x="170" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">5</text>
|
||||
|
||||
<rect x="210" y="0" width="60" height="50" fill="#ffffff" stroke="#dee2e6" stroke-width="2" rx="3"/>
|
||||
<text x="240" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">1</text>
|
||||
|
||||
<rect x="280" y="0" width="60" height="50" fill="#ffffff" stroke="#dee2e6" stroke-width="2" rx="3"/>
|
||||
<text x="310" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">8</text>
|
||||
|
||||
<!-- Bracket -->
|
||||
<path d="M 0,60 L 0,70 L 200,70 L 200,60" fill="none" stroke="#10b981" stroke-width="2"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Transition -->
|
||||
<g transform="translate(400, 260)">
|
||||
<path d="M 0,0 L 0,30" stroke="#dee2e6" stroke-width="2" fill="none"/>
|
||||
<text x="10" y="20" font-family="Arial, sans-serif" font-size="14" font-style="italic" fill="#7f8c8d">Slide window: -2, +1</text>
|
||||
</g>
|
||||
|
||||
<!-- Step 2: Slid window -->
|
||||
<g transform="translate(150, 340)">
|
||||
<text x="0" y="-20" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">2. Next window (Sum = 9)</text>
|
||||
|
||||
<!-- Array -->
|
||||
<g transform="translate(0, 20)">
|
||||
<rect x="0" y="0" width="60" height="50" fill="#e5e7eb" stroke="#94a3b8" stroke-width="2" rx="3"/>
|
||||
<text x="30" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#94a3b8">2</text>
|
||||
|
||||
<rect x="70" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||
<text x="100" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">3</text>
|
||||
|
||||
<rect x="140" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||
<text x="170" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">5</text>
|
||||
|
||||
<rect x="210" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||
<text x="240" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">1</text>
|
||||
|
||||
<rect x="280" y="0" width="60" height="50" fill="#ffffff" stroke="#dee2e6" stroke-width="2" rx="3"/>
|
||||
<text x="310" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">8</text>
|
||||
|
||||
<!-- Bracket -->
|
||||
<path d="M 70,60 L 70,70 L 270,70 L 270,60" fill="none" stroke="#10b981" stroke-width="2"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,66 +0,0 @@
|
||||
<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background (REQUIRED) -->
|
||||
<rect width="800" height="450" fill="#f8f9fa" rx="10"/>
|
||||
|
||||
<!-- Title and Subtitle -->
|
||||
<text x="400" y="45" font-family="Arial, sans-serif" font-size="26" font-weight="bold" fill="#2c3e50" text-anchor="middle">Sliding Window: Flexible Size</text>
|
||||
<text x="400" y="75" font-family="Arial, sans-serif" font-size="16" fill="#7f8c8d" text-anchor="middle">Window expands until invalid, then shrinks until valid again.</text>
|
||||
|
||||
<!-- Step 1: Invalid state -->
|
||||
<g transform="translate(150, 130)">
|
||||
<text x="0" y="-20" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">1. Expansion breaks invariant</text>
|
||||
|
||||
<!-- Array -->
|
||||
<g transform="translate(0, 20)">
|
||||
<rect x="0" y="0" width="60" height="50" fill="#fee2e2" stroke="#ef4444" stroke-width="3" rx="3"/>
|
||||
<text x="30" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">a</text>
|
||||
<polygon points="30,60 25,70 35,70" fill="#27ae60"/>
|
||||
<text x="30" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#27ae60" text-anchor="middle">L</text>
|
||||
|
||||
<rect x="70" y="0" width="60" height="50" fill="#fee2e2" stroke="#ef4444" stroke-width="3" rx="3"/>
|
||||
<text x="100" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">b</text>
|
||||
|
||||
<rect x="140" y="0" width="60" height="50" fill="#fee2e2" stroke="#ef4444" stroke-width="3" rx="3"/>
|
||||
<text x="170" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">c</text>
|
||||
|
||||
<rect x="210" y="0" width="60" height="50" fill="#fee2e2" stroke="#ef4444" stroke-width="4" rx="3"/>
|
||||
<text x="240" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">a</text>
|
||||
<polygon points="240,60 235,70 245,70" fill="#3498db"/>
|
||||
<text x="240" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#3498db" text-anchor="middle">R</text>
|
||||
|
||||
<text x="300" y="32" font-family="Arial, sans-serif" font-size="14" fill="#ef4444" font-weight="bold">Duplicate 'a'!</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Transition -->
|
||||
<g transform="translate(400, 260)">
|
||||
<path d="M 0,0 L 0,30" stroke="#dee2e6" stroke-width="2" fill="none"/>
|
||||
<text x="10" y="20" font-family="Arial, sans-serif" font-size="14" font-style="italic" fill="#7f8c8d">Shrink from left</text>
|
||||
</g>
|
||||
|
||||
<!-- Step 2: Valid again -->
|
||||
<g transform="translate(150, 340)">
|
||||
<text x="0" y="-20" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">2. Invariant restored</text>
|
||||
|
||||
<!-- Array -->
|
||||
<g transform="translate(0, 20)">
|
||||
<rect x="0" y="0" width="60" height="50" fill="#e5e7eb" stroke="#94a3b8" stroke-width="2" rx="3"/>
|
||||
<text x="30" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#94a3b8">a</text>
|
||||
|
||||
<rect x="70" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||
<text x="100" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">b</text>
|
||||
<polygon points="100,60 95,70 105,70" fill="#27ae60"/>
|
||||
<text x="100" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#27ae60" text-anchor="middle">L</text>
|
||||
|
||||
<rect x="140" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||
<text x="170" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">c</text>
|
||||
|
||||
<rect x="210" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||
<text x="240" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">a</text>
|
||||
<polygon points="240,60 235,70 245,70" fill="#3498db"/>
|
||||
<text x="240" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#3498db" text-anchor="middle">R</text>
|
||||
|
||||
<text x="300" y="32" font-family="Arial, sans-serif" font-size="14" fill="#10b981" font-weight="bold">Valid again!</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.1 KiB |
@@ -1,54 +0,0 @@
|
||||
<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background (REQUIRED) -->
|
||||
<rect width="800" height="450" fill="#f8f9fa" rx="10"/>
|
||||
|
||||
<!-- Title and Subtitle -->
|
||||
<text x="400" y="45" font-family="Arial, sans-serif" font-size="26" font-weight="bold" fill="#2c3e50" text-anchor="middle">Sliding Window: Frequency Tracking</text>
|
||||
<text x="400" y="75" font-family="Arial, sans-serif" font-size="16" fill="#7f8c8d" text-anchor="middle">Maintaining a character frequency map for the current window.</text>
|
||||
|
||||
<!-- Left: Window -->
|
||||
<g transform="translate(100, 150)">
|
||||
<text x="0" y="-30" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">Current Window</text>
|
||||
|
||||
<!-- Array -->
|
||||
<g transform="translate(0, 20)">
|
||||
<rect x="0" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||
<text x="30" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">a</text>
|
||||
<polygon points="30,60 25,70 35,70" fill="#27ae60"/>
|
||||
<text x="30" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#27ae60" text-anchor="middle">L</text>
|
||||
|
||||
<rect x="70" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||
<text x="100" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">b</text>
|
||||
|
||||
<rect x="140" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||
<text x="170" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">a</text>
|
||||
<polygon points="170,60 165,70 175,70" fill="#3498db"/>
|
||||
<text x="170" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#3498db" text-anchor="middle">R</text>
|
||||
|
||||
<rect x="210" y="0" width="60" height="50" fill="#ffffff" stroke="#dee2e6" stroke-width="2" rx="3"/>
|
||||
<text x="240" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">c</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Right: Frequency Map -->
|
||||
<g transform="translate(500, 150)">
|
||||
<text x="0" y="-30" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">Frequency Map</text>
|
||||
|
||||
<!-- Map Container -->
|
||||
<rect x="0" y="0" width="150" height="120" fill="#ffffff" stroke="#dee2e6" stroke-width="2" rx="8"/>
|
||||
|
||||
<!-- Entries -->
|
||||
<g transform="translate(25, 35)">
|
||||
<text x="0" y="0" font-family="monospace" font-size="18" fill="#2c3e50">'a' : 2</text>
|
||||
<text x="0" y="30" font-family="monospace" font-size="18" fill="#2c3e50">'b' : 1</text>
|
||||
<text x="0" y="60" font-family="monospace" font-size="18" fill="#7f8c8d">'c' : 0</text>
|
||||
</g>
|
||||
|
||||
<!-- Invariant label -->
|
||||
<rect x="-20" y="140" width="190" height="40" fill="#e3f2fd" stroke="#3498db" stroke-width="1" rx="5"/>
|
||||
<text x="75" y="165" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="#1565c0" text-anchor="middle">INVARIANT: SYNCED STATE</text>
|
||||
</g>
|
||||
|
||||
<!-- Connector -->
|
||||
<path d="M 380,195 L 480,195" stroke="#dee2e6" stroke-width="2" stroke-dasharray="5,5" fill="none"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
+6
-6
@@ -1,13 +1,17 @@
|
||||
---
|
||||
title: '153. Find Minimum in Rotated Sorted Array'
|
||||
description: 'Suppose an array of length n sorted in ascending order is rotated between 1 and n times. For example, the array nums = [0,1,2,4,5,6,7] might become:'
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
video: "nIVW4P8b1VA"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/62"
|
||||
sidebar:
|
||||
label: 'Find Minimum in Rotated Sorted Array'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search</Badge>
|
||||
|
||||
::::warning
|
||||
Notice that rotating an array [a[0], a[1], a[2], ..., a[n-1]] 1 time results in the array [a[n-1], a[0], a[1], a[2], ..., a[n-2]].
|
||||
::::
|
||||
@@ -68,7 +72,3 @@ class Solution:
|
||||
|
||||
return nums[lowest_index]
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="nIVW4P8b1VA" title="Find Minimum in Rotated Sorted Array - Binary Search - Leetcode 153 - Python" />
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: '162. Find Peak Element'
|
||||
description: A peak element is an element that is strictly greater than its neighbors
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
issue: "https://github.com/prdlk/leetcode/issues/64"
|
||||
sidebar:
|
||||
label: 'Find Peak Element'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
::::warning
|
||||
You must write an algorithm that runs in O(log n) time.
|
||||
::::
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,2,3,1]`
|
||||
- Output: `2`
|
||||
- Explanation: `3` is a peak element and your function should return the index number `2`.
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [1,2,1,3,5,6,4]`
|
||||
- Output: `5`
|
||||
- Explanation: Your function can return either index number `1` where the peak element is `2`, or index number `5` where the peak element is `6`.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= nums.length <= 1000`
|
||||
- `-2^31 <= nums[i] <= 2^31 - 1`
|
||||
- `nums[i] != nums[i + 1] for all valid i.`
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def findPeakElement(self, nums: list[int]) -> int:
|
||||
left, right = 0, len(nums) - 1
|
||||
while left < right:
|
||||
mid = (left + right) // 2
|
||||
if nums[mid] < nums[mid + 1]:
|
||||
left = mid + 1
|
||||
else:
|
||||
right = mid
|
||||
|
||||
return left
|
||||
```
|
||||
+6
-6
@@ -1,13 +1,17 @@
|
||||
---
|
||||
title: '33. Search in Rotated Sorted Array'
|
||||
description: There is an integer array nums sorted in ascending order (with distinct values)
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
video: "U8XENwh8Oy8"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/60"
|
||||
sidebar:
|
||||
label: 'Search in Rotated Sorted Array'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search</Badge>
|
||||
|
||||
::::warning
|
||||
You must write an algorithm with O(log n) runtime complexity.
|
||||
::::
|
||||
@@ -92,7 +96,3 @@ var search = function(nums, target) {
|
||||
return -1;
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="U8XENwh8Oy8" title="Search in rotated sorted array - Leetcode 33 - Python" />
|
||||
+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
@@ -1,13 +1,17 @@
|
||||
---
|
||||
title: '35. Search Insert Position'
|
||||
description: Given a sorted array of distinct integers and a target value, return the index if the target is found. If not, return the index where it would be if it were inserted in order
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
video: "K-RYzDZkzCI"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/63"
|
||||
sidebar:
|
||||
label: 'Search Insert Position'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search</Badge>
|
||||
|
||||
::::warning
|
||||
You must write an algorithm with O(log n) runtime complexity.
|
||||
::::
|
||||
@@ -65,7 +69,3 @@ class Solution:
|
||||
|
||||
return (l + r) // 2 + 1
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="K-RYzDZkzCI" title="Search Insert Position - Binary Search - Leetcode 35 - Python" />
|
||||
+6
-6
@@ -1,13 +1,17 @@
|
||||
---
|
||||
title: '704. Binary Search'
|
||||
description: Given an array of integers nums which is sorted in ascending order, and an integer target, write a function to search target in nums. If target exists, then return its index. Otherwise, return -1
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
video: "s4DPM8ct1pI"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/58"
|
||||
sidebar:
|
||||
label: 'Binary Search'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search</Badge>
|
||||
|
||||
::::warning
|
||||
You must write an algorithm with O(log n) runtime complexity.
|
||||
::::
|
||||
@@ -70,7 +74,3 @@ var search = function(nums, target) {
|
||||
return -1;
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="s4DPM8ct1pI" title="Binary Search - Leetcode 704 - Python" />
|
||||
+7
-6
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: '74. Search a 2D Matrix'
|
||||
description: 'You are given an m x n integer matrix matrix with the following two properties:'
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
- { name: "Matrix", slug: "matrix" }
|
||||
video: "Ber2pi2C0j0"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/61"
|
||||
sidebar:
|
||||
label: 'Search a 2D Matrix'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search</Badge>
|
||||
|
||||
::::warning
|
||||
You must write a solution in O(log(m * n)) time complexity.
|
||||
::::
|
||||
@@ -85,7 +90,3 @@ class Solution:
|
||||
return True
|
||||
return False
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="Ber2pi2C0j0" title="Search a 2D Matrix - Leetcode 74 - Python" />
|
||||
+6
-6
@@ -1,13 +1,17 @@
|
||||
---
|
||||
title: '875. Koko Eating Bananas'
|
||||
description: Koko loves to eat bananas. There are n piles of bananas, the i^th pile has piles[i] bananas. The guards have gone and will come back in h hours
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
video: "U2SozAs9RzA"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/59"
|
||||
sidebar:
|
||||
label: 'Koko Eating Bananas'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `piles = [3,6,7,11], h = 8`
|
||||
- Output: `4`
|
||||
@@ -76,7 +80,3 @@ var minEatingSpeed = function(piles, h) {
|
||||
return l;
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="U2SozAs9RzA" title="Koko Eating Bananas - Binary Search - Leetcode 875 - Python" />
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: Binary Search
|
||||
description: 'Half of a sorted range is removed at each step until the target is found.'
|
||||
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='/binary_search.001.png' />
|
||||
<CarouselImage src='/binary_search.002.png' />
|
||||
<CarouselImage src='/binary_search.003.png' />
|
||||
<CarouselImage src='/binary_search.004.png' />
|
||||
<CarouselImage src='/binary_search.005.png' />
|
||||
<CarouselImage src='/binary_search.006.png' />
|
||||
<CarouselImage src='/binary_search.007.png' />
|
||||
<CarouselImage src='/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.**
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: '128. Longest Consecutive Sequence'
|
||||
description: Given an unsorted array of integers nums, return the length of the longest consecutive elements sequence
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "Union-Find", slug: "union-find" }
|
||||
issue: "https://github.com/prdlk/leetcode/issues/34"
|
||||
sidebar:
|
||||
label: 'Longest Consecutive Sequence'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
::::warning
|
||||
You must write an algorithm that runs in O(n) time.
|
||||
::::
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [100,4,200,1,3,2]`
|
||||
- Output: `4`
|
||||
- Explanation: The longest consecutive elements sequence is [1, `2`, `3`, 4]. Therefore its length is `4`.
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [0,3,7,2,5,8,4,6,0,1]`
|
||||
- Output: `9`
|
||||
|
||||
### Example 3:
|
||||
- Input: `nums = [1,0,1,2]`
|
||||
- Output: `3`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `0 <= nums.length <= 10^5`
|
||||
- `-10^9 <= nums[i] <= 10^9`
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def longestConsecutive(self, nums: list[int]) -> int:
|
||||
seen = set(nums)
|
||||
best = 0
|
||||
|
||||
for n in seen:
|
||||
if n - 1 in seen:
|
||||
continue
|
||||
|
||||
length = 1
|
||||
while length + n in seen:
|
||||
length += 1
|
||||
|
||||
best = max(length, best)
|
||||
|
||||
return best
|
||||
```
|
||||
+4
-2
@@ -1,13 +1,15 @@
|
||||
---
|
||||
title: '1832. Check if the Sentence Is Pangram'
|
||||
description: A pangram is a sentence where every letter of the English alphabet appears at least once
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "String", slug: "string" }
|
||||
sidebar:
|
||||
label: 'Check if the Sentence Is Pangram'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `sentence = "thequickbrownfoxjumpsoverthelazydog"`
|
||||
- Output: `true`
|
||||
+6
-6
@@ -1,13 +1,17 @@
|
||||
---
|
||||
title: '1. Two Sum'
|
||||
description: You are given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
video: "KLlXCFG5TnA"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/29"
|
||||
sidebar:
|
||||
label: 'Two Sum'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
::::warning
|
||||
Can you come up with an algorithm that is less than O(n^2) time complexity?
|
||||
::::
|
||||
@@ -59,7 +63,3 @@ class Solution:
|
||||
seen[num] = i
|
||||
return []
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="KLlXCFG5TnA" title="Two Sum - Leetcode 1 - HashMap - Python" />
|
||||
@@ -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))
|
||||
```
|
||||
+11
-21
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: '217. Contains Duplicate'
|
||||
description: Given an integer array nums, return true if any value appears at least twice in the array, and return false if every element is distinct
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "Sorting", slug: "sorting" }
|
||||
video: "3OamzN90kPg"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/27"
|
||||
sidebar:
|
||||
label: 'Contains Duplicate'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,2,3,1]`
|
||||
- Output: `true`
|
||||
@@ -42,23 +47,8 @@ flowchart TD
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
/**
|
||||
* @param {number[]} nums
|
||||
* @return {boolean}
|
||||
*/
|
||||
var containsDuplicate = function(nums) {
|
||||
const freq = {};
|
||||
for (let n of nums) {
|
||||
freq[n] = (freq[n] || 0) + 1;
|
||||
if (freq[n] >= 2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
```py
|
||||
class Solution:
|
||||
def containsDuplicate(self, nums: List[int]) -> bool:
|
||||
return len(set(nums)) != len(nums)
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="3OamzN90kPg" title="Contains Duplicate - Leetcode 217 - Python" />
|
||||
+7
-6
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: '242. Valid Anagram'
|
||||
description: Given two strings s and t, return true if t is an anagram of s, and false otherwise
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "String", slug: "string" }
|
||||
- { name: "Sorting", slug: "sorting" }
|
||||
video: "9UtInBqnCgA"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/28"
|
||||
sidebar:
|
||||
label: 'Valid Anagram'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
::::warning
|
||||
What if the inputs contain Unicode characters? How would you adapt your solution to such a case?
|
||||
::::
|
||||
@@ -59,7 +64,3 @@ var isAnagram = function(s, t) {
|
||||
return true;
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="9UtInBqnCgA" title="Valid Anagram - Leetcode 242 - Python" />
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: '290. Word Pattern'
|
||||
description: Given a pattern and a string s, find if s follows the same pattern
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "String", slug: "string" }
|
||||
issue: "https://github.com/prdlk/leetcode/issues/35"
|
||||
sidebar:
|
||||
label: 'Word Pattern'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `pattern = "abba", s = "dog cat cat dog"`
|
||||
- Output: `true`
|
||||
- Explanation: The bijection can be established as: • 'a' maps to "dog". • 'b' maps to "cat".
|
||||
|
||||
### Example 2:
|
||||
- Input: `pattern = "abba", s = "dog cat cat fish"`
|
||||
- Output: `false`
|
||||
|
||||
### Example 3:
|
||||
- Input: `pattern = "aaaa", s = "dog cat cat dog"`
|
||||
- Output: `false`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= pattern.length <= 300`
|
||||
- `pattern` contains only lower-case English letters.
|
||||
- `1 <= s.length <= 3000`
|
||||
- `s` contains only lowercase English letters and spaces ' '.
|
||||
- `s` does not contain any leading or trailing spaces.
|
||||
- All the words in `s` are separated by a single space.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def wordPattern(self, pattern: str, s: str) -> bool:
|
||||
if len(pattern) != len(s.split()):
|
||||
return False
|
||||
|
||||
words = s.split()
|
||||
map = {}
|
||||
|
||||
for i in range(len(pattern)):
|
||||
c = pattern[i]
|
||||
w = words[i]
|
||||
|
||||
if c in map and map[c] != w:
|
||||
return False
|
||||
else:
|
||||
map[c] = w
|
||||
|
||||
return len(set(pattern)) == len(set(words))
|
||||
```
|
||||
+12
-6
@@ -1,13 +1,23 @@
|
||||
---
|
||||
title: '347. Top K Frequent Elements'
|
||||
description: Given an integer array nums and an integer k, return the k most frequent elements. You may return the answer in any order
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "Divide and Conquer", slug: "divide-and-conquer" }
|
||||
- { name: "Sorting", slug: "sorting" }
|
||||
- { name: "Heap (Priority Queue)", slug: "heap-priority-queue" }
|
||||
- { name: "Bucket Sort", slug: "bucket-sort" }
|
||||
- { name: "Counting", slug: "counting" }
|
||||
- { name: "Quickselect", slug: "quickselect" }
|
||||
video: "YPTqKIgVk-k"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/31"
|
||||
sidebar:
|
||||
label: 'Top K Frequent Elements'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
::::warning
|
||||
Your algorithm's time complexity must be better than O(n log n), where n is the array's size.
|
||||
::::
|
||||
@@ -59,7 +69,3 @@ var topKFrequent = function (nums, k) {
|
||||
.slice(0, k);
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="YPTqKIgVk-k" title="Top K Frequent Elements - Bucket Sort - Leetcode 347 - Python" />
|
||||
+7
-6
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: '387. First Unique Character in a String'
|
||||
description: Given a string s, find the first non-repeating character in it and return its index. If it does not exist, return -1
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "String", slug: "string" }
|
||||
- { name: "Queue", slug: "queue" }
|
||||
- { name: "Counting", slug: "counting" }
|
||||
video: "rBENYgWy3xU"
|
||||
sidebar:
|
||||
label: 'First Unique Character in a String'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "leetcode"`
|
||||
- Output: `0`
|
||||
@@ -57,7 +62,3 @@ var firstUniqChar = function (s) {
|
||||
return -1;
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="rBENYgWy3xU" title="First Unique Character in a String - Leetcode 387 - Python" />
|
||||
+9
-6
@@ -1,13 +1,20 @@
|
||||
---
|
||||
title: '451. Sort Characters By Frequency'
|
||||
description: Given a string s, sort it in decreasing order based on the frequency of the characters. The frequency of a character is the number of times it appears in the string
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "String", slug: "string" }
|
||||
- { name: "Sorting", slug: "sorting" }
|
||||
- { name: "Heap (Priority Queue)", slug: "heap-priority-queue" }
|
||||
- { name: "Bucket Sort", slug: "bucket-sort" }
|
||||
- { name: "Counting", slug: "counting" }
|
||||
video: "OXdXc9HTrIg"
|
||||
sidebar:
|
||||
label: 'Sort Characters By Frequency'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "tree"`
|
||||
- Output: `"eert"`
|
||||
@@ -59,7 +66,3 @@ var frequencySort = function (s) {
|
||||
.join("");
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="OXdXc9HTrIg" title="Sort Characters By Frequency - Leetcode 451 - Python" />
|
||||
+8
-6
@@ -1,13 +1,19 @@
|
||||
---
|
||||
title: '49. Group Anagrams'
|
||||
description: Given an array of strings strs, group the anagrams together. You can return the answer in any order
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "String", slug: "string" }
|
||||
- { name: "Sorting", slug: "sorting" }
|
||||
video: "vzdNOK2oB2E"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/30"
|
||||
sidebar:
|
||||
label: 'Group Anagrams'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `strs = ["eat","tea","tan","ate","nat","bat"]`
|
||||
- Output: `[["bat"],["nat","tan"],["ate","eat","tea"]]`
|
||||
@@ -64,7 +70,3 @@ var groupAnagrams = function(strs) {
|
||||
return Object.values(anagrams);
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="vzdNOK2oB2E" title="Group Anagrams - Categorize Strings by Count - Leetcode 49" />
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Hash Table
|
||||
description: 'A key maps to a value in constant time, which removes the need for a full scan.'
|
||||
hidden: true
|
||||
---
|
||||
|
||||
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">
|
||||
|
||||

|
||||
|
||||
</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)
|
||||
```
|
||||
:::
|
||||
+7
-6
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: '739. Daily Temperatures'
|
||||
description: Given an array of integers temperatures represents the daily temperatures, return an array answer such that answer[i] is the number of days you have to wait after the i^th day to get a warmer temperature. If there is no future day for which this is possible, keep answer[i] == 0 instead
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Stack", slug: "stack" }
|
||||
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
||||
video: "cTBiBSnjO3c"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/72"
|
||||
sidebar:
|
||||
label: 'Daily Temperatures'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Monotonic Stack</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `temperatures = [73,74,75,71,69,72,76,73]`
|
||||
- Output: `[1,1,4,2,1,1,0,0]`
|
||||
@@ -53,7 +58,3 @@ class Solution:
|
||||
stack.append(i)
|
||||
return ans
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="cTBiBSnjO3c" title="Daily Temperatures - Monotonic Stack - Leetcode 739 - Python" />
|
||||
+8
-6
@@ -1,13 +1,19 @@
|
||||
---
|
||||
title: '853. Car Fleet'
|
||||
description: There are n cars at given miles away from the starting mile 0, traveling to reach the mile target
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Stack", slug: "stack" }
|
||||
- { name: "Sorting", slug: "sorting" }
|
||||
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
||||
video: "Pr6T-3yB9RM"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/73"
|
||||
sidebar:
|
||||
label: 'Car Fleet'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Monotonic Stack</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `target = 12, position = [10,8,0,5,3], speed = [2,4,1,1,3]`
|
||||
- Output: `3`
|
||||
@@ -61,7 +67,3 @@ class Solution:
|
||||
|
||||
return len(stack)
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="Pr6T-3yB9RM" title="Car Fleet - Leetcode 853 - Python" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Monotonic Stack
|
||||
description: 'A stack keeps its items in sorted order, which finds the next larger or smaller item in one pass.'
|
||||
hidden: true
|
||||
---
|
||||
+6
-6
@@ -1,13 +1,17 @@
|
||||
---
|
||||
title: '238. Product of Array Except Self'
|
||||
description: Given an integer array nums, return an array answer such that answer[i] is equal to the product of all the elements of nums except nums[i]
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Prefix Sum", slug: "prefix-sum" }
|
||||
video: "bNvIQI2wAjk"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/37"
|
||||
sidebar:
|
||||
label: 'Product of Array Except Self'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Prefix Sum</Badge>
|
||||
|
||||
::::warning
|
||||
You must write an algorithm that runs in O(n) time and without using the division operation.
|
||||
Can you solve the problem in O(1) extra space complexity? (The output array does not count as extra space for space complexity analysis.)
|
||||
@@ -62,7 +66,3 @@ var productExceptSelf = function(nums) {
|
||||
return answer;
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="bNvIQI2wAjk" title="Product of Array Except Self - Leetcode 238 - Python" />
|
||||
+7
-6
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: '303. Range Sum Query - Immutable'
|
||||
description: Given an integer array nums, handle multiple queries of the following type
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Design", slug: "design" }
|
||||
- { name: "Prefix Sum", slug: "prefix-sum" }
|
||||
video: "2pndAmo_sMA"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/36"
|
||||
sidebar:
|
||||
label: 'Range Sum Query - Immutable'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Prefix Sum</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `["NumArray", "sumRange", "sumRange", "sumRange"]` `[[[-2, 0, 3, -5, 2, -1]], [0, 2], [2, 5], [0, 5]]`
|
||||
- Output: `[null, 1, -1, -3]`
|
||||
@@ -65,7 +70,3 @@ class NumArray {
|
||||
* var param_1 = obj.sumRange(left,right)
|
||||
*/
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="2pndAmo_sMA" title="Range Sum Query Immutable - Leetcode 303 - Python" />
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: '525. Contiguous Array'
|
||||
description: Given a binary array nums, return the maximum length of a contiguous subarray with an equal number of 0 and 1
|
||||
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/39"
|
||||
sidebar:
|
||||
label: 'Contiguous Array'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [0,1]`
|
||||
- Output: `2`
|
||||
- Explanation: [0, 1] is the longest contiguous subarray with an equal number of `0` and `1`.
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [0,1,0]`
|
||||
- Output: `2`
|
||||
- Explanation: [0, 1] (or [1, 0]) is a longest contiguous subarray with equal number of `0` and `1`.
|
||||
|
||||
### Example 3:
|
||||
- Input: `nums = [0,1,1,1,1,1,0,0,0]`
|
||||
- Output: `6`
|
||||
- Explanation: `[1,1,1,0,0,0]` is the longest contiguous subarray with equal number of `0` and `1`.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= nums.length <= 10^5`
|
||||
- `nums[i]` is either 0 or 1.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def findMaxLength(self, nums: List[int]) -> int:
|
||||
map = {0: -1}
|
||||
count = 0
|
||||
longest = 0
|
||||
|
||||
for i, num in enumerate(nums):
|
||||
count += 1 if num == 1 else -1
|
||||
|
||||
if count in map:
|
||||
longest = max(longest, i - map[count])
|
||||
else:
|
||||
map[count] = i
|
||||
|
||||
return longest
|
||||
```
|
||||
+7
-6
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: '560. Subarray Sum Equals K'
|
||||
description: Given an array of integers nums and an integer k, return the total number of subarrays whose sum equals to k
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "Prefix Sum", slug: "prefix-sum" }
|
||||
video: "fFVZt-6sgyo"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/38"
|
||||
sidebar:
|
||||
label: 'Subarray Sum Equals K'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Prefix Sum</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,1,1], k = 2`
|
||||
- Output: `2`
|
||||
@@ -64,7 +69,3 @@ var subarraySum = function(nums, k) {
|
||||
return count
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="fFVZt-6sgyo" title="Subarray Sum Equals K - Prefix Sums - Leetcode 560 - Python" />
|
||||
+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
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Prefix Sum
|
||||
description: 'A running total is stored at each index, so the sum of any range is one subtraction.'
|
||||
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.
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
---
|
||||
title: '1004. Max Consecutive Ones III'
|
||||
description: Given a binary array nums and an integer k, return the maximum number of consecutive 1's in the array if you can flip at most k 0's
|
||||
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/56"
|
||||
sidebar:
|
||||
label: 'Max Consecutive Ones III'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,1,1,0,0,0,1,1,1,1,0], k = 2`
|
||||
- Output: `6`
|
||||
- Explanation: `[1,1,1,0,0,1,1,1,1,1,1]` Bolded numbers were flipped from `0` to `1`. The longest subarray is underlined.
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [0,0,1,1,0,0,1,1,1,0,1,1,0,0,0,1,1,1,1], k = 3`
|
||||
- Output: `10`
|
||||
- Explanation: `[0,0,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1]` Bolded numbers were flipped from `0` to `1`. The longest subarray is underlined.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= nums.length <= 10^5`
|
||||
- `nums[i]` is either 0 or 1.
|
||||
- `0 <= k <= nums.length`
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def longestOnes(self, nums: list[int], k: int) -> int:
|
||||
left = 0
|
||||
cur = 0
|
||||
ans = 0
|
||||
|
||||
for right in range(len(nums)):
|
||||
if nums[right] == 0:
|
||||
cur += 1
|
||||
while cur > k:
|
||||
if nums[left] == 0:
|
||||
cur -= 1
|
||||
left += 1
|
||||
|
||||
ans = max(ans, right - left + 1)
|
||||
|
||||
return ans
|
||||
```
|
||||
+6
-6
@@ -1,13 +1,17 @@
|
||||
---
|
||||
title: '121. Best Time to Buy and Sell Stock'
|
||||
description: You are given an array prices where prices[i] is the price of a given stock on the i^th day
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
||||
video: "1pkOgXD63yU"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/50"
|
||||
sidebar:
|
||||
label: 'Best Time to Buy and Sell Stock'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Sliding Window</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `prices = [7,1,5,3,6,4]`
|
||||
- Output: `5`
|
||||
@@ -54,7 +58,3 @@ class Solution:
|
||||
ans = max(ans, curr)
|
||||
return ans
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="1pkOgXD63yU" title="Sliding Window: Best Time to Buy and Sell Stock - Leetcode 121 - Python" />
|
||||
+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
|
||||
```
|
||||
+7
-6
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: '3. Longest Substring Without Repeating Characters'
|
||||
description: Given a string s, find the length of the longest substring without duplicate characters
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "String", slug: "string" }
|
||||
- { name: "Sliding Window", slug: "sliding-window" }
|
||||
video: "wiGpQwVHdE0"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/51"
|
||||
sidebar:
|
||||
label: 'Longest Substring Without Repeating Characters'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Sliding Window</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "abcabcbb"`
|
||||
- Output: `3`
|
||||
@@ -60,7 +65,3 @@ class Solution:
|
||||
|
||||
return ans
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="wiGpQwVHdE0" title="Longest Substring Without Repeating Characters - Leetcode 3 - Python" />
|
||||
+7
-6
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: '424. Longest Repeating Character Replacement'
|
||||
description: You are given a string s and an integer k. You can choose any character of the string and change it to any other uppercase English character. You can perform this operation at most k times
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "String", slug: "string" }
|
||||
- { name: "Sliding Window", slug: "sliding-window" }
|
||||
video: "gqXU1UyA8pk"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/52"
|
||||
sidebar:
|
||||
label: 'Longest Repeating Character Replacement'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Sliding Window</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "ABAB", k = 2`
|
||||
- Output: `4`
|
||||
@@ -60,7 +65,3 @@ class Solution:
|
||||
res = max(res, r - l + 1)
|
||||
return res
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="gqXU1UyA8pk" title="Longest Repeating Character Replacement - Leetcode 424 - Python" />
|
||||
+8
-6
@@ -1,13 +1,19 @@
|
||||
---
|
||||
title: '567. Permutation in String'
|
||||
description: Given two strings s1 and s2, return true if s2 contains a permutation of s1, or false otherwise
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "Two Pointers", slug: "two-pointers" }
|
||||
- { name: "String", slug: "string" }
|
||||
- { name: "Sliding Window", slug: "sliding-window" }
|
||||
video: "UbyhOgBN834"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/53"
|
||||
sidebar:
|
||||
label: 'Permutation in String'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Sliding Window</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s1 = "ab", s2 = "eidbaooo"`
|
||||
- Output: `true`
|
||||
@@ -67,7 +73,3 @@ class Solution:
|
||||
|
||||
return False
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="UbyhOgBN834" title="Permutation in String - Leetcode 567 - Python" />
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: '643. Maximum Average Subarray I'
|
||||
description: You are given an integer array nums consisting of n elements, and an integer k
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Sliding Window", slug: "sliding-window" }
|
||||
issue: "https://github.com/prdlk/leetcode/issues/55"
|
||||
sidebar:
|
||||
label: 'Maximum Average Subarray I'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,12,-5,-6,50,3], k = 4`
|
||||
- Output: `12.75000`
|
||||
- Explanation: Maximum average is `(12 - 5 - 6 + 50`) / `4 = 51 / 4 = 12.75`
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [5], k = 1`
|
||||
- Output: `5.00000`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `n == nums.length`
|
||||
- `1 <= k <= n <= 10^5`
|
||||
- `-10^4 <= nums[i] <= 10^4`
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def findMaxAverage(self, nums: list[int], k: int) -> float:
|
||||
window_sum = 0
|
||||
for i in range(k):
|
||||
window_sum += nums[i]
|
||||
largest = window_sum
|
||||
|
||||
for right in range(k, len(nums)):
|
||||
left = right - k
|
||||
window_sum -= nums[left]
|
||||
window_sum += nums[right]
|
||||
largest = max(largest, window_sum)
|
||||
|
||||
return largest / k
|
||||
```
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
title: Sliding Window
|
||||
description: 'Sliding window is a specialized two-pointer family where the pointers define a contiguous interval and you maintain information about that entire interval as it moves.'
|
||||
hidden: true
|
||||
---
|
||||
|
||||
|
||||
<YouTube id="tSTO9w3piAo" title="Sliding Window" />
|
||||
|
||||
|
||||
Many problems have two moving boundaries. That alone does not make them sliding window problems.
|
||||
|
||||
A problem belongs to this family when:
|
||||
|
||||
- the answer depends on the subarray or substring between left and right
|
||||
- you can update the window state incrementally as elements enter or leave
|
||||
- you maintain a condition that tells you whether the window is valid
|
||||
|
||||
:::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>
|
||||
|
||||
|
||||
## 1. Fixed-size windows
|
||||
|
||||
:::tip
|
||||
The window size never changes. The main task is updating the window state efficiently when one element enters and one leaves.
|
||||
:::
|
||||
|
||||
|
||||
<Frame caption="Sliding Window Fixed" hint="">
|
||||
|
||||

|
||||
|
||||
</Frame>
|
||||
|
||||
|
||||
```python
|
||||
def sliding_window_fixed(input, window_size):
|
||||
ans = window = input[0:window_size]
|
||||
for right in range(window_size, len(input)):
|
||||
left = right - window_size
|
||||
remove input[left] from window
|
||||
append input[right] to window
|
||||
ans = optimal(ans, window)
|
||||
return ans
|
||||
```
|
||||
|
||||
<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
|
||||
Here the window expands until invalid, then shrinks until valid again.
|
||||
:::
|
||||
|
||||
<Frame caption="Sliding Window Flexible" hint="">
|
||||
|
||||

|
||||
|
||||
</Frame>
|
||||
|
||||
|
||||
<Tabs inline>
|
||||
<Tab title="Longest Window">
|
||||
|
||||
```python
|
||||
def sliding_window_flexible_longest(input):
|
||||
initialize window, ans
|
||||
left = 0
|
||||
for right in range(len(input)):
|
||||
append input[right] to window
|
||||
while invalid(window): # update left until window is valid again
|
||||
remove input[left] from window
|
||||
left += 1
|
||||
ans = max(ans, window) # window is guaranteed to be valid here
|
||||
return ans
|
||||
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Shortest Window">
|
||||
|
||||
```python
|
||||
def sliding_window_flexible_shortest(input):
|
||||
initialize window, ans
|
||||
left = 0
|
||||
for right in range(len(input)):
|
||||
append input[right] to window
|
||||
while valid(window):
|
||||
ans = min(ans, window) # window is guaranteed to be valid here
|
||||
remove input[left] from window
|
||||
left += 1
|
||||
return ans
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<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>
|
||||
|
||||
---
|
||||
|
||||
## 3. Frequency-tracking windows
|
||||
|
||||
:::tip
|
||||
The difficulty comes from keeping the bookkeeping correct as characters enter and leave.
|
||||
:::
|
||||
|
||||
|
||||
<Frame caption="Frequency-tracking windows" hint="">
|
||||
|
||||

|
||||
|
||||
</Frame>
|
||||
|
||||
```python
|
||||
from collections import defaultdict
|
||||
|
||||
def frequency_window(s):
|
||||
left = 0
|
||||
counts = defaultdict(int)
|
||||
best = 0
|
||||
|
||||
for right in range(len(s)):
|
||||
counts[s[right]] += 1 # character enters
|
||||
|
||||
while counts[s[right]] > 1: # rule: no repeats
|
||||
counts[s[left]] -= 1 # character leaves
|
||||
left += 1
|
||||
|
||||
best = max(best, right - left + 1)
|
||||
|
||||
return best
|
||||
```
|
||||
|
||||
<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
|
||||
```
|
||||
|
||||
+7
-6
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: '150. Evaluate Reverse Polish Notation'
|
||||
description: You are given an array of strings tokens that represents an arithmetic expression in a Reverse Polish Notation
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Math", slug: "math" }
|
||||
- { name: "Stack", slug: "stack" }
|
||||
video: "iu0082c4HDE"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/69"
|
||||
sidebar:
|
||||
label: 'Evaluate Reverse Polish Notation'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Stack</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `tokens = ["2","1","+","3","*"]`
|
||||
- Output: `9`
|
||||
@@ -68,7 +73,3 @@ class Solution:
|
||||
|
||||
return stack[0]
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="iu0082c4HDE" title="Evaluate Reverse Polish Notation - Leetcode 150 - Python" />
|
||||
+6
-6
@@ -1,13 +1,17 @@
|
||||
---
|
||||
title: '155. Min Stack'
|
||||
description: Design a stack that supports push, pop, top, and retrieving the minimum element in constant time
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Stack", slug: "stack" }
|
||||
- { name: "Design", slug: "design" }
|
||||
video: "qkLl7nAwDPo"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/68"
|
||||
sidebar:
|
||||
label: 'Min Stack'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Stack</Badge>
|
||||
|
||||
::::warning
|
||||
You must implement a solution with O(1) time complexity for each function.
|
||||
::::
|
||||
@@ -76,7 +80,3 @@ class MinStack:
|
||||
# param_3 = obj.top()
|
||||
# param_4 = obj.getMin()
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="qkLl7nAwDPo" title="Design Min Stack - Amazon Interview Question - Leetcode 155 - Python" />
|
||||
+7
-6
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: '20. Valid Parentheses'
|
||||
description: Given a string s containing just the characters '(', ')', '{', '}', '[' and ']', determine if the input string is valid
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "String", slug: "string" }
|
||||
- { name: "Stack", slug: "stack" }
|
||||
- { name: "Bracket Sequences", slug: "bracket-sequences" }
|
||||
video: "WTzjTskDFMg"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/67"
|
||||
sidebar:
|
||||
label: 'Valid Parentheses'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Stack</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "()"`
|
||||
- Output: `true`
|
||||
@@ -70,7 +75,3 @@ class Solution:
|
||||
|
||||
return True if not stack else False
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="WTzjTskDFMg" title="Valid Parentheses - Stack - Leetcode 20 - Python" />
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: '71. Simplify Path'
|
||||
description: You are given an absolute path for a Unix-style file system, which always begins with a slash '/'. Your task is to transform this absolute path into its simplified canonical path
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "String", slug: "string" }
|
||||
- { name: "Stack", slug: "stack" }
|
||||
issue: "https://github.com/prdlk/leetcode/issues/70"
|
||||
sidebar:
|
||||
label: 'Simplify Path'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `path = "/home/"`
|
||||
- Output: `"/home"`
|
||||
- Explanation: The trailing slash should be removed.
|
||||
|
||||
### Example 2:
|
||||
- Input: `path = "/home//foo/"`
|
||||
- Output: `"/home/foo"`
|
||||
- Explanation: Multiple consecutive slashes are replaced by a single one.
|
||||
|
||||
### Example 3:
|
||||
- Input: `path = "/home/user/Documents/../Pictures"`
|
||||
- Output: `"/home/user/Pictures"`
|
||||
- Explanation: A double period ".." refers to the directory up a level (the parent directory).
|
||||
|
||||
### Example 4:
|
||||
- Input: `path = "/../"`
|
||||
- Output: `"/"`
|
||||
- Explanation: Going one level up from the root directory is not possible.
|
||||
|
||||
### Example 5:
|
||||
- Input: `path = "/.../a/../b/c/../d/./"`
|
||||
- Output: `"/.../b/d"`
|
||||
- Explanation: "..." is a valid name for a directory in this problem.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= path.length <= 3000`
|
||||
- `path` consists of English letters, digits, period '.', slash '/' or '_'.
|
||||
- `path` is a valid absolute Unix path.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def simplifyPath(self, path: str) -> str:
|
||||
stack = []
|
||||
segments = [item for item in path.split("/") if item]
|
||||
|
||||
for p in segments:
|
||||
if stack and p == "..":
|
||||
stack.pop()
|
||||
elif not stack and p == ".." or p == ".":
|
||||
continue
|
||||
else:
|
||||
stack.append(p)
|
||||
|
||||
return "/" + "/".join(stack)
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Stack
|
||||
description: 'Items are added and removed from one end, so the last item added is the first item removed.'
|
||||
hidden: true
|
||||
---
|
||||
+7
-6
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: '11. Container With Most Water'
|
||||
description: You are given an integer array height of length n. There are n vertical lines drawn such that the two endpoints of the i^th line are (i, 0) and (i, height[i])
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Two Pointers", slug: "two-pointers" }
|
||||
- { name: "Greedy", slug: "greedy" }
|
||||
video: "UuiTKBwPgAo"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/44"
|
||||
sidebar:
|
||||
label: 'Container With Most Water'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Two Pointers</Badge>
|
||||
|
||||
::::warning
|
||||
Notice that you may not slant the container.
|
||||
::::
|
||||
@@ -62,7 +67,3 @@ class Solution:
|
||||
|
||||
return res
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="UuiTKBwPgAo" title="Container with Most Water - Leetcode 11 - Python" />
|
||||
+6
-6
@@ -1,13 +1,17 @@
|
||||
---
|
||||
title: '125. Valid Palindrome'
|
||||
description: A phrase is a palindrome if, after converting all uppercase letters into lowercase letters and removing all non-alphanumeric characters, it reads the same forward and backward. Alphanumeric characters include letters and numbers
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Two Pointers", slug: "two-pointers" }
|
||||
- { name: "String", slug: "string" }
|
||||
video: "jJXJ16kPFWg"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/41"
|
||||
sidebar:
|
||||
label: 'Valid Palindrome'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Two Pointers</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "A man, a plan, a canal: Panama"`
|
||||
- Output: `true`
|
||||
@@ -63,7 +67,3 @@ var isPalindrome = function(s) {
|
||||
return true;
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="jJXJ16kPFWg" title="Valid Palindrome - Leetcode 125 - Python" />
|
||||
+7
-6
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: '15. 3Sum'
|
||||
description: Given an integer array nums, return all the triplets [nums[i], nums[j], nums[k]] such that i != j, i != k, and j != k, and nums[i] + nums[j] + nums[k] == 0
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Two Pointers", slug: "two-pointers" }
|
||||
- { name: "Sorting", slug: "sorting" }
|
||||
video: "jzZsG8n2R9A"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/43"
|
||||
sidebar:
|
||||
label: '3Sum'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Two Pointers</Badge>
|
||||
|
||||
::::warning
|
||||
Notice that the solution set must not contain duplicate triplets.
|
||||
::::
|
||||
@@ -98,7 +103,3 @@ class Solution:
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="jzZsG8n2R9A" title="3Sum - Leetcode 15 - Python" />
|
||||
+7
-6
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: '167. Two Sum II - Input Array Is Sorted'
|
||||
description: Given a 1-indexed array of integers numbers that is already sorted in non-decreasing order, find two numbers such that they add up to a specific target number. Let these two numbers be numbers[index1] and numbers[index2] where 1 <= index1 < index2 <= numbers.length
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Two Pointers", slug: "two-pointers" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
video: "cQ1Oz4ckceM"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/42"
|
||||
sidebar:
|
||||
label: 'Two Sum II - Input Array Is Sorted'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Two Pointers</Badge>
|
||||
|
||||
::::warning
|
||||
Your solution must use only constant extra space.
|
||||
::::
|
||||
@@ -69,7 +74,3 @@ class Solution:
|
||||
j-=1
|
||||
return []
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="cQ1Oz4ckceM" title="TWO SUM II - Amazon Coding Interview Question - Leetcode 167 - Python" />
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: '189. Rotate Array'
|
||||
description: Given an integer array nums, rotate the array to the right by k steps, where k is non-negative
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Math", slug: "math" }
|
||||
- { name: "Two Pointers", slug: "two-pointers" }
|
||||
issue: "https://github.com/prdlk/leetcode/issues/47"
|
||||
sidebar:
|
||||
label: 'Rotate Array'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,2,3,4,5,6,7], k = 3`
|
||||
- Output: `[5,6,7,1,2,3,4]`
|
||||
- Explanation: rotate `1` steps to the right: `[7,1,2,3,4,5,6]` rotate `2` steps to the right: `[6,7,1,2,3,4,5]` rotate `3` steps to the right: `[5,6,7,1,2,3,4]`
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [-1,-100,3,99], k = 2`
|
||||
- Output: `[3,99,-1,-100]`
|
||||
- Explanation: rotate `1` steps to the right: `[99,-1,-100,3]` rotate `2` steps to the right: `[3,99,-1,-100]`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= nums.length <= 10^5`
|
||||
- `-2^31 <= nums[i] <= 2^31 - 1`
|
||||
- `0 <= k <= 10^5`
|
||||
- Try to come up with as many solutions as you can. There are at least three different ways to solve this problem.
|
||||
- Could you do it in-place with O(1) extra space?
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def rotate(self, nums: list[int], k: int) -> None:
|
||||
N = len(nums) - 1
|
||||
cut = k % len(nums)
|
||||
|
||||
def reverse(l, r):
|
||||
while l <= r:
|
||||
nums[l], nums[r] = nums[r], nums[l]
|
||||
l += 1
|
||||
r -= 1
|
||||
|
||||
reverse(0, N)
|
||||
reverse(0, cut - 1)
|
||||
reverse(cut, N)
|
||||
```
|
||||
+5
-6
@@ -1,13 +1,16 @@
|
||||
---
|
||||
title: '344. Reverse String'
|
||||
description: Write a function that reverses a string. The input string is given as an array of characters s
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Two Pointers", slug: "two-pointers" }
|
||||
- { name: "String", slug: "string" }
|
||||
video: "_d0T_2Lk2qA"
|
||||
sidebar:
|
||||
label: 'Reverse String'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Two Pointers</Badge>
|
||||
|
||||
:::warning
|
||||
You must do this by modifying the input array in-place with O(1) extra memory.
|
||||
:::
|
||||
@@ -55,7 +58,3 @@ var reverseString = function(s) {
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="_d0T_2Lk2qA" title="Reverse String - 3 Ways - Leetcode 344 - Python" />
|
||||
+7
-6
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: '392. Is Subsequence'
|
||||
description: Given two strings s and t, return true if s is a subsequence of t, or false otherwise
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Two Pointers", slug: "two-pointers" }
|
||||
- { name: "String", slug: "string" }
|
||||
- { name: "Dynamic Programming", slug: "dynamic-programming" }
|
||||
video: "99RVfqklbCE"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/45"
|
||||
sidebar:
|
||||
label: 'Is Subsequence'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Two Pointers</Badge>
|
||||
|
||||
::::warning
|
||||
Suppose there are lots of incoming s, say s1, s2, ..., sk where k >= 10^9, and you want to check one by one to see if t has its subsequence. In this scenario, how would you change your code?
|
||||
::::
|
||||
@@ -58,7 +63,3 @@ class Solution:
|
||||
|
||||
return i == len(s)
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="99RVfqklbCE" title="Is Subsequence - Leetcode 392" />
|
||||
@@ -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
|
||||
```
|
||||
Loaded 100 of 228 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user