mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
Compare commits
192
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1c8f1a0f1 | ||
|
|
57a5917e09 | ||
|
|
f8e90d7f55 | ||
|
|
a39336f7ed | ||
|
|
6396f1fbec | ||
|
|
82fe05b225 | ||
|
|
d7fbe88eb8 | ||
|
|
856ab0667a | ||
|
|
2c574018fb | ||
|
|
1f6413f3d8 | ||
|
|
3b9079f87f | ||
|
|
d5496581df | ||
|
|
e931391200 | ||
|
|
790e256127 | ||
|
|
a8bd4fbcd7 | ||
|
|
a7737502e1 | ||
|
|
2ac54e9930 | ||
|
|
ef91c0d1fe | ||
|
|
910e83fa64 | ||
|
|
3c9feb33fe | ||
|
|
b76b199b17 | ||
|
|
cd9aef1229 | ||
|
|
34b944d32d | ||
|
|
b710c777c3 | ||
|
|
19dedc52ce | ||
|
|
d9dc90e433 | ||
|
|
9c2aaf5969 | ||
|
|
a30e593202 | ||
|
|
d194cedf7a | ||
|
|
5d2d1dff3c | ||
|
|
dde06daea3 | ||
|
|
6e9c407698 | ||
|
|
63f0a81af3 | ||
|
|
5ed33057ed | ||
|
|
f4fc0fd0e7 | ||
|
|
f2a2d2d7de | ||
|
|
fa74adeda7 | ||
|
|
e11c9723f8 | ||
|
|
d2c645bef8 | ||
|
|
2f94b02e15 | ||
|
|
6f1cb93395 | ||
|
|
b310f410a2 | ||
|
|
436ff3080e | ||
|
|
d3f7b1c6f9 | ||
|
|
8a331f4b54 | ||
|
|
bc6eb3e66b | ||
|
|
dd929cca6c | ||
|
|
5987fdce7d | ||
|
|
6879afa61c | ||
|
|
40e965aa06 | ||
|
|
eea8d242e0 | ||
|
|
50b42108eb | ||
|
|
6f375e6c14 | ||
|
|
667bc06965 | ||
|
|
e63d517c9a | ||
|
|
4c297b4524 | ||
|
|
32dadf5928 | ||
|
|
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 | ||
|
|
90082518a6 | ||
|
|
e10a913546 | ||
|
|
fec94ed636 | ||
|
|
30273d0030 | ||
|
|
50299defef | ||
|
|
c843be855e | ||
|
|
1e0e03daaa | ||
|
|
7b182206dd | ||
|
|
faaaf52f85 | ||
|
|
090f3d6719 | ||
|
|
a3ae16ad96 | ||
|
|
9faa0b0337 | ||
|
|
cc29304903 | ||
|
|
c50b600103 | ||
|
|
a86d72c7db | ||
|
|
e59d78e571 | ||
|
|
a93bb068b6 | ||
|
|
53b2d5ec8c | ||
|
|
5dda64e41e | ||
|
|
d798871fbc | ||
|
|
bb7324fe8c | ||
|
|
d13859d31e | ||
|
|
664ea534ed | ||
|
|
b86f36daf1 | ||
|
|
7bae7b2920 | ||
|
|
64173857e7 | ||
|
|
002c2c9c97 | ||
|
|
fa8deac963 | ||
|
|
ef8d2b41e1 |
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,149 @@
|
||||
---
|
||||
name: problem-approach
|
||||
description: Write the `## Approach` section — a Mermaid flowchart that traces the submitted solution — for every docs solution page under apps/docs/content/ that lacks one, using the repo's exact placement and node conventions. Use when asked to add/implement/fill in missing approaches, draw the approach diagram for a problem page, or when `/problem-approach` is run.
|
||||
---
|
||||
|
||||
# Problem approach diagrams
|
||||
|
||||
Every solution page (`apps/docs/content/(<phase>)/<topic>/<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 -L '^## Approach' apps/docs/content/*/*.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), then run the
|
||||
docs build once at the end and review the pages in `bun run dev`:
|
||||
|
||||
```sh
|
||||
cd apps/docs && 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: `apps/docs/content/(array)/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 `(hash-table)/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 (`(array)/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,39 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
bun apps/cli/sync-project-fields.ts
|
||||
|
||||
# One timever release per finished topic (every core sub-issue closed),
|
||||
# tagged at the instant the last core problem closed. It reads the same rule
|
||||
# close-topics uses (apps/cli/topics.ts) straight off the sub-issues, so the
|
||||
# push that finishes a topic releases it in this run — no waiting for Close
|
||||
# Topics, which chains after this workflow. Reconciling: a run releases
|
||||
# every finished topic still missing one, so a manual dispatch backfills and
|
||||
# a problem closed by hand in the UI is picked up by the next run.
|
||||
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 }}
|
||||
@@ -30,7 +30,13 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2 # skip if you use npm/pnpm
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
# blume.config.ts pulls the /changelog pages from this repo's GitHub
|
||||
# releases (githubReleases source) at build time. The token authenticates
|
||||
# that fetch — required if the repo is private, and it lifts the shared
|
||||
# runner's unauthenticated API rate limit. `contents: read` covers it.
|
||||
- run: bun run build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
@@ -48,8 +54,9 @@ jobs:
|
||||
|
||||
# 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 +69,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,5 +1,6 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
.openlogs
|
||||
tmp/
|
||||
*.log
|
||||
.crush
|
||||
|
||||
@@ -14,6 +14,7 @@ flowchart LR
|
||||
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-solved.yml release job| Rel[timever release per finished topic]
|
||||
GH[GitHub issues = catalog] -->|nightly reconcile| D1
|
||||
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
|
||||
Mail -->|GET /log| D1
|
||||
@@ -23,6 +24,7 @@ flowchart LR
|
||||
D1 -->|Sat midnight ET| Review[Review — Week N issue]
|
||||
PI -->|"spaced-repetition.yml (6 AM ET cron, closed +3d/+7d)"| SR[Spaced Repetition — Month D issue]
|
||||
D1 --> Charts[SVG charts + /api/stats]
|
||||
Charts -->|bun run stats| TUI[terminal dashboard]
|
||||
```
|
||||
|
||||
- **Reconcile, don't react.** `close-solved.ts`, `close-topics.ts`, and the Worker's catalog sync recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional. `close-solved` posts its *whole* implemented set to `/admin/solved`, not just the issues it closed this run.
|
||||
@@ -32,9 +34,11 @@ 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 — Blume's changelog makes it the page's meta description — then closed on the finish day / earlier / open at release, with solution links at the tag). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs `/changelog` is these releases (`githubReleases` in `apps/docs/blume.config.ts`, fetched at build with `GITHUB_TOKEN`), dated by the timever tag, not `published_at` (which the API cannot backdate): `releasesDatedByTag` rewrites each timever release's `published_at` before Blume maps it, via `custom(githubReleasesSource(…))` because the config-level `githubReleases()` takes no `fetchImpl`. Changing the tag format breaks the changelog dates.
|
||||
|
||||
## Key Directories
|
||||
|
||||
@@ -42,9 +46,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) |
|
||||
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/<num>-<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync (a phase's once, then hand-owned): the phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment (`/tree-dfs/invert-binary-tree`), the `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order with each topic a collapsible group. A problem page's badge row (between the description and the body) is rendered, not written: the MDX `wrapper` (`components/ProblemBadges.astro`, registered as `mdx.wrapper` in `components.ts` — Blume's page template renders the title and description itself with no slot before the body, so the wrapper, which MDX hands no route, reads it off `Astro.url`) reads the page's `topics:` (LeetCode's topic tags, from sync) and `video:` (NeetCode YouTube id, from `bun run videos`) frontmatter — both declared via `frontmatter.extend` in `blume.config.ts`, hand-rolled Standard Schemas, no zod — and links a tag the campaign teaches to its topic landing page (filled), any other tag to its guide page `/guides/<slug>` (outlined; LeetCode's problem list if the guide is gone, gray and iconless), and the video as a red `Explanation` badge. Every badge in the row is a pill. A tag badge carries the icon its target shows in the sidebar (read off `data.navigation`: the topic `meta.ts` icon, the guide's `icon:`) and the tag's own colour from `lib/tag-colors.ts` — unique per tag site-wide, assigned greedily over every tag with a page so tags that share problem pages sit furthest apart in OKLab, in a lightness band that keeps ≥ 3:1 contrast in both themes (Blume's Badge takes one hex for both). The reverse direction is the `PageFooter` slot (`components/BacklinksList.astro`): an Obsidian-style `<num> <name>` list under every topic landing and guide page of the problems whose badges link there, plus a topic's own folder members (so a topic no tag names, `hybrid-structures`, still lists its problems), in a native `<details>` open on load whose top-right chevron collapses it (no JS); problem pages are recognised by their `<num>. <name>` title and render no list. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), and where a badge points is ONE function beside it, `tagTarget()` — read by both components (so badges and backlinks cannot disagree) and the map by sync. `content/guides/` (the `Guides` header tab) is one page per tag outside that map, filed by hand into `(group)` categories — `(1-data-structures)/`, `(2-algorithms)/`, `(3-techniques)/` (hand-written `meta.ts` each; no route segment, so every guide stays `/guides/<slug>`). Sync finds a guide by slug anywhere under `guides/` and creates a placeholder only when none exists or it is empty — at the `guides/` root, for you to file into a category and give a Lucide `icon:` — the one every badge linking to it shows — that no topic and no other guide uses (sync warns while it is missing, a `TOPIC_ICONS` value, or another guide's) — and after that owns exactly one key of it: `sidebar.badge`, the sidebar count, rewritten every run to the guide's backlink count (problem pages carrying the tag — equal to the `BacklinksList` rows, since a guide's tag has no landing; dropped at zero). Everything else is hand-owned (a guide whose tag no page carries any more is left alone, badge dropped), with frontmatter `list:` — the tag's LeetCode problem list, rendered by the same wrapper as an orange `LeetCode list` badge; sync also writes `guides/meta.ts`. A hand-written `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is that topic's landing page: Blume ≥ 1.7.3 links the sidebar group row to it (`sliding-window/index.mdx` → `/sliding-window`) and the hidden flag drops the duplicate row. Two kinds of custom MDX component, both import-free on every page: `islands/<Name>.tsx` is hydrated React (auto-discovered; the five `/progress` cards `SolvedRing`/`SubmissionHeatmap`/`SubmissionChart`/`TopicBubbles`/`RecentSolves`, and `DependencySpine` — the README topic map as data, zoom/pan, nodes link to whatever route Blume's `/api/docs/navigation.json` reports for the topic folder, inert until a topic has pages), `components/<Name>.astro` registered in `components.ts` is static markup plus a custom element for behaviour, the way Blume's own `Frame` is built (`Carousel`/`CarouselImage`: `src` takes a URL, `/file.png`, or the page-relative `../../public/file.png`) |
|
||||
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
|
||||
| `.github/workflows/` | deploy (docs → Pages with `GITHUB_TOKEN` for the releases-backed changelog, Worker → Cloudflare with the `OPENROUTER_API_KEY` Actions secret put into it first, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
|
||||
| `.githooks/` | Tracked git hooks; `bun install` points `core.hooksPath` here (root `prepare` script, no husky). `pre-commit` runs `bun run sync` and stages only what sync changed (pages, `apps/docs/redirects.ts`), so a solution, its page and its `/<num>` short link land in one commit; it refuses to run while `work/1` has anything unstaged (sync reads the tree, so the page would not match the commit), and leaves pre-existing dirt under `apps/docs/content` (in-progress `## Approach` prose) unstaged. `git commit --no-verify` for a deliberate partial commit |
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -56,14 +61,23 @@ bun run pick # scaffold a solution via leetcode-cli (fuzzy picker; Ta
|
||||
# re-solves → work/3, work/7. Each section hides what its own bucket
|
||||
# already holds; index+descriptions cached in
|
||||
# ~/.local/share/leetcode/data.db — see apps/cli/db.ts)
|
||||
bun run stats # terminal dashboard over GET /api/stats — four panes (Overview,
|
||||
# Ladder, Concepts, Activity); Tab/1-4 switches, ↑↓ scrolls,
|
||||
# r refetches, q quits. Outside a TTY it prints every pane once.
|
||||
# SRS_API points it at a local Worker like close-solved does.
|
||||
bun run test # run ONE solution against LeetCode's judge (not a test suite;
|
||||
# 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 sync # work/1 → apps/docs/content/ pages + apps/docs/redirects.ts (/<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 dev|build # Blume docs site (runs in apps/docs/)
|
||||
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
|
||||
@@ -92,21 +106,22 @@ 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` (the one vocabulary shared by the charts, the `work/<n>` buckets and the review issues), ET date math, `workingDay()` (no scheduled date is ever a Sunday), 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/srs.ts` — SRS domain: the ladder (`new → +3 → +7 → retired`; fail resets to `+3`; first-ever log enters at `+3`), `WINDOWS`/`STAGES`/`TEMPERATURES`/`DIFFICULTIES`/`SETS` (the one vocabulary shared by the charts, the `work/<n>` buckets, the review issues and the stats dashboard), ET date math, `workingDay()` (no scheduled date is ever a Sunday), `temperatureOf()` (days since a concept was last exercised → `hot`/`fresh`/`fading`/`cold`, bands derived from `WINDOWS` so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
|
||||
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`, and `OPENROUTER_API_KEY`, whose one copy is the repo's Actions secret (deploy.yml puts it before every deploy — rotate it there).
|
||||
- `apps/api/src/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice.
|
||||
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/56) LeetCode Daily Digest`.
|
||||
- `apps/api/src/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: only `## Solution` onward is script-owned on existing pages; human prose is never touched; never hand-write solution pages or edit inside `## Solution`.
|
||||
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `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.
|
||||
- `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/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) is filled once — on create, or on any run that finds it missing — never refreshed (delete the key to refetch; offline, the page is written without it and the next sync retries). `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`. The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "group"`); the phase `meta.ts` is written once when missing (`Phase N: Name`, `order: N`) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
|
||||
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
|
||||
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` / `apps/docs/lib/stats.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs `/progress` islands and `bun run stats`, so its shape is their contract: change the Worker and both readers together (additive fields a reader ignores are safe). `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
|
||||
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache). The docs `/progress` page is five LeetCode-style islands — `SolvedRing` (difficulty gauge + "Attempting"), `SubmissionHeatmap` and `SubmissionChart` sharing one row (the heatmap: the last six months in month blocks, the README's colour ramp as theme.css `--heat-0..4`, its attempts / active days / max streak counted from the cells it draws; the chart: this calendar year as stacked Easy/Med./Hard bars per month or week, first solves or every attempt, via two shadcn `ToggleGroup`s), `TopicBubbles` (the campaign topics circle-packed by `d3-hierarchy`, filled by solved share), `RecentSolves` (the ten most recent, linked through `redirects.ts`) — composed from shadcn/ui (`components/ui/{card,chart,skeleton,toggle,toggle-group}.tsx`, Recharts under `chart`, Radix under the toggles) on Blume's tokens: `apps/docs/theme.css` maps shadcn's names onto `--blume-*` (never `accent`, which is Blume's brand colour — vendored components that use it, like `toggle.tsx`, are retokened to `background`/`muted`) and gives shadcn's `data-slot` parts their border colour. They share `lib/stats.ts` (`useStats()`), so the page makes ONE `/api/stats` request however many islands hydrate. shadcn sources live outside `islands/` (every `.tsx` there becomes an island) and import `lib/utils` relatively — Blume's Vite has no `@/` alias; `components.json` + `tsconfig.json` exist for the shadcn CLI and `tsc`, so after `shadcn add` rewrite `@/…` imports to relative ones — and check `package.json`: the CLI has twice installed an unrelated `cn` package for `lib/utils` and skipped real deps (`class-variance-authority`). Blume's `.prose :where(a, p, ol)` rules ignore `not-prose`, so island markup overrides them with `!` utilities, as Blume's own components do.
|
||||
- `apps/docs/blume.config.ts` — site base `/leetcode`; imports `apps/docs/redirects.ts`, sync-generated (`/<num>` → `/<topic>/<slug>`, one row per page, exact root-relative paths — Blume adds the base and, for the static Pages build, emits a meta-refresh page per rule plus `blume-redirects.json`); `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`, `PHASE_ALIASES`).
|
||||
- Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
|
||||
|
||||
## 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.
|
||||
- **No root tsconfig, no ESLint, no Prettier** — match surrounding style by hand. `apps/api/` has a strict `tsconfig.json`; `Env` types are generated (`bunx wrangler types`), never hand-written.
|
||||
- **Near-zero runtime npm dependencies** — SVG, PNG, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. The **one** exception is the digest email: `apps/api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. Do not extend this exception to any other module.
|
||||
- **Near-zero runtime npm dependencies** — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. Two exceptions, each confined to one surface. The digest email: `apps/api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. And the docs `/progress` islands: shadcn/ui (`recharts`, `radix-ui`, `class-variance-authority`, `clsx`, `tailwind-merge`) plus `d3-hierarchy` for the bubble packing, client-side in `apps/docs` only. Do not extend either exception to any other module.
|
||||
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
|
||||
|
||||
## Testing & QA
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
<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">
|
||||
@@ -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
|
||||
+10
-3
@@ -14,8 +14,12 @@ 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), `gate` (Saturday's blind set, verbatim), or `sweep` (redo
|
||||
every miss, nothing new). Numbers a `gate` or `problems` entry names are
|
||||
`RESERVED`: the drill and gate samplers never draw them.
|
||||
- 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 +51,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
|
||||
|
||||
|
||||
+26
-24
@@ -1,26 +1,28 @@
|
||||
{
|
||||
"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": { "problems": [399, 261, 909, 77, 52] },
|
||||
"2026-10-02": { "sweep": true },
|
||||
"2026-10-03": { "gate": [347, 1254, 1971, 131, 494, 986] }
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -10,14 +10,11 @@
|
||||
* so relabelling a problem is enough to move it and titles stay readable.
|
||||
* The `problem` label is what marks a sub-issue as curriculum.
|
||||
*/
|
||||
import { workingDay } from "./srs.ts";
|
||||
import { DIFFICULTIES, SETS, workingDay } from "./srs.ts";
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
||||
|
||||
const DIFFICULTIES = ["easy", "medium", "hard"] as const;
|
||||
const SETS = ["core", "optional", "deferred"] as const;
|
||||
|
||||
/** Deferred Hards enter the queue from this date, two per working day. */
|
||||
const DEFER_FROM = "2026-09-28";
|
||||
|
||||
|
||||
+70
-24
@@ -5,14 +5,16 @@
|
||||
* 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) and a set
|
||||
* day's problems — 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 +23,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 +69,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,28 +120,42 @@ 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 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).
|
||||
|
||||
+85
-17
@@ -83,7 +83,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,6 +91,13 @@ 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;
|
||||
@@ -98,11 +105,16 @@ export interface DigestData {
|
||||
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[];
|
||||
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 +290,15 @@ 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.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 +327,47 @@ 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.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
|
||||
@@ -409,16 +458,27 @@ 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.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 +496,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) {
|
||||
|
||||
+31
-13
@@ -2,21 +2,27 @@
|
||||
* 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. 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 +38,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 +50,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 +73,24 @@ 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) {
|
||||
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 +107,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 +118,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 +172,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";
|
||||
@@ -322,6 +323,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 });
|
||||
|
||||
@@ -8,6 +8,8 @@ import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
INTERVAL,
|
||||
RESERVED,
|
||||
SCHEDULE,
|
||||
STAGES,
|
||||
WINDOWS,
|
||||
addDays,
|
||||
@@ -19,6 +21,9 @@ import {
|
||||
isoWeek,
|
||||
rng,
|
||||
sample,
|
||||
TEMPERATURES,
|
||||
temperatureOf,
|
||||
topicDate,
|
||||
weekdayOf,
|
||||
workingDay,
|
||||
} from "./srs.ts";
|
||||
@@ -123,6 +128,38 @@ 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);
|
||||
}
|
||||
});
|
||||
|
||||
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("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"]);
|
||||
@@ -132,6 +169,31 @@ describe("the ladder", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("concept temperature", () => {
|
||||
test("the bands are the ladder's own windows", () => {
|
||||
// Boundaries are inclusive, and the bands tile the whole age axis with no
|
||||
// gap: whatever the windows become, every age still lands in exactly one.
|
||||
expect(TEMPERATURES).toEqual(["hot", "fresh", "fading", "cold"]);
|
||||
expect(temperatureOf(0)).toBe("hot");
|
||||
expect(temperatureOf(WINDOWS[0])).toBe("hot");
|
||||
expect(temperatureOf(WINDOWS[0] + 1)).toBe("fresh");
|
||||
expect(temperatureOf(WINDOWS[1])).toBe("fresh");
|
||||
expect(temperatureOf(WINDOWS[1] + 1)).toBe("fading");
|
||||
expect(temperatureOf(WINDOWS[1] * 2)).toBe("fading");
|
||||
expect(temperatureOf(WINDOWS[1] * 2 + 1)).toBe("cold");
|
||||
});
|
||||
|
||||
test("never attempted is cold, not unknown", () => {
|
||||
expect(temperatureOf(null)).toBe("cold");
|
||||
});
|
||||
|
||||
test("every age of the campaign has exactly one band", () => {
|
||||
for (let age = 0; age <= CALENDAR.length; age++) {
|
||||
expect(TEMPERATURES).toContain(temperatureOf(age));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deterministic sampling", () => {
|
||||
test("same seed, same picks", () => {
|
||||
const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
|
||||
+118
-9
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* SRS domain: ET dates, the work week, the interval ladder, deterministic
|
||||
* sampling, and the one write path for attempts.
|
||||
* SRS domain: ET dates, the work week, the interval ladder, concept
|
||||
* temperature, deterministic sampling, and the one write path for attempts.
|
||||
*
|
||||
* D1 is the single source of truth. The stage names the review a problem must
|
||||
* pass NEXT (`+3` = due 3 working days after the last clean solve). Passing
|
||||
@@ -126,16 +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),
|
||||
* 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[];
|
||||
/** Saturday: the blind set, fixed by the plan rather than sampled. */
|
||||
gate?: number[];
|
||||
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
|
||||
sweep?: boolean;
|
||||
}
|
||||
|
||||
/** Week in which a topic was (or will be) taught. */
|
||||
export function topicWeek(topic: number): number | undefined {
|
||||
for (const [date, t] of Object.entries(SCHEDULE)) {
|
||||
if (t === topic) return campaignWeek(date);
|
||||
export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
|
||||
|
||||
/** Date the schedule teaches a topic on, if it teaches it at all. */
|
||||
export function topicDate(topic: number): string | undefined {
|
||||
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||
if (day.topics?.includes(topic)) return date;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Week in which a topic was (or will be) taught. */
|
||||
export function topicWeek(topic: number): number | undefined {
|
||||
const date = topicDate(topic);
|
||||
return date === undefined ? undefined : campaignWeek(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every problem the plan names outright — a gate's blind set or a set day's
|
||||
* list. 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 day's problems wait for their day.
|
||||
*/
|
||||
export const RESERVED: ReadonlySet<number> = new Set(
|
||||
Object.values(SCHEDULE).flatMap((day) => [...(day.gate ?? []), ...(day.problems ?? [])]),
|
||||
);
|
||||
|
||||
// ── deterministic sampling ───────────────────────────────────────
|
||||
|
||||
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
|
||||
@@ -182,6 +216,45 @@ export const STAGES: Stage[] = ["new", "+3", "+7", "retired"];
|
||||
export type Result = "pass" | "fail";
|
||||
export type Kind = "first" | "review" | "drill" | "gate";
|
||||
|
||||
/**
|
||||
* Catalog vocabulary, carried by the `diff:*` / `set:*` issue labels and
|
||||
* stored verbatim in `problems`. Listed low to high / most to least required,
|
||||
* which is the order every chart, table and digest section prints them in.
|
||||
*/
|
||||
export const DIFFICULTIES = ["easy", "medium", "hard"] as const;
|
||||
export type Difficulty = (typeof DIFFICULTIES)[number];
|
||||
export const SETS = ["core", "optional", "deferred"] as const;
|
||||
export type SetLabel = (typeof SETS)[number];
|
||||
|
||||
/**
|
||||
* How warm a concept is: the age of a topic's most recent attempt, bucketed by
|
||||
* the ladder's own windows so the two cannot drift. Touched inside the first
|
||||
* window it is `hot`, inside the second `fresh`, inside one more full second
|
||||
* window `fading`; older than that — or never attempted — it is `cold`.
|
||||
*
|
||||
* A read-side label only: nothing is scheduled off it. It answers the question
|
||||
* the ladder cannot, because the ladder tracks problems and a topic can go
|
||||
* quiet for a fortnight while not one of its problems comes due.
|
||||
*/
|
||||
export const TEMPERATURES = ["hot", "fresh", "fading", "cold"] as const;
|
||||
export type Temperature = (typeof TEMPERATURES)[number];
|
||||
|
||||
/** Warmest band first, with the age it tolerates. Past the last one is cold. */
|
||||
const TEMPERATURE_MAX_AGE: [Temperature, number][] = [
|
||||
["hot", WINDOWS[0]],
|
||||
["fresh", WINDOWS[1]],
|
||||
["fading", WINDOWS[1] * 2],
|
||||
];
|
||||
|
||||
/** Days since a topic's last attempt → its band; null (never) is cold. */
|
||||
export function temperatureOf(age: number | null): Temperature {
|
||||
if (age === null) return "cold";
|
||||
for (const [temperature, max] of TEMPERATURE_MAX_AGE) {
|
||||
if (age <= max) return temperature;
|
||||
}
|
||||
return "cold";
|
||||
}
|
||||
|
||||
export interface ProblemRow {
|
||||
lc_number: number;
|
||||
issue: number;
|
||||
@@ -203,6 +276,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
|
||||
@@ -212,6 +310,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
|
||||
@@ -313,8 +420,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,
|
||||
@@ -337,6 +445,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;
|
||||
});
|
||||
|
||||
+228
-23
@@ -1,19 +1,34 @@
|
||||
/**
|
||||
* GET /api/stats — the one JSON document behind the docs progress page.
|
||||
* Read-only aggregation over D1; shape is the page's contract, change both
|
||||
* together. A problem counts as "done" once it entered the ladder
|
||||
* (stage != 'new').
|
||||
* GET /api/stats — the one JSON document behind the docs progress page and the
|
||||
* `bun run stats` terminal dashboard. Read-only aggregation over D1; the shape
|
||||
* is both readers' contract, so change all three together. A problem counts as
|
||||
* "done" once it entered the ladder (stage != 'new').
|
||||
*
|
||||
* The temperature block is the one figure here that is not a count of rows:
|
||||
* 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,
|
||||
daysBetween,
|
||||
isoWeek,
|
||||
STAGES,
|
||||
streak,
|
||||
temperatureOf,
|
||||
topicDate,
|
||||
type Temperature,
|
||||
} from "./srs.ts";
|
||||
|
||||
const PHASE_NAMES: Record<number, string> = {
|
||||
@@ -24,6 +39,97 @@ const PHASE_NAMES: Record<number, string> = {
|
||||
5: "V — Decision Space",
|
||||
};
|
||||
|
||||
/** One concept: catalog coverage plus how long since it was last exercised. */
|
||||
interface TopicStat {
|
||||
issue: number;
|
||||
name: string;
|
||||
milestone: number | null;
|
||||
/** Campaign week the schedule teaches it in; null when unscheduled. */
|
||||
week: number | null;
|
||||
/** False while the schedule has not reached it — temperature is moot. */
|
||||
taught: boolean;
|
||||
total: number;
|
||||
done: number;
|
||||
retired: number;
|
||||
due: number;
|
||||
/** ET date of the most recent attempt on any of its problems. */
|
||||
last: string | null;
|
||||
/** Days since `last`; null when never attempted. */
|
||||
age: number | null;
|
||||
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.
|
||||
const { results: coverage } = await db
|
||||
.prepare(
|
||||
`SELECT t.issue AS issue, t.name AS name, t.milestone AS milestone,
|
||||
COUNT(p.lc_number) AS total,
|
||||
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done,
|
||||
SUM(CASE WHEN p.stage = 'retired' THEN 1 ELSE 0 END) AS retired
|
||||
FROM topics t LEFT JOIN problems p ON p.topic_issue = t.issue
|
||||
GROUP BY t.issue
|
||||
ORDER BY t.issue`,
|
||||
)
|
||||
.all<{
|
||||
issue: number;
|
||||
name: string;
|
||||
milestone: number | null;
|
||||
total: number;
|
||||
done: number | null;
|
||||
retired: number | null;
|
||||
}>();
|
||||
|
||||
const { results: activity } = await db
|
||||
.prepare(
|
||||
`SELECT p.topic_issue AS issue, MAX(a.date) AS last
|
||||
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
|
||||
GROUP BY p.topic_issue`,
|
||||
)
|
||||
.all<{ issue: number; last: string | null }>();
|
||||
const lastByTopic = new Map<number, string>();
|
||||
for (const row of activity) {
|
||||
if (row.last) lastByTopic.set(row.issue, row.last);
|
||||
}
|
||||
|
||||
const { results: dueRows } = await db
|
||||
.prepare(
|
||||
`SELECT topic_issue AS issue, COUNT(*) AS n
|
||||
FROM problems WHERE ${DUE_WHERE} GROUP BY topic_issue`,
|
||||
)
|
||||
.bind(today)
|
||||
.all<{ issue: number; n: number }>();
|
||||
const dueByTopic = new Map<number, number>();
|
||||
for (const row of dueRows) dueByTopic.set(row.issue, row.n);
|
||||
|
||||
return coverage.map((row) => {
|
||||
const taughtOn = topicDate(row.issue);
|
||||
const last = lastByTopic.get(row.issue) ?? null;
|
||||
const age = last === null ? null : daysBetween(last, today);
|
||||
return {
|
||||
issue: row.issue,
|
||||
name: row.name,
|
||||
milestone: row.milestone,
|
||||
week: taughtOn === undefined ? null : campaignWeek(taughtOn),
|
||||
taught: taughtOn !== undefined && taughtOn <= today,
|
||||
total: row.total,
|
||||
done: row.done ?? 0,
|
||||
retired: row.retired ?? 0,
|
||||
due: dueByTopic.get(row.issue) ?? 0,
|
||||
last,
|
||||
age,
|
||||
temperature: temperatureOf(age),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildStats(db: D1Database, today: string): Promise<object> {
|
||||
const { results: phaseRows } = await db
|
||||
.prepare(
|
||||
@@ -65,6 +171,21 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
||||
const ladder: Record<string, number> = Object.fromEntries(STAGES.map((s) => [s, 0]));
|
||||
for (const row of ladderRows) ladder[row.stage] = row.n;
|
||||
|
||||
// Same shape as `ladder`: every band present, zeros included, in DIFFICULTIES
|
||||
// order so a reader can index it without sorting.
|
||||
const { results: diffRows } = await db
|
||||
.prepare(
|
||||
`SELECT difficulty,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN stage != 'new' THEN 1 ELSE 0 END) AS done
|
||||
FROM problems GROUP BY difficulty`,
|
||||
)
|
||||
.all<{ difficulty: string; total: number; done: number }>();
|
||||
const difficulty = DIFFICULTIES.map((name) => {
|
||||
const row = diffRows.find((r) => r.difficulty === name);
|
||||
return { difficulty: name, total: row?.total ?? 0, done: row?.done ?? 0 };
|
||||
});
|
||||
|
||||
// gates rows are keyed by ISO week (that's the digest/gate contract), but
|
||||
// the page speaks campaign weeks. The campaign never crosses a year
|
||||
// boundary, so a plain offset converts safely.
|
||||
@@ -74,29 +195,108 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
||||
.all<{ week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }>();
|
||||
const gates = gateRows.map((g) => ({ ...g, week: g.week - isoWeekOffset }));
|
||||
|
||||
// Review-queue depth for the next 14 days: everything due by that day.
|
||||
const queue: { date: string; due: number }[] = [];
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const date = addDays(today, i);
|
||||
const row = await db
|
||||
.prepare(`SELECT COUNT(*) AS n FROM problems WHERE ${DUE_WHERE}`)
|
||||
.bind(date)
|
||||
.first<{ n: number }>();
|
||||
queue.push({ date, due: row?.n ?? 0 });
|
||||
}
|
||||
// Review-queue depth for the next 14 days: everything due by that day. One
|
||||
// statement per day, but DUE_WHERE stays the only definition of "due" — the
|
||||
// batch is a single round trip, not fourteen.
|
||||
const dates = Array.from({ length: 14 }, (_, i) => addDays(today, i));
|
||||
const queueRows = await db.batch<{ n: number }>(
|
||||
dates.map((date) =>
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM problems WHERE ${DUE_WHERE}`).bind(date),
|
||||
),
|
||||
);
|
||||
const queue = dates.map((date, i) => ({ date, due: queueRows[i]?.results[0]?.n ?? 0 }));
|
||||
|
||||
const recent: { date: string; attempts: number; passes: number }[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = addDays(today, -i);
|
||||
const row = await db
|
||||
// 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(
|
||||
`SELECT COUNT(*) AS attempts,
|
||||
`SELECT date,
|
||||
COUNT(*) AS attempts,
|
||||
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes
|
||||
FROM attempts WHERE date = ?`,
|
||||
FROM attempts WHERE date BETWEEN ?1 AND ?2 GROUP BY date`,
|
||||
)
|
||||
.bind(date)
|
||||
.first<{ attempts: number; passes: number | null }>();
|
||||
recent.push({ date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 });
|
||||
.bind(CAMPAIGN_START, end)
|
||||
.all<{ date: string; attempts: number; passes: number | null }>();
|
||||
const byDate = new Map(heatRows.map((r) => [r.date, r]));
|
||||
const heat = Array.from({ length: CAMPAIGN_DAYS }, (_, i) => {
|
||||
const date = addDays(CAMPAIGN_START, i);
|
||||
const row = byDate.get(date);
|
||||
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 {
|
||||
@@ -104,9 +304,14 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
||||
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.
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* The SRS Worker, from the CLI side: one base URL and one read-side payload.
|
||||
*
|
||||
* `SRS_API` points a script at a `wrangler dev` instance instead of production
|
||||
* — the same switch close-solved.ts uses for its `/admin/solved` push, which is
|
||||
* why the default lives here rather than in either script.
|
||||
*
|
||||
* The payload is narrowed with a guard rather than cast: D1 is the only source
|
||||
* of SRS truth, so if the Worker answers something else the caller must say so
|
||||
* out loud, not render half a dashboard off undefined fields.
|
||||
*/
|
||||
import { STAGES, TEMPERATURES, type Stage, type Temperature } from "../api/src/srs.ts";
|
||||
|
||||
export const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
||||
|
||||
// The rungs and the bands come straight from the Worker's domain module — the
|
||||
// same import work.ts makes for WINDOWS — so a change to either vocabulary
|
||||
// reaches this dashboard without a second list to remember.
|
||||
export { STAGES, TEMPERATURES, type Stage, type Temperature };
|
||||
|
||||
export interface Phase {
|
||||
milestone: number;
|
||||
name: string;
|
||||
core_done: number;
|
||||
core_total: number;
|
||||
optional_done: number;
|
||||
optional_total: number;
|
||||
deferred_done: number;
|
||||
deferred_total: number;
|
||||
}
|
||||
|
||||
export interface Topic {
|
||||
issue: number;
|
||||
name: string;
|
||||
milestone: number | null;
|
||||
week: number | null;
|
||||
taught: boolean;
|
||||
total: number;
|
||||
done: number;
|
||||
retired: number;
|
||||
due: number;
|
||||
last: string | null;
|
||||
age: number | null;
|
||||
temperature: Temperature;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
generated: string;
|
||||
campaign: { day: number; week: number; start: string; days: number };
|
||||
phases: Phase[];
|
||||
ladder: Record<Stage, number>;
|
||||
difficulty: { difficulty: string; total: number; done: number }[];
|
||||
topics: Topic[];
|
||||
/** 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 and future days included. */
|
||||
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 &&
|
||||
typeof v.streak === "number" &&
|
||||
Array.isArray(v.phases) &&
|
||||
Array.isArray(v.difficulty) &&
|
||||
Array.isArray(v.topics) &&
|
||||
Array.isArray(v.gates) &&
|
||||
Array.isArray(v.queue) &&
|
||||
Array.isArray(v.heat)
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/stats. Throws with the reason; there is no local substitute. */
|
||||
export async function fetchStats(signal?: AbortSignal): Promise<Stats> {
|
||||
const res = await fetch(`${SRS_API}/api/stats`, { signal });
|
||||
if (!res.ok) throw new Error(`GET /api/stats -> ${res.status} ${await res.text()}`);
|
||||
const body: unknown = await res.json();
|
||||
if (!isStats(body)) throw new Error("GET /api/stats -> unrecognised payload shape");
|
||||
return body;
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
*/
|
||||
import { join } from "node:path";
|
||||
|
||||
import { SRS_API } from "./api.ts";
|
||||
import { github } from "./github.ts";
|
||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
import { isImplemented } from "./source.ts";
|
||||
@@ -162,7 +163,8 @@ for (const num of [...work.keys()].sort((a, b) => a - b)) {
|
||||
// rung named, so the whole implemented set can go over on every push (a total
|
||||
// recompute that backfills whatever earlier runs missed) and re-runs write
|
||||
// nothing.
|
||||
const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
||||
// SRS_API (apps/cli/api.ts) is the switch that points this at a wrangler dev
|
||||
// instance instead of production.
|
||||
const solved = [...work.entries()]
|
||||
.flatMap(([lc, entry]) => [...entry.solved].sort().map((bucket) => ({ lc, bucket })))
|
||||
.sort((a, b) => a.lc - b.lc || a.bucket - b.bucket);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* `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").
|
||||
* The rule lives in topics.ts, shared with release-topics.ts.
|
||||
*
|
||||
* 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
|
||||
@@ -19,6 +20,7 @@
|
||||
*/
|
||||
import { github } from "./github.ts";
|
||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
import { coreSet, readProblems } from "./topics.ts";
|
||||
|
||||
const DRY =
|
||||
process.argv.includes("--dry-run") ||
|
||||
@@ -53,52 +55,13 @@ 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 };
|
||||
}
|
||||
|
||||
// ── reconcile ────────────────────────────────────────────────────
|
||||
|
||||
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));
|
||||
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,
|
||||
|
||||
@@ -4,15 +4,18 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"pick": "bun ./pick.ts",
|
||||
"stats": "bun ./stats.ts",
|
||||
"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
+340
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Publish one GitHub release per finished topic, versioned by timever
|
||||
* (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — https://gist.github.com/twolfson/de1b004dd22536b8e668).
|
||||
*
|
||||
* "Finished" is topics.ts's rule — every core sub-issue closed — the same one
|
||||
* close-topics.ts closes the topic issue on, and the version is the instant
|
||||
* the LAST core problem closed. Keying off the sub-issues rather than the
|
||||
* topic issue lets this run as the tail of Close Solved: the push that closes
|
||||
* a topic's final core problem releases it in the same run, without waiting
|
||||
* for close-topics to chain after it. The topic issue itself closes seconds
|
||||
* later, on the same day.
|
||||
*
|
||||
* Each release is a changelog snapshot at that instant: the topic's problem
|
||||
* sub-issues grouped as closed on the finish day (ET), closed earlier, and
|
||||
* open at release, each with its set, difficulty, and the solution files as
|
||||
* they stood in the tagged commit — the last main commit before the finish.
|
||||
*
|
||||
* 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 (`releasesDatedByTag` in apps/docs/blume.config.ts)
|
||||
* — the tag format is that contract.
|
||||
*
|
||||
* Reconciles instead of reacting: every finished topic 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 { github } from "./github.ts";
|
||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
import { coreSet, labelValue, readProblems, type Problem } 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 (apps/docs/blume.config.ts `deployment.site`); sync publishes `/<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();
|
||||
}
|
||||
|
||||
// ── topics ───────────────────────────────────────────────────────
|
||||
|
||||
interface Topic {
|
||||
number: number;
|
||||
title: string;
|
||||
milestone: string | null;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Narrow one /issues element; the `topic` label filter cannot exclude PRs. */
|
||||
function readTopic(value: unknown): Topic | 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 {
|
||||
topic: Topic;
|
||||
problems: Problem[];
|
||||
core: number[];
|
||||
finishedAt: string;
|
||||
}
|
||||
|
||||
// Closed and open topics alike: the rule reads the sub-issues, so a topic
|
||||
// finished moments ago (close-topics not yet run) releases now.
|
||||
const finished: Finished[] = [];
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
||||
const topic = readTopic(raw);
|
||||
if (!topic) continue;
|
||||
const problems = await readProblems(gh, topic.number);
|
||||
const core = coreSet(problems);
|
||||
if (core.finishedAt) finished.push({ topic, problems, core: core.all, finishedAt: core.finishedAt });
|
||||
}
|
||||
// Oldest first, so a backfill creates releases in campaign order.
|
||||
finished.sort((a, b) => a.finishedAt.localeCompare(b.finishedAt) || a.topic.number - b.topic.number);
|
||||
|
||||
// ── existing releases ────────────────────────────────────────────
|
||||
|
||||
/** Topic -> 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 finishing in the same second (one push closing both
|
||||
* last core problems) get consecutive nanoseconds, in (instant, topic) order.
|
||||
*/
|
||||
const tags = new Map<number, string>(); // topic -> 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.topic.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 { topic, problems } = f;
|
||||
const finishDay = etDate(new Date(f.finishedAt));
|
||||
// Snapshot at the finish instant: a problem closed later was open then.
|
||||
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 open = problems.filter((p) => !closedBy(p));
|
||||
|
||||
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.core.length} core problems closed, ` +
|
||||
`${onDay.length} ${onDay.length === 1 ? "problem" : "problems"} closed that day.`;
|
||||
const fields: string[] = [];
|
||||
const scheduled = bodyField(topic.body, "Scheduled");
|
||||
if (scheduled) fields.push(`**Scheduled:** ${scheduled}`);
|
||||
if (topic.milestone) fields.push(`**Phase:** ${topic.milestone}`);
|
||||
fields.push(`**Topic:** #${topic.number}`);
|
||||
const buildsOn = bodyField(topic.body, "Builds on");
|
||||
if (buildsOn) fields.push(`**Builds on:** ${buildsOn}`);
|
||||
// The marker is how a later run finds this release; see the file header.
|
||||
const sections = [`<!-- release-topics: #${topic.number} -->\n${summary}`, fields.join(" · ")];
|
||||
sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the topic finished\n\n${table(onDay)}`);
|
||||
if (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`);
|
||||
if (open.length) sections.push(`### Open at release\n\n${table(open)}`);
|
||||
const notes = NOTES.flatMap((h) => {
|
||||
const text = bodySection(topic.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 core set completed; ` +
|
||||
`it tags ${sha.slice(0, 7)}, the last main commit before it.`,
|
||||
);
|
||||
return `${sections.join("\n\n")}\n`;
|
||||
}
|
||||
|
||||
// ── reconcile ────────────────────────────────────────────────────
|
||||
|
||||
const latest = finished.at(-1)?.topic.number;
|
||||
const report: string[][] = [];
|
||||
const previews: string[] = [];
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const f of finished) {
|
||||
const { topic } = f;
|
||||
const finishedEt = etDate(new Date(f.finishedAt));
|
||||
const existing = released.get(topic.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(topic.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([`#${topic.number}`, topic.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 = topic.title;
|
||||
|
||||
if (existing) {
|
||||
if (existing.body === body && existing.name === name) {
|
||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "released"]);
|
||||
} else if (DRY) {
|
||||
previews.push(`## ${name} (${tag})\n\n${body}`);
|
||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "would update"]);
|
||||
} else {
|
||||
await gh.api(`/repos/${gh.repo}/releases/${existing.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name, body }),
|
||||
});
|
||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "updated"]);
|
||||
updated++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DRY) {
|
||||
previews.push(`## ${name} (${tag})\n\n${body}`);
|
||||
report.push([`#${topic.number}`, topic.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", `${topic.title} (#${topic.number})\n\nCore set complete: ${f.core.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: topic.number === latest ? "true" : "false",
|
||||
}),
|
||||
});
|
||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "released now"]);
|
||||
created++;
|
||||
}
|
||||
|
||||
// ── report ───────────────────────────────────────────────────────
|
||||
|
||||
if (previews.length) console.log(`${previews.join("\n")}\n`);
|
||||
const rows = [["topic", "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 · ` +
|
||||
(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, ${released.size} already released.\n\n` +
|
||||
(report.length ? `${markdownTable(rows)}\n` : "No finished topics.\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) => {
|
||||
|
||||
Executable
+458
@@ -0,0 +1,458 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* `bun run stats` — the campaign dashboard, in the terminal.
|
||||
*
|
||||
* Four panes over ONE source: GET /api/stats. D1 owns SRS state, so this
|
||||
* fetches the Worker's aggregate rather than recomputing anything from work/
|
||||
* or from git — a second answer to "how far along am I" is exactly the drift
|
||||
* the campaign is built to avoid. The one local fact shown is the work/ file
|
||||
* inventory, which is inventory, not schedule. Unreachable Worker = an error,
|
||||
* never a reconstructed guess.
|
||||
*
|
||||
* The panes:
|
||||
* Overview campaign clock, solved counts, phase bars, recent activity
|
||||
* Ladder the +3/+7 rungs and the 14-day due queue
|
||||
* Concepts every topic by temperature — what has gone cold
|
||||
* Activity the campaign heatmap and the weekly gates
|
||||
*
|
||||
* Outside a TTY (a pipe, CI) it prints every pane once and exits, so the same
|
||||
* command works in a scrollback or a file.
|
||||
*/
|
||||
import {
|
||||
fetchStats,
|
||||
SRS_API,
|
||||
STAGES,
|
||||
TEMPERATURES,
|
||||
type Stats,
|
||||
type Temperature,
|
||||
type Topic,
|
||||
} from "./api.ts";
|
||||
import {
|
||||
BLUE,
|
||||
FG,
|
||||
GREEN,
|
||||
GREENS,
|
||||
LINE,
|
||||
MUTED,
|
||||
RED,
|
||||
YELLOW,
|
||||
bar,
|
||||
bold,
|
||||
cell,
|
||||
fg,
|
||||
heatCell,
|
||||
openScreen,
|
||||
padTo,
|
||||
rule,
|
||||
sparkline,
|
||||
stackedBar,
|
||||
width,
|
||||
} from "./tui.ts";
|
||||
import { BUCKETS, WINDOWS, solvedInBucket } from "./work.ts";
|
||||
|
||||
// ── shared vocabulary → colour ───────────────────────────────────
|
||||
|
||||
// Temperature is an action ramp, not a thermometer: green is warm enough to
|
||||
// leave alone, red is the concept that wants revisiting. The band names carry
|
||||
// the heat, the colours carry the urgency — and the glyphs carry both again,
|
||||
// so a piped or NO_COLOR dashboard still separates the bands.
|
||||
const TEMP_COLOR: Record<Temperature, string> = {
|
||||
hot: GREENS[4]!,
|
||||
fresh: GREEN,
|
||||
fading: YELLOW,
|
||||
cold: RED,
|
||||
};
|
||||
|
||||
const TEMP_GLYPH: Record<Temperature, string> = {
|
||||
hot: "█",
|
||||
fresh: "▓",
|
||||
fading: "▒",
|
||||
cold: "░",
|
||||
};
|
||||
|
||||
const DIFF_COLOR: Record<string, string> = { easy: GREEN, medium: YELLOW, hard: RED };
|
||||
|
||||
const LABEL = 15; // left-hand label column, shared by every pane
|
||||
|
||||
/** "2026-08-31" → "Mon 31", read as a plain calendar date, never localised. */
|
||||
function dayLabel(iso: string): string {
|
||||
const [y, m, d] = iso.split("-").map(Number);
|
||||
const weekday = new Date(Date.UTC(y!, m! - 1, d!)).getUTCDay();
|
||||
return `${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][weekday]} ${String(d).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// ── overview ─────────────────────────────────────────────────────
|
||||
|
||||
function overview(s: Stats, cols: number): string[] {
|
||||
const bars = Math.max(10, Math.min(48, cols - LABEL - 18));
|
||||
const lines: string[] = [];
|
||||
|
||||
const { day, days, week } = s.campaign;
|
||||
lines.push(rule("campaign", cols));
|
||||
lines.push(
|
||||
`${cell("Day", LABEL)}${bar(day, days, bars)} ${fg(FG, `${day}/${days}`)}` +
|
||||
fg(MUTED, ` week ${week}`),
|
||||
);
|
||||
const streak = s.streak > 0 ? fg(GREENS[4]!, `${s.streak} day${s.streak === 1 ? "" : "s"}`) : fg(MUTED, "none");
|
||||
lines.push(`${cell("Streak", LABEL)}${streak}`);
|
||||
|
||||
const total = s.difficulty.reduce((n, d) => n + d.total, 0);
|
||||
const done = s.difficulty.reduce((n, d) => n + d.done, 0);
|
||||
lines.push("");
|
||||
lines.push(rule("solved", cols));
|
||||
lines.push(
|
||||
`${cell("All", LABEL)}${bar(done, total, bars)} ${fg(FG, `${done}/${total}`)}` +
|
||||
fg(MUTED, ` ${total > 0 ? Math.round((done / total) * 100) : 0}%`),
|
||||
);
|
||||
for (const d of s.difficulty) {
|
||||
lines.push(
|
||||
`${cell(d.difficulty, LABEL)}${bar(d.done, d.total, bars, DIFF_COLOR[d.difficulty] ?? GREEN)}` +
|
||||
` ${fg(MUTED, `${d.done}/${d.total}`)}`,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(rule("phases", cols));
|
||||
lines.push(
|
||||
`${" ".repeat(LABEL)}${fg(GREEN, "█")} ${fg(MUTED, "core")} ${fg(BLUE, "█")} ${fg(MUTED, "optional")} ` +
|
||||
`${fg(RED, "█")} ${fg(MUTED, "deferred")} ${fg(LINE, "░")} ${fg(MUTED, "remaining")}`,
|
||||
);
|
||||
// Every phase bar is scaled to the LARGEST phase, not to itself: phases are
|
||||
// not the same size, and equal-length bars would hide that.
|
||||
const scale = Math.max(
|
||||
1,
|
||||
...s.phases.map((p) => p.core_total + p.optional_total + p.deferred_total),
|
||||
);
|
||||
for (const p of s.phases) {
|
||||
const phaseDone = p.core_done + p.optional_done + p.deferred_done;
|
||||
const phaseTotal = p.core_total + p.optional_total + p.deferred_total;
|
||||
const segments = [
|
||||
{ value: p.core_done, color: GREEN },
|
||||
{ value: p.optional_done, color: BLUE },
|
||||
{ value: p.deferred_done, color: RED },
|
||||
{ value: phaseTotal - phaseDone, color: LINE, glyph: "░" },
|
||||
];
|
||||
lines.push(
|
||||
`${cell(p.name, LABEL - 1)} ${stackedBar(segments, scale, bars)} ${fg(MUTED, `${phaseDone}/${phaseTotal}`)}`,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(rule("last 14 days", cols));
|
||||
const window = s.heat.slice(Math.max(0, day - 14), day);
|
||||
const attempts = window.reduce((n, h) => n + h.attempts, 0);
|
||||
const passes = window.reduce((n, h) => n + h.passes, 0);
|
||||
lines.push(
|
||||
`${cell("Attempts", LABEL)}${sparkline(window.map((h) => h.attempts))} ` +
|
||||
fg(MUTED, `${attempts} attempts · ${passes} passed`),
|
||||
);
|
||||
|
||||
// The one local fact on the dashboard: solutions on disk, per
|
||||
// spaced-repetition bucket. Inventory, not schedule — D1's ladder is the
|
||||
// schedule, and the Ladder pane is where it is shown. Empty scaffolds do not
|
||||
// count, the same rule close-solved.ts closes issues by.
|
||||
lines.push("");
|
||||
lines.push(rule("work/ on disk", cols));
|
||||
lines.push(
|
||||
cell("Solutions", LABEL) +
|
||||
BUCKETS.map((b) => `${fg(FG, String(solvedInBucket(b)))} ${fg(MUTED, `in work/${b}`)}`).join(
|
||||
fg(MUTED, " · "),
|
||||
),
|
||||
);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── ladder ───────────────────────────────────────────────────────
|
||||
|
||||
function ladder(s: Stats, cols: number): string[] {
|
||||
const bars = Math.max(10, Math.min(48, cols - LABEL - 18));
|
||||
const lines: string[] = [rule("ladder", cols)];
|
||||
lines.push(fg(MUTED, `${" ".repeat(LABEL)}the review each problem owes next`));
|
||||
|
||||
const rungs = STAGES.map((stage) => s.ladder[stage] ?? 0);
|
||||
const peak = Math.max(1, ...rungs);
|
||||
STAGES.forEach((stage, i) => {
|
||||
const n = rungs[i]!;
|
||||
// new is the untouched pile and retired is the finished one; only the
|
||||
// middle rungs are live work, so only they get the live colour.
|
||||
const color = stage === "new" ? LINE : stage === "retired" ? GREENS[4]! : GREEN;
|
||||
lines.push(`${cell(stage, LABEL)}${bar(n, peak, bars, color)} ${fg(FG, String(n))}`);
|
||||
});
|
||||
|
||||
lines.push("");
|
||||
lines.push(rule("due queue", cols));
|
||||
const today = s.queue[0];
|
||||
lines.push(
|
||||
fg(MUTED, `${" ".repeat(LABEL)}cumulative: everything the ladder wants by that day`) +
|
||||
(today ? fg(FG, ` · ${today.due} due today`) : ""),
|
||||
);
|
||||
const depth = Math.max(1, ...s.queue.map((q) => q.due));
|
||||
for (const q of s.queue) {
|
||||
const color = q.due > 3 ? RED : q.due > 0 ? GREEN : LINE;
|
||||
lines.push(
|
||||
`${cell(dayLabel(q.date), LABEL)}${bar(q.due, depth, bars, color)} ${fg(MUTED, String(q.due))}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── concepts ─────────────────────────────────────────────────────
|
||||
|
||||
/** Coldest first, and everything the schedule has not reached yet last. */
|
||||
function byUrgency(a: Topic, b: Topic): number {
|
||||
if (a.taught !== b.taught) return a.taught ? -1 : 1;
|
||||
return (
|
||||
TEMPERATURES.indexOf(b.temperature) - TEMPERATURES.indexOf(a.temperature) ||
|
||||
(b.age ?? Infinity) - (a.age ?? Infinity) ||
|
||||
a.issue - b.issue
|
||||
);
|
||||
}
|
||||
|
||||
// Row layout: "● " + name + temp + coverage bar + done/total + last + due.
|
||||
// Everything but the name is fixed, so the name absorbs the terminal width.
|
||||
const TEMP_W = 7;
|
||||
const COVER_W = 10;
|
||||
const COUNT_W = 6;
|
||||
const AGE_W = 6;
|
||||
const DUE_W = 3;
|
||||
const CONCEPT_FIXED = 3 + (TEMP_W + 1) + (COVER_W + 1) + (COUNT_W + 1) + (AGE_W + 1) + DUE_W + 1;
|
||||
|
||||
function concepts(s: Stats, cols: number): string[] {
|
||||
const lines: string[] = [rule("concepts by temperature", cols)];
|
||||
// Thresholds are the ladder's windows; print them rather than restate them.
|
||||
const bands: [Temperature, string][] = [
|
||||
["hot", `≤${WINDOWS[0]}d`],
|
||||
["fresh", `≤${WINDOWS[1]}d`],
|
||||
["fading", `≤${WINDOWS[1] * 2}d`],
|
||||
["cold", "older, or never"],
|
||||
];
|
||||
lines.push(
|
||||
fg(MUTED, " last exercised — ") +
|
||||
bands
|
||||
.map(([t, when]) => `${fg(TEMP_COLOR[t], `${TEMP_GLYPH[t]} ${t}`)} ${fg(MUTED, when)}`)
|
||||
.join(fg(MUTED, " · ")),
|
||||
);
|
||||
|
||||
const taught = s.topics.filter((t) => t.taught);
|
||||
const counts = TEMPERATURES.map((t) => ({
|
||||
value: taught.filter((x) => x.temperature === t).length,
|
||||
color: TEMP_COLOR[t],
|
||||
glyph: TEMP_GLYPH[t],
|
||||
}));
|
||||
lines.push("");
|
||||
lines.push(` ${stackedBar(counts, Math.max(1, taught.length), Math.max(10, Math.min(60, cols - 4)))}`);
|
||||
lines.push(
|
||||
` ${TEMPERATURES.map((t, i) => `${fg(TEMP_COLOR[t], TEMP_GLYPH[t])} ${counts[i]!.value} ${fg(MUTED, t)}`).join(" ")}` +
|
||||
fg(MUTED, ` of ${taught.length} taught so far`),
|
||||
);
|
||||
|
||||
const nameW = Math.max(12, Math.min(34, cols - CONCEPT_FIXED));
|
||||
lines.push("");
|
||||
lines.push(
|
||||
fg(
|
||||
MUTED,
|
||||
` ${cell("concept", nameW)} ${cell("temp", TEMP_W)} ` +
|
||||
`${cell("coverage", COVER_W + 1 + COUNT_W)} ${cell("last", AGE_W)} ${cell("due", DUE_W, "right")}`,
|
||||
),
|
||||
);
|
||||
|
||||
for (const t of s.topics.slice().sort(byUrgency)) {
|
||||
// An unreached concept has no temperature to report — it shows the week
|
||||
// the schedule gets to it instead, so the list stays one list.
|
||||
const color = t.taught ? TEMP_COLOR[t.temperature] : LINE;
|
||||
const age =
|
||||
!t.taught ? "—" : t.age === null ? "never" : t.age <= 0 ? "today" : `${t.age}d`;
|
||||
lines.push(
|
||||
` ${fg(color, t.taught ? TEMP_GLYPH[t.temperature] : "·")} ${fg(t.taught ? FG : MUTED, cell(t.name, nameW))} ` +
|
||||
`${fg(color, cell(t.taught ? t.temperature : `wk ${t.week ?? "?"}`, TEMP_W))} ` +
|
||||
`${bar(t.done, t.total, COVER_W, t.taught ? GREEN : LINE)} ` +
|
||||
`${fg(MUTED, cell(`${t.done}/${t.total}`, COUNT_W))} ${fg(MUTED, cell(age, AGE_W))} ` +
|
||||
`${t.due > 0 ? fg(GREEN, cell(String(t.due), DUE_W, "right")) : fg(LINE, cell("·", DUE_W, "right"))}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── activity ─────────────────────────────────────────────────────
|
||||
|
||||
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
function activity(s: Stats, cols: number): string[] {
|
||||
const lines: string[] = [rule("attempts per campaign day", cols)];
|
||||
lines.push(fg(MUTED, " the README heatmap, same ramp — Sunday is the rest day"));
|
||||
lines.push("");
|
||||
|
||||
// CAMPAIGN_START is a Monday, so the grid is simply the series in weeks of
|
||||
// seven: column = week, row = weekday, exactly as the SVG lays it out.
|
||||
const weeks = Math.ceil(s.heat.length / 7);
|
||||
lines.push(
|
||||
` ${fg(MUTED, Array.from({ length: weeks }, (_, w) => ` ${String(w + 1).padStart(2)}`).join(""))}`,
|
||||
);
|
||||
WEEKDAYS.forEach((name, row) => {
|
||||
const cells = Array.from({ length: weeks }, (_, w) => {
|
||||
const entry = s.heat[w * 7 + row];
|
||||
if (entry === undefined) return " ";
|
||||
// The marker sits in the gutter, not inside the cell: a foreground
|
||||
// escape within a background run would end the background early.
|
||||
const marker = s.campaign.day - 1 === w * 7 + row ? fg(FG, "▸") : " ";
|
||||
return marker + heatCell(entry.attempts, " ");
|
||||
}).join("");
|
||||
lines.push(` ${fg(MUTED, cell(name, 4))}${cells}`);
|
||||
});
|
||||
lines.push("");
|
||||
lines.push(
|
||||
` ${fg(MUTED, "less ")}${GREENS.map((_, level) => heatCell(level, " ")).join("")}${fg(MUTED, " more")}` +
|
||||
fg(MUTED, " · one cell per day, colour = attempts, ▸ marks today"),
|
||||
);
|
||||
|
||||
lines.push("");
|
||||
lines.push(rule("weekly gates", cols));
|
||||
if (s.gates.length === 0) {
|
||||
lines.push(fg(MUTED, " no gate has been scored yet"));
|
||||
}
|
||||
const gateW = Math.max(10, Math.min(24, cols - 30));
|
||||
for (const g of s.gates) {
|
||||
// Gates were stored as an integer percent before they were stored as a
|
||||
// fraction; both are in the table, so normalise on read.
|
||||
const rate = g.pass_rate === null ? null : g.pass_rate <= 1 ? g.pass_rate : g.pass_rate / 100;
|
||||
const color = rate === null ? LINE : rate >= 0.7 ? GREEN : RED;
|
||||
lines.push(
|
||||
` ${fg(FG, cell(`Week ${g.week}`, 9))}` +
|
||||
`${bar(rate ?? 0, 1, gateW, color)}` +
|
||||
` ${fg(color, cell(rate === null ? "open" : `${Math.round(rate * 100)}%`, 5))}` +
|
||||
` ${fg(MUTED, g.issue === null ? "" : `#${g.issue}`)}${fg(MUTED, g.closed_on ? ` closed ${g.closed_on}` : "")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── panes ────────────────────────────────────────────────────────
|
||||
|
||||
const PANES: { name: string; render: (s: Stats, cols: number) => string[] }[] = [
|
||||
{ name: "Overview", render: overview },
|
||||
{ name: "Ladder", render: ladder },
|
||||
{ name: "Concepts", render: concepts },
|
||||
{ name: "Activity", render: activity },
|
||||
];
|
||||
|
||||
// ── non-interactive: every pane, once ────────────────────────────
|
||||
|
||||
let stats: Stats;
|
||||
try {
|
||||
stats = await fetchStats();
|
||||
} catch (err) {
|
||||
console.error(`stats: ${err instanceof Error ? err.message : String(err)}`);
|
||||
console.error(`stats: D1 is the only source of SRS truth — nothing to fall back to (SRS_API=${SRS_API}).`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!process.stdout.isTTY) {
|
||||
const cols = 80;
|
||||
for (const pane of PANES) {
|
||||
console.log(bold(pane.name.toUpperCase()));
|
||||
for (const line of pane.render(stats, cols)) console.log(line);
|
||||
console.log("");
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── interactive ──────────────────────────────────────────────────
|
||||
|
||||
let pane = 0;
|
||||
let offset = 0;
|
||||
let note = "";
|
||||
|
||||
// Quitting resolves this; the finally below is what restores the terminal, so
|
||||
// a thrown render never leaves the shell in raw mode on the alternate screen.
|
||||
const { promise: quit, resolve: leave } = Promise.withResolvers<void>();
|
||||
const screen = openScreen(onKey, draw);
|
||||
|
||||
function frame(): string[] {
|
||||
const cols = screen.columns;
|
||||
const tabs = PANES.map((p, i) =>
|
||||
i === pane ? bold(fg(FG, ` ${p.name} `)) : fg(MUTED, ` ${p.name} `),
|
||||
).join(fg(LINE, "│"));
|
||||
const clock = fg(MUTED, `day ${stats.campaign.day}/${stats.campaign.days} · streak ${stats.streak} `);
|
||||
const header = padTo(tabs, Math.max(0, cols - width(clock))) + clock;
|
||||
|
||||
// header + rule + body + footer fills the terminal exactly.
|
||||
const body = PANES[pane]!.render(stats, cols);
|
||||
const height = Math.max(1, screen.rows - 3);
|
||||
offset = Math.max(0, Math.min(offset, Math.max(0, body.length - height)));
|
||||
const scrolled =
|
||||
body.length > height ? ` · ${offset + 1}-${Math.min(offset + height, body.length)}/${body.length}` : "";
|
||||
|
||||
return [
|
||||
header,
|
||||
fg(LINE, "─".repeat(cols)),
|
||||
...body.slice(offset, offset + height),
|
||||
fg(MUTED, `Tab/←→ pane · ↑↓ scroll · r refresh · q quit${scrolled}`) + (note ? ` ${fg(YELLOW, note)}` : ""),
|
||||
];
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
screen.draw(frame());
|
||||
}
|
||||
|
||||
function onKey(chunk: string): void {
|
||||
switch (chunk) {
|
||||
// Raw mode delivers Ctrl-C as a byte, not a signal, so it is handled here.
|
||||
case "q":
|
||||
case "\x03":
|
||||
case "\x1b":
|
||||
leave();
|
||||
return;
|
||||
case "\t":
|
||||
case "\x1b[C":
|
||||
case "l":
|
||||
pane = (pane + 1) % PANES.length;
|
||||
offset = 0;
|
||||
break;
|
||||
case "\x1b[Z":
|
||||
case "\x1b[D":
|
||||
case "h":
|
||||
pane = (pane + PANES.length - 1) % PANES.length;
|
||||
offset = 0;
|
||||
break;
|
||||
case "\x1b[A":
|
||||
case "k":
|
||||
offset = Math.max(0, offset - 1);
|
||||
break;
|
||||
case "\x1b[B":
|
||||
case "j":
|
||||
offset += 1;
|
||||
break;
|
||||
case "r":
|
||||
note = "refreshing…";
|
||||
draw();
|
||||
void fetchStats()
|
||||
.then((fresh) => {
|
||||
stats = fresh;
|
||||
note = "";
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
note = `refresh failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
})
|
||||
.finally(draw);
|
||||
return;
|
||||
default:
|
||||
if (chunk >= "1" && chunk <= String(PANES.length)) {
|
||||
pane = Number(chunk) - 1;
|
||||
offset = 0;
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
draw();
|
||||
}
|
||||
|
||||
try {
|
||||
draw();
|
||||
await quit;
|
||||
} finally {
|
||||
screen.close();
|
||||
}
|
||||
+458
-21
@@ -2,24 +2,69 @@
|
||||
/**
|
||||
* Sync work/1 leetcode solutions into apps/docs/content/ pages.
|
||||
*
|
||||
* - New problems get a full page (frontmatter, badge, warning, examples,
|
||||
* - 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 docs MDX `wrapper`
|
||||
* (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the
|
||||
* `video:` key `videos.ts` writes into the badge row under the description.
|
||||
* - Every tag a page carries that no campaign topic teaches (not a key of
|
||||
* `apps/docs/topic-landings.ts`) gets a placeholder guide page,
|
||||
* `guides/<slug>.mdx`, with `list:` pointing at LeetCode's problem list for
|
||||
* the tag. Created once (a missing or empty file), then hand-owned except
|
||||
* for `sidebar.badge`, which sync keeps at the guide's backlink count (the
|
||||
* problem pages carrying the tag; dropped at zero); the tag's badge on a
|
||||
* problem page links to it. A guide may be filed into a category `(group)`
|
||||
* folder (e.g. `guides/(2-algorithms)/`) — no route segment, so
|
||||
* `/guides/<slug>` holds — and is found there by slug; a new placeholder
|
||||
* lands at the `guides/` root for a human to file (and give an `icon:`,
|
||||
* which every badge linking to the guide shows; sync warns while one is
|
||||
* missing or shared).
|
||||
*
|
||||
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`,
|
||||
* ordered by topic number via a script-owned `meta.ts`). 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. Sync never writes or moves an `index.mdx`, and never removes a folder
|
||||
* that still holds one. A phase folder's `meta.ts` is written once, when
|
||||
* missing, then hand-owned (its title is yours to rename); a topic's is
|
||||
* script-owned. An unplanned category group has no phase and sits at the
|
||||
* content root.
|
||||
*
|
||||
* Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves
|
||||
* of problems whose page already exists (see work.ts), and a fresh scaffold
|
||||
* there would otherwise wipe the page's `## Solution`.
|
||||
*
|
||||
* Every page also gets a short link: `/<num>` → `/<topic>/<slug>` (so
|
||||
* `/leetcode/217` opens Contains Duplicate). The rules live in the generated
|
||||
* `apps/docs/redirects.ts`, which `blume.config.ts` imports — Blume takes exact
|
||||
* root-relative paths and prepends the deployment base itself.
|
||||
*/
|
||||
import { mkdir, rename } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
|
||||
import { TOPIC_LANDINGS } from "../docs/topic-landings.ts";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..", "..");
|
||||
const WORK = join(ROOT, "work", "1");
|
||||
const DOCS = join(ROOT, "apps", "docs", "content");
|
||||
const REDIRECTS = join(ROOT, "apps", "docs", "redirects.ts");
|
||||
|
||||
type Lang = "js" | "py";
|
||||
|
||||
@@ -158,24 +203,246 @@ 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)})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blume `(group)` meta for a phase: the overview page's heading, in phase
|
||||
* order. Written only when the folder has none — the title is hand-owned after
|
||||
* that. Nothing is lost by never rewriting it: the order and name it is built
|
||||
* from are also the folder name (`phaseDir`), so a README change to either
|
||||
* lands in a new folder that gets a fresh meta.
|
||||
*/
|
||||
function phaseMeta(phase: Phase): string {
|
||||
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: ${JSON.stringify(`Phase ${phase.order}: ${phase.name}`)}, order: ${phase.order} });\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blume folder meta for a topic: exact label (the folder slug loses `/` and
|
||||
* case), curriculum order, the sidebar icon, and `display: "group"` so the
|
||||
* topic collapses under its phase heading. 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): string {
|
||||
const icon = TOPIC_ICONS[topic.name];
|
||||
if (!icon) console.warn(`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`);
|
||||
const fields = [`title: ${JSON.stringify(topic.name)}`, `order: ${topic.order ?? 0}`];
|
||||
if (icon) fields.push(`icon: ${JSON.stringify(icon)}`);
|
||||
fields.push(`display: "group"`);
|
||||
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ ${fields.join(", ")} });\n`;
|
||||
}
|
||||
|
||||
// ── LeetCode topic tags ──────────────────────────────────────────
|
||||
|
||||
/** 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");
|
||||
}
|
||||
|
||||
/** The frontmatter's `topics:` as sync wrote them (or a human edited them); [] when absent. */
|
||||
function frontmatterTopics(page: string): TopicTag[] {
|
||||
const fm = page.match(/^---\n([\s\S]*?)\n---/)?.[1];
|
||||
const data: unknown = fm ? Bun.YAML.parse(fm) : undefined;
|
||||
if (!data || typeof data !== "object" || !("topics" in data) || !Array.isArray(data.topics)) return [];
|
||||
return data.topics.filter((t): t is TopicTag => typeof t?.name === "string" && typeof t?.slug === "string");
|
||||
}
|
||||
|
||||
// ── guides ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Placeholder for a tag no campaign topic teaches: the `/guides/<slug>` page a
|
||||
* problem's topic badge links to. `list:` is the tag's LeetCode problem list,
|
||||
* rendered as a badge by the docs MDX `wrapper`. Written once — the page
|
||||
* is hand-owned from then on.
|
||||
*/
|
||||
function guidePage(tag: TopicTag): string {
|
||||
return [
|
||||
"---",
|
||||
`title: ${JSON.stringify(tag.name)}`,
|
||||
`list: ${JSON.stringify(`https://leetcode.com/problem-list/${tag.slug}/`)}`,
|
||||
"---",
|
||||
"",
|
||||
":::note",
|
||||
`This guide has not been written yet. The LeetCode list above has every problem tagged ${tag.name}.`,
|
||||
":::",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Folder meta for `guides/`: its own header tab (`navigation.tabs` in blume.config.ts), so only the label. */
|
||||
const GUIDES_META = `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: "Guides" });\n`;
|
||||
|
||||
/**
|
||||
* A guide's frontmatter with `sidebar.badge` set to its backlink count — the
|
||||
* one key sync owns on a hand-owned guide — or dropped (with a `sidebar:` left
|
||||
* empty) at zero, so the sidebar shows a count only where there is something
|
||||
* to count.
|
||||
*/
|
||||
function withBacklinkBadge(page: string, count: number): string {
|
||||
const fm = page.match(/^---\n[\s\S]*?\n---/);
|
||||
if (!fm) return page;
|
||||
let head = fm[0].replace(/^ {2}badge: .*\n/m, "");
|
||||
if (count > 0) {
|
||||
const line = ` badge: '${count}'`;
|
||||
head = /^sidebar:$/m.test(head)
|
||||
? head.replace(/^sidebar:$/m, `sidebar:\n${line}`)
|
||||
: head.replace(/\n---$/, `\nsidebar:\n${line}\n---`);
|
||||
} else {
|
||||
head = head.replace(/^sidebar:\n(?! {2})/m, "");
|
||||
}
|
||||
return head + page.slice(fm[0].length);
|
||||
}
|
||||
|
||||
// ── inline-code formatting heuristics ────────────────────────────
|
||||
|
||||
/** Identifiers worth backticking in prose, harvested from the code. */
|
||||
@@ -277,7 +544,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): string {
|
||||
const h = sol.header;
|
||||
const ids = identifiers(sol);
|
||||
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
|
||||
@@ -287,13 +557,13 @@ 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)] : []),
|
||||
"sidebar:",
|
||||
` label: '${h.title.replaceAll("'", "''")}'`,
|
||||
` badge: '${h.difficulty}'`,
|
||||
"---",
|
||||
"",
|
||||
`<Badge variant="accent">${topic}</Badge>`,
|
||||
"",
|
||||
);
|
||||
|
||||
const warning = warningText(h);
|
||||
@@ -350,6 +620,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,6 +634,44 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 survive regeneration.
|
||||
*/
|
||||
function solutionEnd(page: string, from: number): number {
|
||||
const lines = page.slice(from).split("\n");
|
||||
let offset = from + lines[0]!.length + 1; // past the '## Solution' line itself
|
||||
let fenced = false;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
if (line.startsWith("```")) fenced = !fenced;
|
||||
else if (!fenced && line.startsWith("## ")) return offset;
|
||||
offset += line.length + 1;
|
||||
}
|
||||
return page.length;
|
||||
}
|
||||
|
||||
// Existing docs pages, keyed by leetcode number from the frontmatter title.
|
||||
const pages = new Map<number, string>(); // num -> path relative to DOCS
|
||||
for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).sort()) {
|
||||
@@ -369,16 +682,28 @@ 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 groups = new Map<string, Topic>(); // topic folder -> topic, for meta.ts
|
||||
const phases = new Map<string, Phase>(); // phase folder -> phase, for meta.ts
|
||||
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
|
||||
const redirects: string[] = []; // `{ from: "/<num>", to: "/<topic>/<slug>" }` rows, numeric order
|
||||
const tags = new Map<string, TopicTag>(); // every tag slug any problem page carries -> tag
|
||||
const tagged = new Map<string, number>(); // tag slug -> how many problem pages carry it
|
||||
|
||||
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>/ folder (the topic is a real route segment, so its
|
||||
// index.mdx is the landing page) + 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);
|
||||
groups.set(dir, topic);
|
||||
if (topic.phase) phases.set(phaseDir(topic.phase), topic.phase);
|
||||
const name = `${dir}/${sol.num}-${sol.slug}.mdx`;
|
||||
const existing = pages.get(sol.num);
|
||||
await mkdir(join(DOCS, group), { recursive: true });
|
||||
await mkdir(join(DOCS, dir), { recursive: true });
|
||||
|
||||
let written: string; // the page as it stands after this run, for its topics
|
||||
if (existing) {
|
||||
const page = await Bun.file(join(DOCS, existing)).text();
|
||||
const at = page.indexOf("## Solution");
|
||||
@@ -386,10 +711,13 @@ for (const sol of ordered) {
|
||||
report.push([workRef, existing, "ERROR: no '## Solution' heading"]);
|
||||
continue;
|
||||
}
|
||||
const next = ensureLabel(page.slice(0, at) + solutionSection(sol), sol.header);
|
||||
const tail = page.slice(solutionEnd(page, at));
|
||||
const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
|
||||
const next = await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), 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) {
|
||||
@@ -397,10 +725,119 @@ for (const sol of ordered) {
|
||||
actions.push("updated");
|
||||
}
|
||||
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
|
||||
written = next;
|
||||
} else {
|
||||
const topic = topics.get(sol.num) ?? sol.category;
|
||||
await Bun.write(join(DOCS, name), renderPage(sol, topic));
|
||||
const page = renderPage(sol, await fetchTopicTags(sol));
|
||||
await Bun.write(join(DOCS, name), page);
|
||||
report.push([workRef, name, "created"]);
|
||||
written = page;
|
||||
}
|
||||
for (const tag of frontmatterTopics(written)) {
|
||||
tags.set(tag.slug, tag);
|
||||
tagged.set(tag.slug, (tagged.get(tag.slug) ?? 0) + 1);
|
||||
}
|
||||
// The route drops the phase `(group)` and the page's number prefix.
|
||||
redirects.push(` { from: "/${sol.num}", to: ${JSON.stringify(`/${slugify(topic.name)}/${sol.slug}`)} },`);
|
||||
}
|
||||
|
||||
// Script-owned short links, one per page; imported by blume.config.ts.
|
||||
{
|
||||
const module = [
|
||||
"// Generated by `bun run sync` (apps/cli/sync.ts) — do not edit.",
|
||||
"// `/leetcode/<num>` → that problem's page; exact paths, base added by Blume.",
|
||||
"export default [",
|
||||
...redirects,
|
||||
"];",
|
||||
"",
|
||||
].join("\n");
|
||||
if (!(await Bun.file(REDIRECTS).exists()) || (await Bun.file(REDIRECTS).text()) !== module) {
|
||||
await Bun.write(REDIRECTS, module);
|
||||
report.push([`${redirects.length} redirects`, "apps/docs/redirects.ts", "written"]);
|
||||
}
|
||||
}
|
||||
|
||||
// One guide page per tag that no campaign topic teaches, created when missing
|
||||
// (an empty file counts as missing) and never rewritten. A guide is found by
|
||||
// slug anywhere under guides/ — a human files it into a category `(group)`,
|
||||
// which leaves its route alone — and a new one lands at the guides/ root. A
|
||||
// guide whose tag no page carries any more is left alone: it is hand-owned.
|
||||
const guideTags = [...tags.values()]
|
||||
.filter((tag) => !TOPIC_LANDINGS[tag.slug])
|
||||
.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||
const guideFiles = new Map<string, string>(); // slug -> path relative to DOCS
|
||||
for await (const rel of new Bun.Glob("guides/**/*.mdx").scan(DOCS)) guideFiles.set(basename(rel, ".mdx"), rel);
|
||||
for (const tag of guideTags) {
|
||||
const rel = guideFiles.get(tag.slug) ?? `guides/${tag.slug}.mdx`;
|
||||
const file = Bun.file(join(DOCS, rel));
|
||||
if ((await file.exists()) && file.size > 0) continue;
|
||||
await Bun.write(file, guidePage(tag));
|
||||
report.push([`tag ${tag.slug}`, rel, "created"]);
|
||||
}
|
||||
|
||||
// Every guide's sidebar badge is its backlink count: the problem pages whose
|
||||
// tag badge links to it, which is every page carrying the tag, since a guide's
|
||||
// tag has no landing (`tagTarget()`). A guide whose tag a landing has since
|
||||
// claimed has no backlinks left. Counts move with every solved problem, so the
|
||||
// badge is rewritten whenever it differs; the rest of the guide is untouched.
|
||||
// The same pass checks each guide's hand-picked `icon:`, which is also the icon
|
||||
// on every problem-page badge linking to the guide: it must exist, must not be
|
||||
// a topic group's (`TOPIC_ICONS`), so a guide badge never reads as a topic's,
|
||||
// and must not be another guide's.
|
||||
const topicIcons = new Set(Object.values(TOPIC_ICONS));
|
||||
const guideIcons = new Map<string, string>(); // icon -> the guide that took it first
|
||||
for await (const rel of new Bun.Glob("guides/**/*.mdx").scan(DOCS)) {
|
||||
const slug = basename(rel, ".mdx");
|
||||
const count = TOPIC_LANDINGS[slug] ? 0 : (tagged.get(slug) ?? 0);
|
||||
const page = await Bun.file(join(DOCS, rel)).text();
|
||||
const icon = page.match(/^---\n[\s\S]*?\n---/)?.[0].match(/^icon: (\S+)$/m)?.[1];
|
||||
if (!icon) console.warn(`${rel}: no icon; its badges render without one — file it and add \`icon:\``);
|
||||
else if (topicIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is a topic's (TOPIC_ICONS); pick another`);
|
||||
else if (guideIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is ${guideIcons.get(icon)}'s too; pick another`);
|
||||
else guideIcons.set(icon, rel);
|
||||
const next = withBacklinkBadge(page, count);
|
||||
if (next === page) continue;
|
||||
await Bun.write(join(DOCS, rel), next);
|
||||
report.push([`${count} backlinks`, rel, "badged"]);
|
||||
}
|
||||
|
||||
// Phase metas: created when missing (an empty file counts as missing), never
|
||||
// rewritten, so a renamed phase title survives every sync.
|
||||
for (const [dir, phase] of phases) {
|
||||
const file = Bun.file(join(DOCS, dir, "meta.ts"));
|
||||
if ((await file.exists()) && file.size > 0) continue;
|
||||
await Bun.write(file, phaseMeta(phase));
|
||||
report.push([`phase ${phase.order}`, `${dir}/meta.ts`, "created"]);
|
||||
}
|
||||
|
||||
// Script-owned folder metas — one per live topic folder, plus guides/ —
|
||||
// rewritten only when they differ.
|
||||
const metas: [label: string, dir: string, meta: string][] = [
|
||||
...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]),
|
||||
...(guideTags.length ? [["guides", "guides", GUIDES_META] as [string, string, string]] : []),
|
||||
];
|
||||
for (const [label, dir, meta] of metas) {
|
||||
const path = join(DOCS, dir, "meta.ts");
|
||||
if ((await Bun.file(path).exists()) && (await Bun.file(path).text()) === meta) continue;
|
||||
await Bun.write(path, meta);
|
||||
report.push([label, `${dir}/meta.ts`, "written"]);
|
||||
}
|
||||
|
||||
// A folder every page left behind is dead: drop its meta and the folder itself,
|
||||
// then the phase folder above it if that emptied too. A folder that still holds
|
||||
// anything else (a hand-written index.mdx, another topic) stops the climb.
|
||||
for (const start of vacated) {
|
||||
for (let dir = start; dir !== "."; dir = dirname(dir)) {
|
||||
if (groups.has(dir) || phases.has(dir)) break;
|
||||
let left: string[];
|
||||
try {
|
||||
left = await readdir(join(DOCS, dir));
|
||||
} catch {
|
||||
break; // already removed via a sibling's climb
|
||||
}
|
||||
if (left.some((f) => f !== "meta.ts")) break;
|
||||
if (left.length) await unlink(join(DOCS, dir, "meta.ts"));
|
||||
await rmdir(join(DOCS, dir));
|
||||
report.push(["", dir, "removed (empty)"]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Library: side-effect-free on import; every call takes the caller's github().
|
||||
*/
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
/** One problem sub-issue, narrowed from the sub_issues payload. */
|
||||
export interface Problem {
|
||||
number: number;
|
||||
/** `LC <num> · <Name>`. */
|
||||
title: string;
|
||||
/** ISO instant the issue closed; null while it is open. */
|
||||
closedAt: string | null;
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
export interface Core {
|
||||
/** 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[];
|
||||
/** When the last core sub-issue closed; null until the topic is finished. */
|
||||
finishedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every sub-issue of a topic. The sub_issues payload carries the full issue
|
||||
* objects (state + labels), so no per-problem follow-up request is needed.
|
||||
*/
|
||||
export async function readProblems(gh: GitHub, topic: number): Promise<Problem[]> {
|
||||
const problems: Problem[] = [];
|
||||
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);
|
||||
}
|
||||
|
||||
export function coreSet(problems: Problem[]): Core {
|
||||
const core = problems.filter((p) => labelValue(p, "set:") === "core");
|
||||
const pending = core.filter((p) => !p.closedAt).map((p) => p.number);
|
||||
// ISO instants from the API share one format, so string order is time order.
|
||||
const finishedAt =
|
||||
core.length && !pending.length
|
||||
? core.map((p) => p.closedAt!).reduce((a, b) => (a > b ? a : b))
|
||||
: null;
|
||||
return { all: core.map((p) => p.number), pending, finishedAt };
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Terminal drawing primitives for the stats dashboard: colour, block-glyph
|
||||
* charts, and the alternate-screen plumbing.
|
||||
*
|
||||
* Hand-rolled for the same reason apps/api/src/charts.ts and png.ts are — the
|
||||
* repo carries near-zero runtime dependencies, and everything a progress
|
||||
* dashboard needs is a bar, a sparkline and a coloured cell. The palette is
|
||||
* charts.ts's, so the terminal, the README SVGs and the digest email are the
|
||||
* same picture in three renderers.
|
||||
*
|
||||
* Side-effect-free on import except for reading the colour environment once;
|
||||
* nothing is written to the terminal until openScreen() is called.
|
||||
*/
|
||||
|
||||
// ── palette (apps/api/src/charts.ts) ─────────────────────────────
|
||||
|
||||
export const FG = "#fafafa"; // zinc-50 text
|
||||
export const MUTED = "#a1a1aa"; // zinc-400 secondary text
|
||||
export const LINE = "#27272a"; // zinc-800 structure / empty track
|
||||
export const GREEN = "#16a34a"; // green-600 (core / pass)
|
||||
export const BLUE = "#2563eb"; // blue-600 (optional)
|
||||
export const YELLOW = "#eab308"; // yellow-500 (attention)
|
||||
export const RED = "#f87171"; // red-400 (fail, on dark)
|
||||
|
||||
/** GitHub dark-mode contribution hues; level 0 is the empty cell. */
|
||||
export const GREENS = ["#27272a", "#0e4429", "#006d32", "#26a641", "#39d353"];
|
||||
|
||||
// ── colour ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Honour NO_COLOR and pipes, and let FORCE_COLOR override both — the dashboard
|
||||
* is legible without colour, and `bun run stats | less -R` should still be.
|
||||
*/
|
||||
export const COLOR =
|
||||
process.env.FORCE_COLOR !== undefined
|
||||
? process.env.FORCE_COLOR !== "0"
|
||||
: process.env.NO_COLOR === undefined && process.stdout.isTTY === true;
|
||||
|
||||
const RESET = "\x1b[0m";
|
||||
|
||||
function channels(hex: string): string {
|
||||
return `${parseInt(hex.slice(1, 3), 16)};${parseInt(hex.slice(3, 5), 16)};${parseInt(hex.slice(5, 7), 16)}`;
|
||||
}
|
||||
|
||||
export function fg(hex: string, text: string): string {
|
||||
return COLOR ? `\x1b[38;2;${channels(hex)}m${text}${RESET}` : text;
|
||||
}
|
||||
|
||||
export function bg(hex: string, text: string): string {
|
||||
return COLOR ? `\x1b[48;2;${channels(hex)}m${text}${RESET}` : text;
|
||||
}
|
||||
|
||||
export function bold(text: string): string {
|
||||
return COLOR ? `\x1b[1m${text}${RESET}` : text;
|
||||
}
|
||||
|
||||
const ANSI = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
/** Printable width — every glyph used here is one cell wide. */
|
||||
export function width(text: string): number {
|
||||
return text.replace(ANSI, "").length;
|
||||
}
|
||||
|
||||
/** Pad or cut a *plain* string to exactly n cells; colour it afterwards. */
|
||||
export function cell(text: string, n: number, align: "left" | "right" = "left"): string {
|
||||
const clipped = text.length > n ? `${text.slice(0, Math.max(0, n - 1))}…` : text;
|
||||
return align === "left" ? clipped.padEnd(n) : clipped.padStart(n);
|
||||
}
|
||||
|
||||
/** Pad an already-coloured string to n cells. */
|
||||
export function padTo(text: string, n: number): string {
|
||||
return text + " ".repeat(Math.max(0, n - width(text)));
|
||||
}
|
||||
|
||||
// ── charts ───────────────────────────────────────────────────────
|
||||
|
||||
const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
|
||||
const SPARKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
||||
|
||||
/**
|
||||
* Filled and empty. The two differ by GLYPH as well as by colour, so a bar
|
||||
* still reads under NO_COLOR or in a pipe — every chart here has to survive
|
||||
* `bun run stats > notes.txt`.
|
||||
*/
|
||||
const FILL = "█";
|
||||
const TRACK = "░";
|
||||
|
||||
/**
|
||||
* One bar over a muted track, `cells` wide. Sub-cell remainders use the
|
||||
* eighth-block glyphs, so one solved problem out of 161 still shows.
|
||||
*/
|
||||
export function bar(value: number, total: number, cells: number, color = GREEN): string {
|
||||
const units = Math.round(Math.max(0, Math.min(1, total > 0 ? value / total : 0)) * cells * 8);
|
||||
const filled = FILL.repeat(Math.floor(units / 8)) + EIGHTHS[units % 8]!;
|
||||
return fg(color, filled) + fg(LINE, TRACK.repeat(Math.max(0, cells - filled.length)));
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
value: number;
|
||||
color: string;
|
||||
/** Defaults to the solid block; pass TRACK-ish glyphs for "not done yet". */
|
||||
glyph?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segments laid end to end, `total` mapped to `cells`. Anything short of
|
||||
* `total` is left BLANK, not tracked: bars sharing a scale must show their
|
||||
* different lengths. A caller wanting a visible remainder passes it as a
|
||||
* segment of its own.
|
||||
*
|
||||
* Non-zero segments are floored but never vanish; the overshoot that floor
|
||||
* creates is taken back from the widest segment, which can spare it.
|
||||
*/
|
||||
export function stackedBar(segments: Segment[], total: number, cells: number): string {
|
||||
if (total <= 0) return " ".repeat(cells);
|
||||
const widths = segments.map((s) =>
|
||||
s.value > 0 ? Math.max(1, Math.floor((s.value / total) * cells)) : 0,
|
||||
);
|
||||
let over = widths.reduce((a, b) => a + b, 0) - cells;
|
||||
while (over > 0) {
|
||||
const i = widths.indexOf(Math.max(...widths));
|
||||
if (widths[i]! <= 1) break;
|
||||
widths[i]!--;
|
||||
over--;
|
||||
}
|
||||
const drawn = widths.map((n, i) => fg(segments[i]!.color, (segments[i]!.glyph ?? FILL).repeat(n)));
|
||||
return drawn.join("") + " ".repeat(Math.max(0, cells - widths.reduce((a, b) => a + b, 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* One cell of the campaign heatmap. With colour it is the GREENS ramp the
|
||||
* README and the digest use; without it, the same five levels as shading.
|
||||
*/
|
||||
export function heatCell(attempts: number, body: string): string {
|
||||
const level = Math.min(attempts, GREENS.length - 1);
|
||||
return COLOR ? bg(GREENS[level]!, body) : [" ", "·", "▒", "▓", "█"][level]!.repeat(body.length);
|
||||
}
|
||||
|
||||
/** One glyph per value, scaled to the largest. All-zero renders as a floor. */
|
||||
export function sparkline(values: number[], color = GREEN): string {
|
||||
const peak = Math.max(...values, 0);
|
||||
const line = values
|
||||
.map((v) => (peak === 0 ? SPARKS[0]! : SPARKS[Math.min(7, Math.round((v / peak) * 7))]!))
|
||||
.join("");
|
||||
return fg(color, line);
|
||||
}
|
||||
|
||||
// ── layout ───────────────────────────────────────────────────────
|
||||
|
||||
/** A section rule that fills the width: `── name ────────────`. */
|
||||
export function rule(title: string, cells: number): string {
|
||||
const label = ` ${title} `;
|
||||
return (
|
||||
fg(LINE, "──") + fg(MUTED, label) + fg(LINE, "─".repeat(Math.max(0, cells - 2 - label.length)))
|
||||
);
|
||||
}
|
||||
|
||||
// ── screen ───────────────────────────────────────────────────────
|
||||
|
||||
const ALT_ON = "\x1b[?1049h\x1b[?25l";
|
||||
const ALT_OFF = "\x1b[?25h\x1b[?1049l";
|
||||
|
||||
export interface Screen {
|
||||
readonly rows: number;
|
||||
readonly columns: number;
|
||||
/** Draw a frame, clipped to the terminal height. */
|
||||
draw(lines: string[]): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take over the terminal: alternate buffer, hidden cursor, raw keys. `onKey`
|
||||
* receives the raw chunk (`"\t"`, `"\x1b[B"`, `"q"`, …) — decoding belongs to
|
||||
* the app, which is the only thing that knows what a key means.
|
||||
*
|
||||
* The caller MUST close() in a finally: raw mode survives a thrown exception.
|
||||
*/
|
||||
export function openScreen(onKey: (chunk: string) => void, onResize: () => void): Screen {
|
||||
const out = process.stdout;
|
||||
const input = process.stdin;
|
||||
|
||||
out.write(ALT_ON);
|
||||
input.setRawMode?.(true);
|
||||
input.setEncoding("utf8");
|
||||
input.resume();
|
||||
input.on("data", onKey);
|
||||
out.on("resize", onResize);
|
||||
|
||||
let closed = false;
|
||||
return {
|
||||
get rows() {
|
||||
return out.rows ?? 24;
|
||||
},
|
||||
get columns() {
|
||||
return out.columns ?? 80;
|
||||
},
|
||||
draw(lines: string[]): void {
|
||||
const visible = lines.slice(0, this.rows);
|
||||
// Home, then erase each line as it is rewritten plus everything below the
|
||||
// last one. No full clear, so a redraw does not flash, and no trailing
|
||||
// newline, which would scroll the alternate screen by a row per frame.
|
||||
out.write(`\x1b[H${visible.map((l) => `${l}\x1b[K`).join("\r\n")}\x1b[J`);
|
||||
},
|
||||
close(): void {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
input.off("data", onKey);
|
||||
out.off("resize", onResize);
|
||||
input.setRawMode?.(false);
|
||||
input.pause();
|
||||
out.write(ALT_OFF);
|
||||
},
|
||||
};
|
||||
}
|
||||
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 docs MDX `wrapper`
|
||||
* (`apps/docs/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, "apps", "docs", "content");
|
||||
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` : ""}`);
|
||||
+21
-4
@@ -93,6 +93,25 @@ export function scanBucket(bucket: Bucket): Map<number, WorkFile[]> {
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this file a real solution rather than an empty scaffold? The same call
|
||||
* close-solved.ts makes before it closes an issue — a stub under work/1 is a
|
||||
* problem still unsolved, and owes no review.
|
||||
*/
|
||||
export function isSolution(file: WorkFile): boolean {
|
||||
const source = readFileSync(join(WORK, file.rel), "utf8");
|
||||
return isImplemented(source, file.rel.endsWith(".py") ? "py" : "js");
|
||||
}
|
||||
|
||||
/** Problems in a bucket with a solution on disk; scaffolds do not count. */
|
||||
export function solvedInBucket(bucket: Bucket): number {
|
||||
let solved = 0;
|
||||
for (const files of scanBucket(bucket).values()) {
|
||||
if (files.some(isSolution)) solved++;
|
||||
}
|
||||
return solved;
|
||||
}
|
||||
|
||||
/**
|
||||
* LC number -> ET date of the commit that first added a work/ file for it.
|
||||
*
|
||||
@@ -153,14 +172,12 @@ export function reviewQueues(today: string): Map<Window, Owed[]> {
|
||||
const dates = firstSolved();
|
||||
|
||||
// An empty scaffold under work/1 is a problem still unsolved, not one owing
|
||||
// a review — the same call close-solved.ts makes before it closes an issue.
|
||||
// a review.
|
||||
const solved: { file: WorkFile; date: string }[] = [];
|
||||
for (const files of scanBucket(1).values()) {
|
||||
const date = dates.get(files[0]!.lc);
|
||||
if (date === undefined) continue; // uncommitted: you only just solved it
|
||||
const file = files.find((f) =>
|
||||
isImplemented(readFileSync(join(WORK, f.rel), "utf8"), f.rel.endsWith(".py") ? "py" : "js"),
|
||||
);
|
||||
const file = files.find(isSolution);
|
||||
if (file) solved.push({ file, date });
|
||||
}
|
||||
|
||||
|
||||
+173
-9
@@ -1,21 +1,185 @@
|
||||
import { defineConfig } from "blume";
|
||||
import type { StandardSchema } from "blume/core/standard-schema.ts";
|
||||
import { custom, filesystem } from "blume/sources";
|
||||
import { githubReleasesSource } from "blume/sources/github-releases.ts";
|
||||
import redirects from "./redirects";
|
||||
|
||||
/**
|
||||
* GitHub releases → /changelog, dated by the topic's real finish instant.
|
||||
*
|
||||
* Blume dates a release entry by `published_at`, which the GitHub API cannot
|
||||
* backdate — so the topic releases backfilled on one day (apps/cli/
|
||||
* release-topics.ts) would all read that day. Their tags are timever
|
||||
* (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the moment the topic finished, so this
|
||||
* fetch rewrites each timever release's `published_at` to that instant before
|
||||
* Blume maps it; anything else passes through untouched. The config-level
|
||||
* `githubReleases()` accepts no `fetchImpl`, hence the `custom()` wrap of the
|
||||
* underlying source (the `blume/sources/*.ts` path the Blume docs use for
|
||||
* custom sources), with the context the built-in adapter would receive.
|
||||
*/
|
||||
const TIMEVER = /^(\d{4})(\d{2})(\d{2})\.(\d{2})(\d{2})(\d{2})\.\d{9}$/;
|
||||
|
||||
const releasesDatedByTag: typeof fetch = async (input, init) => {
|
||||
const res = await fetch(input, init);
|
||||
if (!res.ok) return res;
|
||||
const releases: unknown = await res.json();
|
||||
if (Array.isArray(releases)) {
|
||||
for (const release of releases) {
|
||||
const m =
|
||||
typeof release?.tag_name === "string"
|
||||
? release.tag_name.match(TIMEVER)
|
||||
: null;
|
||||
if (m)
|
||||
release.published_at = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
|
||||
}
|
||||
}
|
||||
// Blume reads only `ok` and the JSON body, so the original headers (whose
|
||||
// content-length/encoding describe the upstream bytes) are not carried over.
|
||||
return Response.json(releases, { status: res.status });
|
||||
};
|
||||
|
||||
// Blume's defaults (`inter`, `ibm-plex-mono`) are fetched from fonts.gstatic.com
|
||||
// on every build, which has timed out on the Pages runner and, fully offline,
|
||||
// silently ships no fonts at all. The same two families from Fontsource, as
|
||||
// local files, keep the build network-free. Blume resolves `src` from this
|
||||
// directory; the workspace links these packages under ./node_modules.
|
||||
const inter = {
|
||||
name: "Inter",
|
||||
variants: [
|
||||
{
|
||||
src: "./node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2",
|
||||
weight: "100..900",
|
||||
},
|
||||
],
|
||||
};
|
||||
const plexMono = {
|
||||
name: "IBM Plex Mono",
|
||||
variants: [
|
||||
{
|
||||
src: "./node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-400-normal.woff2",
|
||||
weight: 400,
|
||||
},
|
||||
{
|
||||
src: "./node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
|
||||
weight: 500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom frontmatter, rendered as the badge row by
|
||||
* components/ProblemBadges.astro. Problem pages: `topics:` is LeetCode's topic
|
||||
* tags (written by apps/cli/sync.ts), `video:` the NeetCode walkthrough's
|
||||
* YouTube id (apps/cli/videos.ts). Guide pages (content/guides/, created by
|
||||
* sync): `list:` is the tag's LeetCode problem list. Blume rejects undeclared
|
||||
* keys and takes any Standard Schema; these are hand-rolled so the docs
|
||||
* workspace needs no zod. All optional — every other page carries none.
|
||||
*/
|
||||
const optional = <T>(
|
||||
message: string,
|
||||
valid: (value: unknown) => value is T,
|
||||
): StandardSchema<unknown, T | undefined> => ({
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "prdlk",
|
||||
validate: (value) =>
|
||||
value === undefined || valid(value)
|
||||
? { value }
|
||||
: { issues: [{ message }] },
|
||||
},
|
||||
});
|
||||
|
||||
const topicsSchema = optional(
|
||||
"expected a list of { name, slug } LeetCode topic tags",
|
||||
(value): value is { name: string; slug: string }[] =>
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(t) => typeof t?.name === "string" && typeof t?.slug === "string",
|
||||
),
|
||||
);
|
||||
const videoSchema = optional(
|
||||
"expected an 11-character YouTube video id",
|
||||
(value): value is string =>
|
||||
typeof value === "string" && /^[\w-]{11}$/.test(value),
|
||||
);
|
||||
const listSchema = optional(
|
||||
"expected a https://leetcode.com/problem-list/<slug>/ URL",
|
||||
(value): value is string =>
|
||||
typeof value === "string" &&
|
||||
/^https:\/\/leetcode\.com\/problem-list\/[\w-]+\/$/.test(value),
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
title: "Leetcode",
|
||||
description: "Documentation for LeetCode solutions",
|
||||
title: "prdlk",
|
||||
description: "Documentation for my LeetCode solutions",
|
||||
logo: "./public/logo.svg",
|
||||
deployment: {
|
||||
site: "https://prdlk.github.io",
|
||||
base: "/leetcode",
|
||||
site: "https://lc.prd.lk",
|
||||
},
|
||||
github: {
|
||||
owner: "prdlk",
|
||||
repo: "leetcode",
|
||||
},
|
||||
lastModified: "git",
|
||||
redirects,
|
||||
frontmatter: {
|
||||
extend: { topics: topicsSchema, video: videoSchema, list: listSchema },
|
||||
},
|
||||
seo: {
|
||||
og: { enabled: true },
|
||||
rss: { enabled: true, types: ["changelog"] },
|
||||
sitemap: true,
|
||||
robots: true,
|
||||
structuredData: true,
|
||||
x: { handle: "@prdlk" },
|
||||
},
|
||||
// Blume's own assistant route is server-rendered (it reads the provider key
|
||||
// per request), which a static Pages build cannot ship. The SRS Worker
|
||||
// answers instead (apps/api/src/ask.ts: OpenRouter, grounded on this site's
|
||||
// llms-full.txt, which `agents.llmsTxt` publishes).
|
||||
ai: {
|
||||
assistant: {
|
||||
enabled: true,
|
||||
endpoint: "https://srs-api.prdlk.workers.dev/ask",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
llmsTxt: true,
|
||||
mcp: { enabled: false },
|
||||
},
|
||||
content: {
|
||||
root: "content",
|
||||
sources: [
|
||||
filesystem({ root: "content" }),
|
||||
custom(
|
||||
githubReleasesSource(
|
||||
{
|
||||
name: "changelog",
|
||||
prefix: "changelog",
|
||||
owner: "prdlk",
|
||||
repo: "leetcode",
|
||||
fetchImpl: releasesDatedByTag,
|
||||
},
|
||||
// What resolve.ts hands the built-in adapter: `.blume/cache/<name>/`,
|
||||
// and a refresh on every load (the source reads nothing else).
|
||||
{
|
||||
projectRoot: import.meta.dirname,
|
||||
cacheDir: `${import.meta.dirname}/.blume/cache/changelog`,
|
||||
mode: "build",
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
},
|
||||
theme: {
|
||||
fonts: { display: inter, body: inter, mono: plexMono },
|
||||
},
|
||||
navigation: {
|
||||
tabs: [
|
||||
{
|
||||
label: "Reference",
|
||||
path: "/reference",
|
||||
sidebar: {
|
||||
display: "flat", // "flat" | "group" | "page"
|
||||
},
|
||||
tabs: [
|
||||
{ label: "Guides", path: "/guides" },
|
||||
{ label: "Changelog", path: "/changelog" },
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "theme.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Project MDX components, available on every page with no import, and layout
|
||||
* slot overrides. Static `.astro` files ship no framework JS (a custom element
|
||||
* carries any behaviour); interactive React lives in `islands/` instead.
|
||||
*/
|
||||
import { defineComponents } from "blume";
|
||||
|
||||
export default defineComponents({
|
||||
mdx: {
|
||||
Carousel: "./components/Carousel.astro",
|
||||
CarouselImage: "./components/CarouselImage.astro",
|
||||
// MDX's own content wrapper: problem pages' topic + explanation badges, from
|
||||
// `topics:` / `video:` frontmatter, between the description and the body.
|
||||
wrapper: "./components/ProblemBadges.astro",
|
||||
},
|
||||
layout: {
|
||||
// Topic landing + guide pages' "what links here": every problem pointing at the page.
|
||||
PageFooter: "./components/BacklinksList.astro",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
/**
|
||||
* The `PageFooter` slot: an Obsidian-style backlinks list under a topic landing
|
||||
* or guide page — every problem page that points at it, as `<num> <name>`
|
||||
* rows in LeetCode-number order. Pages nothing points at (problem pages, the
|
||||
* overview, the changelog) render nothing.
|
||||
*
|
||||
* A problem points at a page when either
|
||||
* - one of its topic badges links there — the same `tagTarget()` answer
|
||||
* `ProblemBadges.astro` renders, so this list and the badges cannot drift
|
||||
* (a DFS problem is a backlink of the one DFS landing its badge picks); or
|
||||
* - it lives under the page's route (`/hybrid-structures/lru-cache` under
|
||||
* `/hybrid-structures`), so a topic no LeetCode tag names still lists its
|
||||
* own problems.
|
||||
*
|
||||
* Problem pages are recognised by their `<num>. <name>` title, the shape
|
||||
* `apps/cli/sync.ts` writes.
|
||||
*
|
||||
* The list is a native `<details>`, open on load: the chevron at the top
|
||||
* right of the header row collapses it like an accordion, with no JS.
|
||||
*/
|
||||
import { getEntry } from "astro:content";
|
||||
import data from "blume:data";
|
||||
import { withBase } from "blume/components/islands/base-path.ts";
|
||||
import Icon from "blume/components/Icon.astro";
|
||||
import { tagTarget, type TopicTag } from "../topic-landings.ts";
|
||||
|
||||
interface Props {
|
||||
route: string;
|
||||
}
|
||||
|
||||
interface Route {
|
||||
path: string;
|
||||
title: string;
|
||||
collection: string;
|
||||
entryId: string;
|
||||
}
|
||||
|
||||
const { route } = Astro.props;
|
||||
const routes: Route[] = data.routes;
|
||||
const served = new Set(routes.map((r) => r.path));
|
||||
|
||||
const problems = routes.flatMap((r) => {
|
||||
const m = r.title.match(/^(\d+)\.\s+(.+)$/);
|
||||
return m ? [{ route: r, num: Number(m[1]), name: m[2]! }] : [];
|
||||
});
|
||||
|
||||
// A problem page is never a backlink target: skip the per-problem lookups there.
|
||||
const backlinks = problems.some((p) => p.route.path === route)
|
||||
? []
|
||||
: (
|
||||
await Promise.all(
|
||||
problems.map(async (p) => {
|
||||
if (p.route.path.startsWith(`${route}/`)) return p;
|
||||
const entry = await getEntry(p.route.collection, p.route.entryId);
|
||||
const tags: TopicTag[] = entry?.data.topics ?? [];
|
||||
const page = { route: p.route.path, tags };
|
||||
return tags.some((tag) => tagTarget(tag, page, served).href === route) ? p : undefined;
|
||||
}),
|
||||
)
|
||||
)
|
||||
.filter((p) => p !== undefined)
|
||||
.sort((a, b) => a.num - b.num);
|
||||
---
|
||||
|
||||
{
|
||||
backlinks.length > 0 && (
|
||||
<details
|
||||
class="group not-prose mx-auto mt-12 max-w-content border-border border-t pt-6"
|
||||
open
|
||||
>
|
||||
<summary class="mb-2 flex cursor-pointer list-none items-center justify-between gap-2 text-muted-foreground transition-colors hover:text-foreground [&::-webkit-details-marker]:hidden">
|
||||
<h2 class="flex items-baseline gap-2 font-medium text-xs uppercase tracking-wide" id="backlinks">
|
||||
Backlinks
|
||||
<span class="tabular-nums">{backlinks.length}</span>
|
||||
</h2>
|
||||
<Icon class="shrink-0 transition-transform group-open:rotate-180" name="chevron-down" size={16} />
|
||||
</summary>
|
||||
<ul class="text-sm">
|
||||
{backlinks.map((p) => (
|
||||
<li>
|
||||
<a
|
||||
class="-mx-2 flex items-baseline gap-3 rounded-md px-2 py-1 text-foreground no-underline transition-colors hover:bg-muted"
|
||||
href={withBase(p.route.path)}
|
||||
>
|
||||
<span class="w-10 shrink-0 text-right font-mono text-muted-foreground text-xs tabular-nums">{p.num}</span>
|
||||
<span class="truncate">{p.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
/**
|
||||
* A horizontal carousel of <CarouselImage> slides, built the way Blume's own
|
||||
* content components are: static markup, a custom element for the behaviour,
|
||||
* no framework. The track is a CSS scroll-snap strip (so it also swipes on
|
||||
* touch and the browser does the animation); prev/next sit outside it and
|
||||
* the play toggle sits below with a "n / N" counter.
|
||||
*
|
||||
* `time` is seconds per slide while playing (default 1.25); `autoplay` starts
|
||||
* playing on load. Play wraps around, pauses whenever the tab is hidden, and
|
||||
* never starts on its own for a reader who prefers reduced motion — the
|
||||
* button still works for them. Manual navigation (buttons, arrow keys, swipe)
|
||||
* does not stop playback; it only restarts the interval so a step is never
|
||||
* doubled.
|
||||
*/
|
||||
import Icon from "blume/components/Icon.astro";
|
||||
|
||||
interface Props {
|
||||
/** Seconds each slide stays while playing. */
|
||||
time?: number;
|
||||
autoplay?: boolean;
|
||||
}
|
||||
|
||||
const { time = 1.25, autoplay = false } = Astro.props;
|
||||
|
||||
const buttonClass =
|
||||
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors hover:border-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40";
|
||||
---
|
||||
|
||||
<blume-carousel
|
||||
class="not-prose my-6 block"
|
||||
data-time={String(time)}
|
||||
data-autoplay={autoplay ? "" : undefined}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class={buttonClass} data-prev type="button" aria-label="Previous slide">
|
||||
<Icon class="rtl:-scale-x-100" name="chevron-left" size={18} />
|
||||
</button>
|
||||
<div
|
||||
class="flex min-w-0 flex-1 snap-x snap-mandatory overflow-x-auto scroll-smooth rounded-blume [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
data-track
|
||||
tabindex="0"
|
||||
aria-live="polite"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<button class={buttonClass} data-next type="button" aria-label="Next slide">
|
||||
<Icon class="rtl:-scale-x-100" name="chevron-right" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-center gap-3">
|
||||
<button class={buttonClass} data-play type="button" aria-label="Play" aria-pressed="false">
|
||||
<Icon class="[[aria-pressed=true]_&]:hidden" name="play" size={16} />
|
||||
<Icon class="[[aria-pressed=false]_&]:hidden" name="pause" size={16} />
|
||||
</button>
|
||||
<span class="text-muted-foreground text-xs tabular-nums leading-5" data-count></span>
|
||||
</div>
|
||||
</blume-carousel>
|
||||
|
||||
<script>
|
||||
class BlumeCarousel extends HTMLElement {
|
||||
#track!: HTMLElement;
|
||||
#play!: HTMLButtonElement;
|
||||
#count!: HTMLElement;
|
||||
#timer: number | null = null;
|
||||
#index = 0;
|
||||
|
||||
connectedCallback() {
|
||||
this.#track = this.querySelector("[data-track]")!;
|
||||
this.#play = this.querySelector("[data-play]")!;
|
||||
this.#count = this.querySelector("[data-count]")!;
|
||||
const prev = this.querySelector<HTMLButtonElement>("[data-prev]")!;
|
||||
const next = this.querySelector<HTMLButtonElement>("[data-next]")!;
|
||||
|
||||
const slides = this.#slides().length;
|
||||
if (slides < 2) {
|
||||
for (const b of [prev, next, this.#play]) b.disabled = true;
|
||||
}
|
||||
this.#render();
|
||||
|
||||
prev.addEventListener("click", () => this.#step(-1));
|
||||
next.addEventListener("click", () => this.#step(1));
|
||||
this.#play.addEventListener("click", () => (this.#timer === null ? this.#start() : this.#stop()));
|
||||
this.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowLeft") this.#step(-1);
|
||||
else if (event.key === "ArrowRight") this.#step(1);
|
||||
else return;
|
||||
event.preventDefault();
|
||||
});
|
||||
// Swipes and trackpad scrolls move the track without #step; follow them
|
||||
// once the scroll settles. Settle-only, not per event: a long smooth
|
||||
// scroll from #step passes over intermediate slides and must not be
|
||||
// read back as the reader's position mid-flight.
|
||||
let settle: number | undefined;
|
||||
this.#track.addEventListener(
|
||||
"scroll",
|
||||
() => {
|
||||
window.clearTimeout(settle);
|
||||
settle = window.setTimeout(() => this.#syncFromScroll(), 150);
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
document.addEventListener("visibilitychange", this.#onVisibility);
|
||||
|
||||
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (this.hasAttribute("data-autoplay") && slides > 1 && !reduced) this.#start();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.#stop();
|
||||
document.removeEventListener("visibilitychange", this.#onVisibility);
|
||||
}
|
||||
|
||||
#slides(): HTMLElement[] {
|
||||
return [...this.#track.querySelectorAll<HTMLElement>(":scope > blume-carousel-image")];
|
||||
}
|
||||
|
||||
#step(delta: number) {
|
||||
const slides = this.#slides();
|
||||
this.#index = (this.#index + delta + slides.length) % slides.length;
|
||||
slides[this.#index]!.scrollIntoView({ block: "nearest", inline: "start", behavior: "smooth" });
|
||||
this.#render();
|
||||
if (this.#timer !== null) this.#start(); // restart the interval from this slide
|
||||
}
|
||||
|
||||
#syncFromScroll() {
|
||||
const at = Math.round(this.#track.scrollLeft / this.#track.clientWidth);
|
||||
if (at !== this.#index && Number.isFinite(at)) {
|
||||
this.#index = Math.min(Math.max(at, 0), this.#slides().length - 1);
|
||||
this.#render();
|
||||
}
|
||||
}
|
||||
|
||||
#start() {
|
||||
this.#stop();
|
||||
const ms = Number(this.dataset.time) * 1000;
|
||||
this.#timer = window.setInterval(() => this.#step(1), ms > 0 ? ms : 1250);
|
||||
this.#play.setAttribute("aria-pressed", "true");
|
||||
this.#play.setAttribute("aria-label", "Pause");
|
||||
}
|
||||
|
||||
#stop() {
|
||||
if (this.#timer !== null) window.clearInterval(this.#timer);
|
||||
this.#timer = null;
|
||||
this.#play.setAttribute("aria-pressed", "false");
|
||||
this.#play.setAttribute("aria-label", "Play");
|
||||
}
|
||||
|
||||
// A background tab keeps its timers but not its layout; resume on return.
|
||||
#wasPlaying = false;
|
||||
#onVisibility = () => {
|
||||
if (document.hidden) {
|
||||
this.#wasPlaying = this.#timer !== null;
|
||||
this.#stop();
|
||||
} else if (this.#wasPlaying) this.#start();
|
||||
};
|
||||
|
||||
#render() {
|
||||
const slides = this.#slides();
|
||||
this.#count.textContent = `${this.#index + 1} / ${slides.length}`;
|
||||
slides.forEach((s, i) => s.setAttribute("aria-hidden", i === this.#index ? "false" : "true"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("blume-carousel")) {
|
||||
customElements.define("blume-carousel", BlumeCarousel);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
/**
|
||||
* One slide of a <Carousel>: Blume's own <Frame> around an <img>, so a slide
|
||||
* is pixel-identical to a framed image elsewhere on the site — `alt` doubles
|
||||
* as the frame's hint line and `caption` is Markdown, exactly as in Frame.
|
||||
*
|
||||
* `src` accepts what MDX authors already write for images: a URL, a
|
||||
* root-relative `/file.png` (served from public/, prefixed with the deploy
|
||||
* base), or the page-relative `../../public/file.png` form. A relative path
|
||||
* that does not pass through public/ has nothing to serve it, so it is a
|
||||
* build error rather than a silent 404.
|
||||
*/
|
||||
import Frame from "blume/components/content/Frame.astro";
|
||||
import { withBase } from "blume/components/islands/base-path.ts";
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
const { src, alt, caption } = Astro.props;
|
||||
|
||||
const resolveSrc = (value: string): string => {
|
||||
if (URL.canParse(value)) return value;
|
||||
if (value.startsWith("/")) return withBase(value);
|
||||
const at = value.indexOf("public/");
|
||||
if (at === -1) {
|
||||
throw new Error(
|
||||
`<CarouselImage src="${value}">: a relative src must point into public/ (or use /file.png)`
|
||||
);
|
||||
}
|
||||
return withBase(`/${value.slice(at + "public/".length)}`);
|
||||
};
|
||||
---
|
||||
|
||||
<blume-carousel-image
|
||||
class="block w-full shrink-0 snap-center [&_blume-frame]:my-0"
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
>
|
||||
<Frame caption={caption} hint={alt}>
|
||||
<img alt={alt ?? ""} src={resolveSrc(src)} loading="lazy" draggable="false" />
|
||||
</Frame>
|
||||
</blume-carousel-image>
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
/**
|
||||
* The MDX `wrapper`: the badge row between a page's description and its body.
|
||||
* Blume's page template renders the title and description itself and then
|
||||
* the MDX content, with no slot in between, so wrapping the content is the
|
||||
* only way to land right after the description. MDX hands a wrapper no
|
||||
* route, so it is read off the URL, and the page's frontmatter is then
|
||||
* looked up through the route manifest — the same `getEntry` call Blume's own
|
||||
* page template makes. Pages with none of the keys below (landing pages, the
|
||||
* changelog) render only their content.
|
||||
*
|
||||
* - `topics:` (problem pages, written by `apps/cli/sync.ts`) is LeetCode's own
|
||||
* topic tags. A tag the campaign teaches (`topic-landings.ts`) links to that
|
||||
* topic's landing page and is filled; any other tag links to its guide
|
||||
* page, `/guides/<slug>` (sync creates one per such tag), and is outlined.
|
||||
* `tagTarget()` picks the route (and between two landings, e.g. DFS); the
|
||||
* `PageFooter` backlinks list reads the same answer. Each tag badge is a
|
||||
* pill carrying the icon its target shows in the sidebar (the topic's
|
||||
* `meta.ts` icon, the guide's `icon:`) and the tag's own colour
|
||||
* (`lib/tag-colors.ts`), assigned over every tag the site has a page for —
|
||||
* no two tags share one, and tags that share problem pages are spread
|
||||
* furthest apart. A tag with no page (the LeetCode-list fallback) stays
|
||||
* gray and iconless.
|
||||
* - `video:` (problem pages, written by `apps/cli/videos.ts`) is the NeetCode
|
||||
* walkthrough's YouTube id, the red "Explanation" badge at the end of the row.
|
||||
* - `list:` (guide pages, written by sync on create) is the tag's LeetCode
|
||||
* problem list, an orange "LeetCode list" badge.
|
||||
*/
|
||||
import { getEntry } from "astro:content";
|
||||
import data from "blume:data";
|
||||
import Badge from "blume/components/content/Badge.astro";
|
||||
import { stripBase, withBase } from "blume/components/islands/base-path.ts";
|
||||
import { tagColors } from "../lib/tag-colors.ts";
|
||||
import { TOPIC_LANDINGS, tagTarget, type TopicTag } from "../topic-landings.ts";
|
||||
|
||||
// `/sub/tree-dfs/invert-binary-tree/` (or `.../index.html` in a static build)
|
||||
// -> `/tree-dfs/invert-binary-tree`, the base-less shape of `data.routes`.
|
||||
const route =
|
||||
stripBase(import.meta.env.BASE_URL ?? "/", Astro.url.pathname)
|
||||
.replace(/(\/index)?\.html$/u, "")
|
||||
.replace(/(.)\/+$/u, "$1");
|
||||
const at = data.routes.find((r: { path: string }) => r.path === route);
|
||||
const entry = at ? await getEntry(at.collection, at.entryId) : undefined;
|
||||
const tags: TopicTag[] = entry?.data.topics ?? [];
|
||||
const video: string | undefined = entry?.data.video;
|
||||
const list: string | undefined = entry?.data.list;
|
||||
|
||||
const served = new Set<string>(data.routes.map((r: { path: string }) => r.path));
|
||||
|
||||
// route → the icon its sidebar row shows: a page's own, or a topic group's
|
||||
// (from its `meta.ts`) for the landing page the group row links to.
|
||||
interface NavNode {
|
||||
route?: string;
|
||||
icon?: string;
|
||||
children?: NavNode[];
|
||||
}
|
||||
const icons = new Map<string, string>();
|
||||
const walk = (nodes: NavNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.route && node.icon && !icons.has(node.route)) icons.set(node.route, node.icon);
|
||||
if (node.children) walk(node.children);
|
||||
}
|
||||
};
|
||||
walk(data.navigation.sidebar);
|
||||
|
||||
// Every tag with a page — the ones a landing teaches, plus every served guide —
|
||||
// coloured against how problem pages (`<num>. <name>` titles, the shape sync
|
||||
// writes) combine them. Only a page with tags needs the answer.
|
||||
const colors = tags.length
|
||||
? tagColors(
|
||||
[
|
||||
...Object.keys(TOPIC_LANDINGS),
|
||||
...[...served].flatMap((path) => path.match(/^\/guides\/([^/]+)$/u)?.slice(1) ?? []),
|
||||
],
|
||||
await Promise.all(
|
||||
data.routes
|
||||
.filter((r: { title: string }) => /^\d+\.\s/u.test(r.title))
|
||||
.map(async (r: { collection: string; entryId: string }) => {
|
||||
const page = await getEntry(r.collection, r.entryId);
|
||||
return (page?.data.topics ?? []).map((t: TopicTag) => t.slug);
|
||||
}),
|
||||
),
|
||||
)
|
||||
: new Map<string, string>();
|
||||
|
||||
const badges = tags.map((tag) => {
|
||||
const target = tagTarget(tag, { route, tags }, served);
|
||||
const external = target.kind === "external";
|
||||
return {
|
||||
name: tag.name,
|
||||
href: external ? target.href : withBase(target.href),
|
||||
external,
|
||||
icon: external ? undefined : icons.get(target.href),
|
||||
color: external ? undefined : colors.get(tag.slug),
|
||||
stroke: target.kind !== "landing",
|
||||
};
|
||||
});
|
||||
|
||||
const linkClass = "no-underline transition-opacity hover:opacity-80";
|
||||
---
|
||||
|
||||
{
|
||||
(badges.length > 0 || video || list) && (
|
||||
<div class="not-prose mb-6 flex flex-wrap gap-1.5">
|
||||
{badges.map((b) => (
|
||||
<a
|
||||
href={b.href}
|
||||
target={b.external ? "_blank" : undefined}
|
||||
rel={b.external ? "noopener" : undefined}
|
||||
class={linkClass}
|
||||
>
|
||||
<Badge color={b.color} icon={b.icon} shape="pill" stroke={b.stroke}>
|
||||
{b.name}
|
||||
</Badge>
|
||||
</a>
|
||||
))}
|
||||
{video && (
|
||||
<a href={`https://www.youtube.com/watch?v=${video}`} target="_blank" rel="noopener" class={linkClass}>
|
||||
<Badge variant="danger" icon="youtube" shape="pill">
|
||||
Explanation
|
||||
</Badge>
|
||||
</a>
|
||||
)}
|
||||
{list && (
|
||||
<a href={list} target="_blank" rel="noopener" class={linkClass}>
|
||||
<Badge variant="warning" icon="list" shape="pill">
|
||||
LeetCode list
|
||||
</Badge>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<slot />
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* What the four progress islands share besides the data: the not-ready body
|
||||
* (a skeleton while /api/stats loads, one muted line if it fails) and the
|
||||
* hover tooltip for the two charts drawn as plain SVG (the heatmap calendar
|
||||
* and the topic bubbles — Recharts has neither layout). The tooltip wears the
|
||||
* exact classes of shadcn's ChartTooltipContent, so all four cards' tooltips
|
||||
* look like one component.
|
||||
*/
|
||||
import { type PointerEvent, type ReactNode, useRef, useState } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { Load } from "../../lib/stats";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export function StatsPending({ load, className }: { load: Exclude<Load, { phase: "ready" }>; className: string }) {
|
||||
if (load.phase === "loading") return <Skeleton className={className} />;
|
||||
return (
|
||||
<p className="my-0! text-muted-foreground text-sm">
|
||||
Stats unavailable — they are served live from the SRS Worker, and it did not respond.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
interface Tip {
|
||||
x: number;
|
||||
y: number;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pointer-following tooltip for SVG marks. Put `ref` on a `relative`
|
||||
* wrapper, call `show` from a mark's pointer events, render `tip` inside the
|
||||
* wrapper. Positions are relative to the wrapper, so page scroll never skews it.
|
||||
*/
|
||||
export function useHoverTip<T extends HTMLElement>() {
|
||||
const ref = useRef<T>(null);
|
||||
const [tip, setTip] = useState<Tip | null>(null);
|
||||
const show = (event: PointerEvent, content: ReactNode) => {
|
||||
const box = ref.current?.getBoundingClientRect();
|
||||
if (box) setTip({ x: event.clientX - box.left, y: event.clientY - box.top, content });
|
||||
};
|
||||
const hide = () => setTip(null);
|
||||
const node = tip && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute z-10 grid min-w-[8rem] -translate-x-1/2 -translate-y-[calc(100%+0.75rem)] items-start gap-1.5",
|
||||
"rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
)}
|
||||
style={{ left: tip.x, top: tip.y }}
|
||||
>
|
||||
{tip.content}
|
||||
</div>
|
||||
);
|
||||
return { ref, show, hide, node };
|
||||
}
|
||||
|
||||
/** One tooltip row, the shape ChartTooltipContent draws: colour dot, label, mono value. */
|
||||
export function TipRow({ color, label, value }: { color: string; label: ReactNode; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="size-2.5 shrink-0 rounded-[2px]" style={{ background: color }} />
|
||||
<div className="flex flex-1 items-center justify-between gap-4 leading-none">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium font-mono text-foreground tabular-nums">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
import type { TooltipValueType } from "recharts"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }. Blume switches dark mode with
|
||||
// [data-theme="dark"] on <html>, not shadcn's .dark class.
|
||||
const THEMES = { light: "", dark: ':root[data-theme="dark"]' } as const
|
||||
|
||||
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
|
||||
type TooltipNameType = number | string
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
>
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
initialDimension = INITIAL_DIMENSION,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
initialDimension?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer
|
||||
initialDimension={initialDimension}
|
||||
>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme ?? config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
} & Omit<
|
||||
RechartsPrimitive.DefaultTooltipContentProps<
|
||||
TooltipValueType,
|
||||
TooltipNameType
|
||||
>,
|
||||
"accessibilityLayer"
|
||||
>) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? (config[label]?.label ?? label)
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color ?? item.payload?.fill ?? item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ?? item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number"
|
||||
? item.value.toLocaleString()
|
||||
: String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
|
||||
|
||||
import { toggleVariants } from "./toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
||||
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { Toggle as TogglePrimitive } from "radix-ui"
|
||||
|
||||
// Blume's `accent` is its brand colour, not shadcn's subtle surface: the on
|
||||
// and hover states use background/muted instead (see theme.css).
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-muted hover:text-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 min-w-9 px-2",
|
||||
sm: "h-8 min-w-8 px-1.5",
|
||||
lg: "h-10 min-w-10 px-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
+20
-2
@@ -1,13 +1,16 @@
|
||||
---
|
||||
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"
|
||||
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]].
|
||||
::::
|
||||
@@ -35,6 +38,21 @@ Notice that rotating an array [a[0], a[1], a[2], ..., a[n-1]] 1 time results in
|
||||
- All the integers of `nums` are unique.
|
||||
- `nums` is sorted and rotated between 1 and `n` times.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["findMin(nums)"]) --> I["l = 0, r = n-1, lowest_index = -1"]
|
||||
I --> W{"l <= r?"}
|
||||
W -- no --> E(["return nums[lowest_index]"])
|
||||
W -- yes --> M["m = (l + r) // 2"]
|
||||
M --> C{"nums[m] <= nums[-1]?"}
|
||||
C -- yes --> A["in the right sorted run: record lowest_index = m, r = m - 1"]
|
||||
A --> W
|
||||
C -- no --> B["still in the left sorted run: l = m + 1"]
|
||||
B --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+28
-2
@@ -1,13 +1,16 @@
|
||||
---
|
||||
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"
|
||||
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.
|
||||
::::
|
||||
@@ -32,6 +35,29 @@ You must write an algorithm with O(log n) runtime complexity.
|
||||
- `nums` is an ascending array that is possibly rotated.
|
||||
- `-10^4 <= target <= 10^4`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["search(nums, target)"]) --> I["l = 0, r = n - 1"]
|
||||
I --> W{"l <= r?"}
|
||||
W -- no --> E(["return -1"])
|
||||
W -- yes --> M["mid = floor((l + r) / 2)"]
|
||||
M --> F{"nums[mid] == target?"}
|
||||
F -- yes --> R(["return mid"])
|
||||
F -- no --> H{"nums[l] <= nums[mid]? — left half sorted"}
|
||||
H -- yes --> A{"target outside [nums[l], nums[mid]]?"}
|
||||
A -- yes --> A1["l = mid + 1 — search the right half"]
|
||||
A -- no --> A2["r = mid - 1"]
|
||||
H -- no --> B{"target outside [nums[mid], nums[r]]?"}
|
||||
B -- yes --> B1["r = mid - 1 — search the left half"]
|
||||
B -- no --> B2["l = mid + 1"]
|
||||
A1 --> W
|
||||
A2 --> W
|
||||
B1 --> W
|
||||
B2 --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
+21
-2
@@ -1,13 +1,16 @@
|
||||
---
|
||||
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"
|
||||
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.
|
||||
::::
|
||||
@@ -31,6 +34,22 @@ You must write an algorithm with O(log n) runtime complexity.
|
||||
- `nums` contains distinct values sorted in ascending order.
|
||||
- `-10^4 <= target <= 10^4`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["searchInsert(nums, target)"]) --> I["l = 0, r = n - 1"]
|
||||
I --> W{"l <= r?"}
|
||||
W -- yes --> M["m = (l + r) // 2"]
|
||||
M --> Q{"nums[m] vs target"}
|
||||
Q -- equal --> R(["return m"])
|
||||
Q -- "nums[m] < target" --> L["l = m + 1"]
|
||||
L --> W
|
||||
Q -- "nums[m] > target" --> H["r = m - 1"]
|
||||
H --> W
|
||||
W -- no --> E(["return (l + r) // 2 + 1 — the insert slot"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+21
-2
@@ -1,13 +1,16 @@
|
||||
---
|
||||
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"
|
||||
sidebar:
|
||||
label: 'Binary Search'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search</Badge>
|
||||
|
||||
::::warning
|
||||
You must write an algorithm with O(log n) runtime complexity.
|
||||
::::
|
||||
@@ -29,6 +32,22 @@ You must write an algorithm with O(log n) runtime complexity.
|
||||
- All the integers in `nums` are unique.
|
||||
- `nums` is sorted in ascending order.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["search(nums, target)"]) --> I["left = 0, right = n - 1"]
|
||||
I --> W{"left <= right?"}
|
||||
W -- no --> E(["return -1"])
|
||||
W -- yes --> M["mid = left + floor((right - left) / 2)"]
|
||||
M --> Q{"nums[mid] vs target"}
|
||||
Q -- equal --> R(["return mid"])
|
||||
Q -- "nums[mid] < target" --> L["left = mid + 1"]
|
||||
L --> W
|
||||
Q -- "nums[mid] > target" --> H["right = mid - 1"]
|
||||
H --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
+32
-2
@@ -1,13 +1,17 @@
|
||||
---
|
||||
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"
|
||||
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.
|
||||
::::
|
||||
@@ -27,6 +31,32 @@ You must write a solution in O(log(m * n)) time complexity.
|
||||
- `1 <= m, n <= 100`
|
||||
- `-10^4 <= matrix[i][j], target <= 10^4`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["searchMatrix(matrix, target)"]) --> I["top = 0, bot = ROWS - 1"]
|
||||
I --> W1{"top <= bot?"}
|
||||
W1 -- yes --> R1["row = (top + bot) // 2"]
|
||||
R1 --> C1{"target vs that row's range"}
|
||||
C1 -- "above matrix[row][-1]" --> U["top = row + 1"]
|
||||
C1 -- "below matrix[row][0]" --> V["bot = row - 1"]
|
||||
C1 -- inside --> B(["break — row found"])
|
||||
U --> W1
|
||||
V --> W1
|
||||
W1 -- no --> F(["return False — no row can hold it"])
|
||||
B --> P["row = (top + bot) // 2; l = 0, r = COLS - 1"]
|
||||
P --> W2{"l <= r?"}
|
||||
W2 -- no --> G(["return False"])
|
||||
W2 -- yes --> M["m = (l + r) // 2"]
|
||||
M --> C2{"target vs matrix[row][m]"}
|
||||
C2 -- greater --> X["l = m + 1"]
|
||||
C2 -- smaller --> Y["r = m - 1"]
|
||||
C2 -- equal --> T(["return True"])
|
||||
X --> W2
|
||||
Y --> W2
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+21
-2
@@ -1,13 +1,16 @@
|
||||
---
|
||||
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"
|
||||
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`
|
||||
@@ -26,6 +29,22 @@ sidebar:
|
||||
- `piles.length <= h <= 10^9`
|
||||
- `1 <= piles[i] <= 10^9`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["minEatingSpeed(piles, h)"]) --> I["l = 1, r = max(piles)"]
|
||||
I --> W{"l < r?"}
|
||||
W -- no --> E(["return l — the smallest workable speed"])
|
||||
W -- yes --> M["mid = floor((l + r) / 2)"]
|
||||
M --> K["kWorks(mid): hours = sum of ceil(p / mid)"]
|
||||
K --> Q{"hours <= h?"}
|
||||
Q -- yes --> A["r = mid — keep mid, try slower"]
|
||||
Q -- no --> B["l = mid + 1 — too slow, speed up"]
|
||||
A --> W
|
||||
B --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Binary Search
|
||||
description: 'Half of a sorted range is removed at each step until the target is found.'
|
||||
hidden: true
|
||||
---
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({
|
||||
title: "Binary Search",
|
||||
order: 5,
|
||||
icon: "search",
|
||||
display: "group",
|
||||
});
|
||||
+19
-2
@@ -1,13 +1,16 @@
|
||||
---
|
||||
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"
|
||||
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?
|
||||
::::
|
||||
@@ -32,6 +35,20 @@ Can you come up with an algorithm that is less than O(n^2) time complexity?
|
||||
- `-10^9 <= target <= 10^9`
|
||||
- Only one valid answer exists.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["twoSum(nums, target)"]) --> I["seen = {} — value to index"]
|
||||
I --> L{"more (i, num) in nums?"}
|
||||
L -- no --> E(["return []"])
|
||||
L -- yes --> C["complement = target - num"]
|
||||
C --> H{"complement in seen?"}
|
||||
H -- yes --> R(["return [seen[complement], i]"])
|
||||
H -- no --> W["seen[num] = i"]
|
||||
W --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+17
-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`
|
||||
@@ -22,6 +24,19 @@ sidebar:
|
||||
- `1 <= sentence.length <= 1000`
|
||||
- `sentence` consists of lowercase English letters.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["checkIfPangram(sentence)"]) --> I["freq = {}"]
|
||||
I --> L{"more char c?"}
|
||||
L -- yes --> C["freq[c] = (freq[c] or 0) + 1"]
|
||||
C --> L
|
||||
L -- no --> Q{"distinct keys in freq == 26?"}
|
||||
Q -- yes --> R(["return true"])
|
||||
Q -- no --> F(["return false"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
+23
-17
@@ -1,13 +1,17 @@
|
||||
---
|
||||
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"
|
||||
sidebar:
|
||||
label: 'Contains Duplicate'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,2,3,1]`
|
||||
- Output: `true`
|
||||
@@ -27,21 +31,23 @@ sidebar:
|
||||
- `1 <= nums.length <= 10^5`
|
||||
- `-10^9 <= nums[i] <= 10^9`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["containsDuplicate(nums)"]) --> I["freq = {}"]
|
||||
I --> L{"more n in nums?"}
|
||||
L -- no --> E(["return false"])
|
||||
L -- yes --> C["freq[n] = (freq[n] or 0) + 1"]
|
||||
C --> Q{"freq[n] >= 2?"}
|
||||
Q -- yes --> R(["return true"])
|
||||
Q -- no --> L
|
||||
```
|
||||
|
||||
## 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)
|
||||
```
|
||||
+19
-2
@@ -1,13 +1,17 @@
|
||||
---
|
||||
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"
|
||||
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?
|
||||
::::
|
||||
@@ -25,6 +29,19 @@ What if the inputs contain Unicode characters? How would you adapt your solution
|
||||
- `1 <= s.length, t.length <= 5 * 10^4`
|
||||
- `s` and `t` consist of lowercase English letters.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["isAnagram(s, t)"]) --> I["freq: count every char of s"]
|
||||
I --> L{"more char c in t?"}
|
||||
L -- no --> E(["return true"])
|
||||
L -- yes --> Q{"freq[c] missing or 0?"}
|
||||
Q -- yes --> R(["return false — t has a char s cannot cover"])
|
||||
Q -- no --> D["freq[c] -= 1"]
|
||||
D --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
+21
-2
@@ -1,13 +1,22 @@
|
||||
---
|
||||
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"
|
||||
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.
|
||||
::::
|
||||
@@ -31,6 +40,16 @@ Your algorithm's time complexity must be better than O(n log n), where n is the
|
||||
- `k` is in the range `[1, the number of unique elements in the array]`.
|
||||
- It is guaranteed that the answer is unique.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["topKFrequent(nums, k)"]) --> F["freq: count every value in nums"]
|
||||
F --> K["keys of freq, cast to Number"]
|
||||
K --> O["sort by freq[b] - freq[a] — most frequent first"]
|
||||
O --> R(["return slice(0, k)"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
+19
-2
@@ -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`
|
||||
@@ -26,6 +31,18 @@ sidebar:
|
||||
- `1 <= s.length <= 10^5`
|
||||
- `s` consists of only lowercase English letters.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["firstUniqChar(s)"]) --> F["Pass 1 — freq: count every char of s"]
|
||||
F --> L{"Pass 2 — more i in 0..len-1?"}
|
||||
L -- no --> E(["return -1"])
|
||||
L -- yes --> Q{"freq[s[i]] == 1?"}
|
||||
Q -- yes --> R(["return i — leftmost wins because the scan is in order"])
|
||||
Q -- no --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
+21
-2
@@ -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"`
|
||||
@@ -28,6 +35,18 @@ sidebar:
|
||||
- `1 <= s.length <= 5 * 10^5`
|
||||
- `s` consists of uppercase and lowercase English letters and digits.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["frequencySort(s)"]) --> F["freq: count every char of s"]
|
||||
F --> C["split s and sort with a two-key comparator"]
|
||||
C --> K1["primary: freq[b] - freq[a] — most frequent first"]
|
||||
C --> K2["tie-break: a.localeCompare(b) — alphabetical"]
|
||||
K1 --> R(["join and return"])
|
||||
K2 --> R
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
+22
-2
@@ -1,13 +1,18 @@
|
||||
---
|
||||
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"
|
||||
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"]]`
|
||||
@@ -27,6 +32,21 @@ sidebar:
|
||||
- `0 <= strs[i].length <= 100`
|
||||
- `strs[i]` consists of lowercase English letters.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["groupAnagrams(strs)"]) --> K["sorted[i] = letters of strs[i], sorted — the anagram key"]
|
||||
K --> I["anagrams = {}"]
|
||||
I --> L{"more i in 0..n-1?"}
|
||||
L -- no --> E(["return Object.values(anagrams)"])
|
||||
L -- yes --> Q{"anagrams has sorted[i]?"}
|
||||
Q -- no --> N["anagrams[sorted[i]] = [strs[i]]"]
|
||||
Q -- yes --> P["anagrams[sorted[i]].push(strs[i])"]
|
||||
N --> L
|
||||
P --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Hash Table
|
||||
description: 'A key maps to a value in constant time, which removes the need for a full scan.'
|
||||
hidden: true
|
||||
---
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({
|
||||
title: "Hash Table",
|
||||
order: 1,
|
||||
icon: "hash",
|
||||
display: "group",
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "1. Linear Structures", order: 1 });
|
||||
+20
-2
@@ -1,13 +1,17 @@
|
||||
---
|
||||
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"
|
||||
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]`
|
||||
@@ -25,6 +29,20 @@ sidebar:
|
||||
- `1 <= temperatures.length <= 10^5`
|
||||
- `30 <= temperatures[i] <= 100`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["dailyTemperatures(temperatures)"]) --> I["ans = zeros(n), stack = [] — indices of days still waiting"]
|
||||
I --> L{"more (i, temp)?"}
|
||||
L -- no --> E(["return ans"])
|
||||
L -- yes --> W{"stack non-empty and temperatures[stack[-1]] < temp?"}
|
||||
W -- yes --> P["prev = stack.pop(); ans[prev] = i - prev — today resolves that day"]
|
||||
P --> W
|
||||
W -- no --> A["stack.append(i)"]
|
||||
A --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+23
-2
@@ -1,13 +1,18 @@
|
||||
---
|
||||
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"
|
||||
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`
|
||||
@@ -32,6 +37,22 @@ sidebar:
|
||||
- All the values of `position` are unique.
|
||||
- `0 < speed[i] <= 10^6`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["carFleet(target, position, speed)"]) --> O["zip and sort by position, descending — nearest the target first"]
|
||||
O --> I["stack = [] — arrival time of each fleet's leader"]
|
||||
I --> L{"more (pos, spd)?"}
|
||||
L -- no --> E(["return len(stack) — one entry per fleet"])
|
||||
L -- yes --> T["time = (target - pos) / spd"]
|
||||
T --> Q{"stack empty or time > stack[-1]?"}
|
||||
Q -- yes --> A["stack.append(time) — slower, so it starts a new fleet"]
|
||||
Q -- no --> B["discard — it catches the car ahead and joins that fleet"]
|
||||
A --> L
|
||||
B --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -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
|
||||
---
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Monotonic Stack", order: 7, icon: "trending-up", display: "group" });
|
||||
+16
-2
@@ -1,13 +1,16 @@
|
||||
---
|
||||
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"
|
||||
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.)
|
||||
@@ -27,6 +30,17 @@ Can you solve the problem in O(1) extra space complexity? (The output array does
|
||||
- `-30 <= nums[i] <= 30`
|
||||
- The input is generated such that `answer[i]` is guaranteed to fit in a 32-bit integer.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["productExceptSelf(nums)"]) --> I["answer = ones(n), rightArr = ones(n)"]
|
||||
I --> P1["Pass 1 — left to right: answer[i] = nums[i-1] * answer[i-1]"]
|
||||
P1 --> P2["Pass 2 — right to left: rightArr[i] = nums[i+1] * rightArr[i+1]"]
|
||||
P2 --> P3["Pass 3: answer[i] *= rightArr[i]"]
|
||||
P3 --> E(["return answer — no division needed"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
+21
-2
@@ -1,13 +1,17 @@
|
||||
---
|
||||
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"
|
||||
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]`
|
||||
@@ -20,6 +24,21 @@ sidebar:
|
||||
- `0 <= left <= right < nums.length`
|
||||
- At most `10^4` calls will be made to `sumRange`.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph build["constructor(nums) — O(n) once"]
|
||||
A["prefix = [0]"] --> B{"more n in nums?"}
|
||||
B -- yes --> C["prefix.push(last + n)"]
|
||||
C --> B
|
||||
end
|
||||
build --> Q
|
||||
subgraph Q["sumRange(left, right) — O(1) per call"]
|
||||
D(["return prefix[right + 1] - prefix[left]"])
|
||||
end
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
+21
-2
@@ -1,13 +1,17 @@
|
||||
---
|
||||
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"
|
||||
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`
|
||||
@@ -22,6 +26,21 @@ sidebar:
|
||||
- `-1000 <= nums[i] <= 1000`
|
||||
- `-10^7 <= k <= 10^7`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["subarraySum(nums, k)"]) --> I["map = {0: 1} — the empty prefix; sum = 0, count = 0"]
|
||||
I --> L{"more n in nums?"}
|
||||
L -- no --> E(["return count"])
|
||||
L -- yes --> A["sum += n"]
|
||||
A --> Q{"map has sum - k?"}
|
||||
Q -- yes --> C["count += map.get(sum - k) — every earlier prefix that closes a window"]
|
||||
Q -- no --> W["map.set(sum, count of sum + 1)"]
|
||||
C --> W
|
||||
W --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Prefix Sum
|
||||
description: 'A running total is stored at each index, so the sum of any range is one subtraction.'
|
||||
hidden: true
|
||||
---
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Prefix Sum", order: 2, icon: "sigma", display: "group" });
|
||||
+23
-2
@@ -1,13 +1,16 @@
|
||||
---
|
||||
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"
|
||||
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`
|
||||
@@ -23,6 +26,24 @@ sidebar:
|
||||
- `1 <= prices.length <= 10^5`
|
||||
- `0 <= prices[i] <= 10^4`
|
||||
|
||||
## 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`, and `left` holds a price rather than an index, so `prices[left]` indexes by value.
|
||||
::::
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["maxProfit(prices)"]) --> I["left = min(prices)"]
|
||||
I --> F{"more right in range(len(prices))?"}
|
||||
F -- no --> E(["return ans"])
|
||||
F -- yes --> W{"curr > left?"}
|
||||
W -- yes --> P["curr -= prices[left]; left += 1"]
|
||||
P --> W
|
||||
W -- no --> M["ans = max(ans, curr)"]
|
||||
M --> F
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+21
-2
@@ -1,13 +1,17 @@
|
||||
---
|
||||
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"
|
||||
sidebar:
|
||||
label: 'Longest Substring Without Repeating Characters'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Sliding Window</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "abcabcbb"`
|
||||
- Output: `3`
|
||||
@@ -28,6 +32,21 @@ sidebar:
|
||||
- `0 <= s.length <= 10^5`
|
||||
- `s` consists of English letters, digits, symbols and spaces.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["lengthOfLongestSubstring(s)"]) --> I["left = 0, ans = 0, window = set()"]
|
||||
I --> L{"more (right, c) in s?"}
|
||||
L -- no --> E(["return ans"])
|
||||
L -- yes --> W{"c already in window?"}
|
||||
W -- yes --> P["window.remove(s[left]); left += 1 — shrink until c is free"]
|
||||
P --> W
|
||||
W -- no --> A["window.add(c)"]
|
||||
A --> M["ans = max(ans, right - left + 1)"]
|
||||
M --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+21
-2
@@ -1,13 +1,17 @@
|
||||
---
|
||||
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"
|
||||
sidebar:
|
||||
label: 'Longest Repeating Character Replacement'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Sliding Window</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "ABAB", k = 2`
|
||||
- Output: `4`
|
||||
@@ -24,6 +28,21 @@ sidebar:
|
||||
- `s` consists of only uppercase English letters.
|
||||
- `0 <= k <= s.length`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["characterReplacement(s, k)"]) --> I["count = {}, res = 0, l = 0, maxF = 0"]
|
||||
I --> L{"more (r, c) in s?"}
|
||||
L -- no --> E(["return res"])
|
||||
L -- yes --> A["count[c] += 1; maxF = max(maxF, count[c])"]
|
||||
A --> W{"(r - l + 1) - maxF > k? — too many chars to replace"}
|
||||
W -- yes --> P["count[s[l]] -= 1; l += 1 — shrink from the left"]
|
||||
P --> W
|
||||
W -- no --> M["res = max(res, r - l + 1)"]
|
||||
M --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+25
-2
@@ -1,13 +1,18 @@
|
||||
---
|
||||
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"
|
||||
sidebar:
|
||||
label: 'Permutation in String'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Sliding Window</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s1 = "ab", s2 = "eidbaooo"`
|
||||
- Output: `true`
|
||||
@@ -22,6 +27,24 @@ sidebar:
|
||||
- `1 <= s1.length, s2.length <= 10^4`
|
||||
- `s1` and `s2` consist of lowercase English letters.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["checkInclusion(s1, s2)"]) --> G{"k = len(s1) > len(s2)?"}
|
||||
G -- yes --> X(["return False"])
|
||||
G -- no --> I["need = Counter(s1), window = Counter()"]
|
||||
I --> L{"more (right, c) in s2?"}
|
||||
L -- no --> E(["return False"])
|
||||
L -- yes --> A["window[c] += 1"]
|
||||
A --> B{"right >= k? — window is now longer than k"}
|
||||
B -- yes --> C["drop s2[right - k] from window, deleting the key at 0"]
|
||||
B -- no --> Q
|
||||
C --> Q{"window == need?"}
|
||||
Q -- yes --> R(["return True"])
|
||||
Q -- no --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+4
-3
@@ -1,6 +1,7 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
:::warning
|
||||
@@ -54,7 +55,7 @@ Without an invariant, shrinking the window becomes arbitrary. You no longer know
|
||||
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
|
||||
|
||||
@@ -62,7 +63,7 @@ The window size never changes. The main task is updating the window state effici
|
||||
Here the window expands until invalid, then shrinks until valid again.
|
||||
:::
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
### 3. Frequency-tracking windows
|
||||
@@ -71,7 +72,7 @@ Here the window expands until invalid, then shrinks until valid again.
|
||||
The difficulty comes from keeping the bookkeeping correct as characters enter and leave.
|
||||
:::
|
||||
|
||||

|
||||

|
||||
|
||||
## How Sliding Window Differs From Other Two-Pointer Families
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Sliding Window", order: 4, icon: "app-window", display: "group" });
|
||||
+21
-2
@@ -1,13 +1,17 @@
|
||||
---
|
||||
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"
|
||||
sidebar:
|
||||
label: 'Evaluate Reverse Polish Notation'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Stack</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `tokens = ["2","1","+","3","*"]`
|
||||
- Output: `9`
|
||||
@@ -28,6 +32,21 @@ sidebar:
|
||||
- `1 <= tokens.length <= 10^4`
|
||||
- `tokens[i]` is either an operator: "+", "-", "*", or "/", or an integer in the range [-200, 200].
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["evalRPN(tokens)"]) --> I["stack = [], operations = + - * /"]
|
||||
I --> L{"more token c?"}
|
||||
L -- no --> E(["return stack[0]"])
|
||||
L -- yes --> Q{"c is an operator?"}
|
||||
Q -- no --> N["stack.append(int(c))"]
|
||||
N --> L
|
||||
Q -- yes --> P["y = stack.pop(), x = stack.pop() — order matters"]
|
||||
P --> C["stack.append(operations[c](x, y))"]
|
||||
C --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+27
-2
@@ -1,13 +1,16 @@
|
||||
---
|
||||
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"
|
||||
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.
|
||||
::::
|
||||
@@ -23,6 +26,28 @@ You must implement a solution with O(1) time complexity for each function.
|
||||
- Methods pop, top and getMin operations will always be called on non-empty stacks.
|
||||
- At most 3 * 10^4 calls will be made to push, pop, top, and getMin.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph state["two parallel stacks — same depth, always"]
|
||||
A["stack — the values"]
|
||||
B["minStack — the minimum as of that depth"]
|
||||
end
|
||||
state --> P
|
||||
subgraph P["push(value)"]
|
||||
P1["stack.append(value)"] --> P2["minStack.append(min(value, minStack[-1]))"]
|
||||
end
|
||||
state --> O
|
||||
subgraph O["pop()"]
|
||||
O1["pop both stacks together"]
|
||||
end
|
||||
state --> R
|
||||
subgraph R["top() / getMin() — O(1)"]
|
||||
R1(["stack[-1] / minStack[-1]"])
|
||||
end
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+24
-2
@@ -1,13 +1,17 @@
|
||||
---
|
||||
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"
|
||||
sidebar:
|
||||
label: 'Valid Parentheses'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Stack</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "()"`
|
||||
- Output: `true`
|
||||
@@ -33,6 +37,24 @@ sidebar:
|
||||
- `1 <= s.length <= 10^4`
|
||||
- `s` consists of parentheses only '()[]{}'.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["isValid(s)"]) --> I["stack = [], pairs = closer to opener"]
|
||||
I --> L{"more char c?"}
|
||||
L -- no --> Z{"stack empty?"}
|
||||
Z -- yes --> T(["return True"])
|
||||
Z -- no --> U(["return False — unclosed openers left"])
|
||||
L -- yes --> Q{"c is a closer?"}
|
||||
Q -- no --> A["stack.append(c) — it is an opener"]
|
||||
A --> L
|
||||
Q -- yes --> M{"stack non-empty and pairs[c] == stack[-1]?"}
|
||||
M -- yes --> P["stack.pop() — matched"]
|
||||
P --> L
|
||||
M -- no --> F(["return False — mismatch"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -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
|
||||
---
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Stack", order: 6, icon: "layers", display: "group" });
|
||||
+22
-2
@@ -1,13 +1,17 @@
|
||||
---
|
||||
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"
|
||||
sidebar:
|
||||
label: 'Container With Most Water'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Two Pointers</Badge>
|
||||
|
||||
::::warning
|
||||
Notice that you may not slant the container.
|
||||
::::
|
||||
@@ -27,6 +31,22 @@ Notice that you may not slant the container.
|
||||
- `2 <= n <= 10^5`
|
||||
- `0 <= height[i] <= 10^4`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["maxArea(height)"]) --> I["res = 0, left = 0, right = len - 1"]
|
||||
I --> W{"left < right?"}
|
||||
W -- no --> E(["return res"])
|
||||
W -- yes --> A["area = (right - left) * min(height[left], height[right])"]
|
||||
A --> M["res = max(res, area)"]
|
||||
M --> C{"height[left] < height[right]?"}
|
||||
C -- yes --> L["left += 1 — drop the shorter wall"]
|
||||
C -- no --> R["right -= 1 — drop the shorter wall"]
|
||||
L --> W
|
||||
R --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+19
-2
@@ -1,13 +1,16 @@
|
||||
---
|
||||
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"
|
||||
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`
|
||||
@@ -28,6 +31,20 @@ sidebar:
|
||||
- `1 <= s.length <= 2 * 10^5`
|
||||
- `s` consists only of printable ASCII characters.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["isPalindrome(s)"]) --> N["normal = s stripped of non-alphanumerics, lowercased"]
|
||||
N --> I["i = 0, j = normal.length - 1"]
|
||||
I --> W{"i < j?"}
|
||||
W -- no --> E(["return true — the pointers met in the middle"])
|
||||
W -- yes --> Q{"normal[i] != normal[j]?"}
|
||||
Q -- yes --> F(["return false"])
|
||||
Q -- no --> A["i++; j-- — step both inward"]
|
||||
A --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
+28
-2
@@ -1,13 +1,17 @@
|
||||
---
|
||||
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"
|
||||
sidebar:
|
||||
label: '3Sum'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Two Pointers</Badge>
|
||||
|
||||
::::warning
|
||||
Notice that the solution set must not contain duplicate triplets.
|
||||
::::
|
||||
@@ -32,6 +36,28 @@ Notice that the solution set must not contain duplicate triplets.
|
||||
- `3 <= nums.length <= 3000`
|
||||
- `-10^5 <= nums[i] <= 10^5`
|
||||
|
||||
## Approach
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+22
-2
@@ -1,13 +1,17 @@
|
||||
---
|
||||
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"
|
||||
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.
|
||||
::::
|
||||
@@ -35,6 +39,22 @@ Your solution must use only constant extra space.
|
||||
- `-1000 <= target <= 1000`
|
||||
- The tests are generated such that there is exactly one solution.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["twoSum(numbers, target)"]) --> I["i = 0, j = len - 1"]
|
||||
I --> W{"i < j?"}
|
||||
W -- no --> E(["return []"])
|
||||
W -- yes --> C["c = numbers[i] + numbers[j]"]
|
||||
C --> Q{"c vs target"}
|
||||
Q -- equal --> R(["return [i+1, j+1] — 1-indexed"])
|
||||
Q -- "c < target" --> L["i += 1 — grow the sum"]
|
||||
L --> W
|
||||
Q -- "c > target" --> H["j -= 1 — shrink the sum"]
|
||||
H --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+17
-2
@@ -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.
|
||||
:::
|
||||
@@ -25,6 +28,18 @@ You must do this by modifying the input array in-place with O(1) extra memory.
|
||||
- `1 <= s.length <= 10^5`
|
||||
- `s[i]` is a printable ascii character.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["reverseString(s)"]) --> I["i = 0, j = s.length - 1"]
|
||||
I --> W{"i < j?"}
|
||||
W -- no --> E(["done — s was reversed in place, nothing returned"])
|
||||
W -- yes --> A["swap s[i] and s[j]"]
|
||||
A --> B["i++; j--"]
|
||||
B --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
+22
-2
@@ -1,13 +1,17 @@
|
||||
---
|
||||
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"
|
||||
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?
|
||||
::::
|
||||
@@ -26,6 +30,22 @@ Suppose there are lots of incoming s, say s1, s2, ..., sk where k >= 10^9, and y
|
||||
- `0 <= t.length <= 10^4`
|
||||
- `s` and `t` consist only of lowercase English letters.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["isSubsequence(s, t)"]) --> G{"len(s) > len(t)?"}
|
||||
G -- yes --> X(["return False"])
|
||||
G -- no --> I["i = 0 into s, j = 0 into t"]
|
||||
I --> W{"i < len(s) and j < len(t)?"}
|
||||
W -- no --> E(["return i == len(s) — every char of s was matched"])
|
||||
W -- yes --> Q{"s[i] == t[j]?"}
|
||||
Q -- yes --> A["i += 1 — consume the match"]
|
||||
Q -- no --> B["j += 1 — always advance t"]
|
||||
A --> B
|
||||
B --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+21
-2
@@ -1,13 +1,17 @@
|
||||
---
|
||||
title: '977. Squares of a Sorted Array'
|
||||
description: Given an integer array nums sorted in non-decreasing order, return an array of the squares of each number sorted in non-decreasing order
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Two Pointers", slug: "two-pointers" }
|
||||
- { name: "Sorting", slug: "sorting" }
|
||||
video: "FPCZsG_AkUg"
|
||||
sidebar:
|
||||
label: 'Squares of a Sorted Array'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Two Pointers</Badge>
|
||||
|
||||
::::warning
|
||||
Squaring each element and sorting the new array is very trivial, could you find an O(n) solution using a different approach?
|
||||
::::
|
||||
@@ -27,6 +31,21 @@ Squaring each element and sorting the new array is very trivial, could you find
|
||||
- `-10^4 <= nums[i] <= 10^4`
|
||||
- `nums` is sorted in non-decreasing order.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["sortedSquares(nums)"]) --> I["ans = Array(n), left = 0, right = n - 1"]
|
||||
I --> L{"more i from n-1 down to 0?"}
|
||||
L -- no --> E(["return ans"])
|
||||
L -- yes --> C{"abs(nums[left]) < abs(nums[right])?"}
|
||||
C -- yes --> R["square = nums[right]; right--"]
|
||||
C -- no --> F["square = nums[left]; left++"]
|
||||
R --> W["ans[i] = square * square — fill from the back, largest first"]
|
||||
F --> W
|
||||
W --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Two Pointers
|
||||
description: 'Two indices move through the data from different positions and meet at the answer.'
|
||||
hidden: true
|
||||
---
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Two Pointers", order: 3, icon: "arrow-left-right", display: "group" });
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
title: '146. LRU Cache'
|
||||
description: Design a data structure that follows the constraints of a Least Recently Used (LRU) cache
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "Linked List", slug: "linked-list" }
|
||||
- { name: "Design", slug: "design" }
|
||||
- { name: "Doubly-Linked List", slug: "doubly-linked-list" }
|
||||
video: "7ABFKPK2hD4"
|
||||
sidebar:
|
||||
label: 'LRU Cache'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: ``
|
||||
- Output: ``
|
||||
- Explanation: Input ["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"] `[[2]`, [1, 1], [2, 2], `[1]`, [3, 3], `[2]`, [4, 4], `[1]`, `[3]`, [4]] Output [null, null, null, `1`, null, `-1`, null, `-1`, `3`, 4] Explanation LRUCache lRUCache = new LRUCache(2); lRUCache.put(1, `1`); // cache is {1=1} lRUCache.put(2, `2`); // cache is {1=1, 2=2} lRUCache.get(1); // return `1` lRUCache.put(3, `3`); // LRU `key` was `2`, evicts `key 2`, cache is {1=1, 3=3} lRUCache.get(2); // returns `-1` (not found) lRUCache.put(4, `4`); // LRU `key` was `1`, evicts `key 1`, cache is {4=4, 3=3} lRUCache.get(1); // return `-1` (not found) lRUCache.get(3); // return `3` lRUCache.get(4); // return `4`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= capacity <= 3000`
|
||||
- `0 <= key <= 10^4`
|
||||
- `0 <= value <= 10^5`
|
||||
- At most 2 * 10^5 calls will be made to get and put.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph state["state — dict for O(1) lookup, doubly linked list for O(1) reorder"]
|
||||
H["head — least recent"] --- N["... nodes ..."] --- T["tail — most recent"]
|
||||
end
|
||||
subgraph get["get(key)"]
|
||||
G1{"key in cache?"} -- no --> G2(["return -1"])
|
||||
G1 -- yes --> G3["_remove_node then _add_node — move it beside the tail"]
|
||||
G3 --> G4(["return node.value"])
|
||||
end
|
||||
subgraph put["put(key, value)"]
|
||||
P1{"key in cache?"} -- yes --> P2["update value, _remove_node then _add_node"]
|
||||
P1 -- no --> P3["new Node, cache[key] = node, _add_node"]
|
||||
P3 --> P4{"len(cache) > capacity?"}
|
||||
P4 -- yes --> P5["evict head.next — the least recent — and del cache[lru.key]"]
|
||||
P4 -- no --> P6(["done"])
|
||||
P2 --> P6
|
||||
P5 --> P6
|
||||
end
|
||||
state --> get
|
||||
state --> put
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Node:
|
||||
def __init__(self, key=0, value=0):
|
||||
self.key = key
|
||||
self.value = value
|
||||
self.prev = None
|
||||
self.next = None
|
||||
|
||||
|
||||
class LRUCache:
|
||||
def __init__(self, capacity: int):
|
||||
self.capacity = capacity
|
||||
self.cache = {}
|
||||
self.head = Node()
|
||||
self.tail = Node()
|
||||
self.head.next = self.tail # pyright: ignore[reportAttributeAccessIssue]
|
||||
self.tail.prev = self.head # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
def _remove_node(self, node):
|
||||
prev_node = node.prev
|
||||
next_node = node.next
|
||||
prev_node.next = next_node
|
||||
next_node.prev = prev_node
|
||||
|
||||
def _add_node(self, node):
|
||||
# Insert just before the tail (most recent at end)
|
||||
node.next = self.tail
|
||||
node.prev = self.tail.prev
|
||||
self.tail.prev.next = node # pyright: ignore[reportAttributeAccessIssue]
|
||||
self.tail.prev = node
|
||||
|
||||
def get(self, key: int) -> int:
|
||||
if key not in self.cache:
|
||||
return -1
|
||||
|
||||
node = self.cache[key]
|
||||
self._remove_node(node)
|
||||
self._add_node(node)
|
||||
return node.value
|
||||
|
||||
def put(self, key: int, value: int) -> None:
|
||||
if key in self.cache:
|
||||
node = self.cache[key]
|
||||
node.value = value
|
||||
self._remove_node(node)
|
||||
self._add_node(node)
|
||||
else:
|
||||
new_node = Node(key, value)
|
||||
self.cache[key] = new_node
|
||||
self._add_node(new_node)
|
||||
|
||||
if len(self.cache) > self.capacity:
|
||||
lru = self.head.next # Oldest node is next to the head
|
||||
self._remove_node(lru)
|
||||
del self.cache[lru.key] # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
|
||||
# Your LRUCache object will be instantiated and called as such:
|
||||
# obj = LRUCache(capacity)
|
||||
# param_1 = obj.get(key)
|
||||
# obj.put(key,value)
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: '2. Add Two Numbers'
|
||||
description: You are given two non-empty linked lists representing two non-negative integers. The digits are stored in reverse order, and each of their nodes contains a single digit. Add the two numbers and return the sum as a linked list
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Linked List", slug: "linked-list" }
|
||||
- { name: "Math", slug: "math" }
|
||||
- { name: "Recursion", slug: "recursion" }
|
||||
video: "wgFPrzTjm7s"
|
||||
sidebar:
|
||||
label: 'Add Two Numbers'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `l1 = [2,4,3], l2 = [5,6,4]`
|
||||
- Output: `[7,0,8]`
|
||||
- Explanation: `342 + 465 = 807`.
|
||||
|
||||
### Example 2:
|
||||
- Input: `l1 = [0], l2 = [0]`
|
||||
- Output: `[0]`
|
||||
|
||||
### Example 3:
|
||||
- Input: `l1 = [9,9,9,9,9,9,9], l2 = [9,9,9,9]`
|
||||
- Output: `[8,9,9,9,0,0,0,1]`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- The number of nodes in each linked list is in the range [1, 100].
|
||||
- `0 <= Node.val <= 9`
|
||||
- It is guaranteed that the list represents a number that does not have leading zeros.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["addTwoNumbers(l1, l2)"]) --> I["dummy = ListNode(), carry = 0, curr = dummy — dummy.next will be the head"]
|
||||
I --> W{"l1 or l2 or carry?"}
|
||||
W -- no --> E(["return dummy.next"])
|
||||
W -- yes --> A["s = (l1.val if l1 else 0) + (l2.val if l2 else 0) + carry — a missing digit counts as 0"]
|
||||
A --> D["carry, val = divmod(s, 10)"]
|
||||
D --> N["curr.next = ListNode(val), curr = curr.next"]
|
||||
N --> V["l1 = l1.next if l1 else None, l2 = l2.next if l2 else None"]
|
||||
V --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
# Definition for singly-linked list.
|
||||
# class ListNode:
|
||||
# def __init__(self, val=0, next=None):
|
||||
# self.val = val
|
||||
# self.next = next
|
||||
class Solution:
|
||||
def addTwoNumbers(
|
||||
self, l1: Optional[ListNode], l2: Optional[ListNode]
|
||||
) -> Optional[ListNode]:
|
||||
|
||||
dummy = ListNode()
|
||||
carry = 0
|
||||
curr = dummy
|
||||
while l1 or l2 or carry:
|
||||
s = (l1.val if l1 else 0) + (l2.val if l2 else 0) + carry
|
||||
carry, val = divmod(s, 10)
|
||||
|
||||
curr.next = ListNode(val)
|
||||
curr = curr.next
|
||||
|
||||
l1 = l1.next if l1 else None
|
||||
l2 = l2.next if l2 else None
|
||||
|
||||
return dummy.next
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Hybrid Structures
|
||||
description: 'Two structures are combined, such as a hash map and a linked list, to get the best properties of each.'
|
||||
hidden: true
|
||||
---
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Hybrid Structures", order: 9, icon: "combine", display: "group" });
|
||||
+21
-2
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: '141. Linked List Cycle'
|
||||
description: Given head, the head of a linked list, determine if the linked list has a cycle in it
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "Linked List", slug: "linked-list" }
|
||||
- { name: "Two Pointers", slug: "two-pointers" }
|
||||
- { name: "Floyd's Cycle Finding Algorithm", slug: "floyds-cycle-finding-algorithm" }
|
||||
video: "gBTe7lFR3vc"
|
||||
sidebar:
|
||||
label: 'Linked List Cycle'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Linked List</Badge>
|
||||
|
||||
::::warning
|
||||
Can you solve it using O(1) (i.e. constant) memory?
|
||||
::::
|
||||
@@ -33,6 +38,20 @@ Can you solve it using O(1) (i.e. constant) memory?
|
||||
- `-10^5 <= Node.val <= 10^5`
|
||||
- pos is -1 or a valid index in the linked-list.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["hasCycle(head)"]) --> I["fast = slow = head"]
|
||||
I --> W{"fast and fast.next?"}
|
||||
W -- no --> E(["return False — ran off the end, no cycle"])
|
||||
W -- yes --> A["fast = fast.next.next — two steps"]
|
||||
A --> B["slow = slow.next — one step"]
|
||||
B --> Q{"fast is slow?"}
|
||||
Q -- yes --> R(["return True — the gap closed, so it loops"])
|
||||
Q -- no --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+18
-2
@@ -1,13 +1,16 @@
|
||||
---
|
||||
title: '206. Reverse Linked List'
|
||||
description: Given the head of a singly linked list, reverse the list, and return the reversed list
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Linked List", slug: "linked-list" }
|
||||
- { name: "Recursion", slug: "recursion" }
|
||||
video: "G0_I-ZF0S38"
|
||||
sidebar:
|
||||
label: 'Reverse Linked List'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Linked List</Badge>
|
||||
|
||||
::::warning
|
||||
A linked list can be reversed either iteratively or recursively. Could you implement both?
|
||||
::::
|
||||
@@ -29,6 +32,19 @@ A linked list can be reversed either iteratively or recursively. Could you imple
|
||||
- The number of nodes in the list is the range [0, 5000].
|
||||
- `-5000 <= Node.val <= 5000`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["reverseList(head)"]) --> I["prev = None, curr = head"]
|
||||
I --> W{"curr?"}
|
||||
W -- no --> E(["return prev — the old tail is the new head"])
|
||||
W -- yes --> A["next_ = curr.next — save it before overwriting"]
|
||||
A --> B["curr.next = prev — flip the link"]
|
||||
B --> C["prev = curr; curr = next_ — step forward"]
|
||||
C --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
+22
-2
@@ -1,13 +1,16 @@
|
||||
---
|
||||
title: '21. Merge Two Sorted Lists'
|
||||
description: You are given the heads of two sorted linked lists list1 and list2
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Linked List", slug: "linked-list" }
|
||||
- { name: "Recursion", slug: "recursion" }
|
||||
video: "XIdigk956u0"
|
||||
sidebar:
|
||||
label: 'Merge Two Sorted Lists'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Linked List</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `list1 = [1,2,4], list2 = [1,3,4]`
|
||||
- Output: `[1,1,2,3,4,4]`
|
||||
@@ -26,6 +29,23 @@ sidebar:
|
||||
- `-100 <= Node.val <= 100`
|
||||
- Both list1 and list2 are sorted in non-decreasing order.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["mergeTwoLists(list1, list2)"]) --> G1{"list1 empty?"}
|
||||
G1 -- yes --> X1(["return list2"])
|
||||
G1 -- no --> G2{"list2 empty?"}
|
||||
G2 -- yes --> X2(["return list1"])
|
||||
G2 -- no --> H["head = the smaller first node; advance that list"]
|
||||
H --> C["current = head"]
|
||||
C --> W{"list1 and list2 both non-empty?"}
|
||||
W -- yes --> P["current.next = the smaller node; advance that list; current = current.next"]
|
||||
P --> W
|
||||
W -- no --> T["current.next = list1 or list2 — append the leftover tail"]
|
||||
T --> E(["return head"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
Loaded 100 of 318 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user