mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
Compare commits
156
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
51ce47a49a | ||
|
|
4650b33364 | ||
|
|
804ad1379e | ||
|
|
0d845838c2 | ||
|
|
b7041b1440 | ||
|
|
5655cede21 | ||
|
|
025ce82041 | ||
|
|
a3657732f6 | ||
|
|
1622bf39b7 | ||
|
|
5ea123e46d | ||
|
|
38fbb18f32 | ||
|
|
7e46fceb1c | ||
|
|
b21ab75e0e | ||
|
|
d784575c87 | ||
|
|
70f88122e0 | ||
|
|
47c2b20646 | ||
|
|
e8b0d4dc30 | ||
|
|
bd9afc6ea1 | ||
|
|
8396c4a7ed | ||
|
|
bf4a1f4c7a | ||
|
|
e23a68c91c |
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`
|
`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
|
## 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.
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Pre-commit: the docs pages under apps/docs/content (and the `/<num>` short
|
||||||
|
# links in apps/docs/redirects.ts) are generated from work/1 by `bun run sync`,
|
||||||
|
# and deploy.yml publishes whatever main holds. Regenerate them now and stage
|
||||||
|
# what sync changed, so a solution, its page and its redirect land in the same
|
||||||
|
# commit and a stale `## Solution` never reaches main.
|
||||||
|
#
|
||||||
|
# Sync reads the working tree, not the index, so the pages it writes only
|
||||||
|
# match this commit when work/1 has nothing unstaged: an untracked or
|
||||||
|
# half-staged solution would ship a page whose source is not in the commit.
|
||||||
|
# In that case refuse and say what to stage.
|
||||||
|
#
|
||||||
|
# Only paths that became dirty during sync are staged. Anything already dirty
|
||||||
|
# beforehand (hand-written `## Approach` prose in progress, a new index.mdx)
|
||||||
|
# stays where the author left it.
|
||||||
|
#
|
||||||
|
# Bypass for a deliberate partial commit: git commit --no-verify
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
unstaged=$(git status --porcelain -- work/1 | grep -v '^[MADR] ' || true)
|
||||||
|
if [ -n "$unstaged" ]; then
|
||||||
|
printf 'pre-commit: work/1 has unstaged changes; stage or stash them so the generated pages match this commit:\n%s\n' "$unstaged" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
before=$(mktemp)
|
||||||
|
trap 'rm -f "$before"' EXIT
|
||||||
|
git status --porcelain -- apps/docs/content apps/docs/redirects.ts | sort > "$before"
|
||||||
|
report=$(bun run --silent sync)
|
||||||
|
generated=$(git status --porcelain -- apps/docs/content apps/docs/redirects.ts | sort | comm -13 "$before" - | cut -c4-)
|
||||||
|
if [ -n "$generated" ]; then
|
||||||
|
printf '%s\n' "$report" | grep -v 'skipped (in sync)' >&2
|
||||||
|
printf '%s\n' "$generated" | xargs git add --
|
||||||
|
fi
|
||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
dry_run:
|
dry_run:
|
||||||
description: Report matches without closing anything
|
description: Report matches and would-be releases without closing or releasing anything
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
@@ -61,3 +61,39 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
bun apps/cli/sync-project-fields.ts
|
bun apps/cli/sync-project-fields.ts
|
||||||
|
|
||||||
|
# One timever release per finished topic (every core sub-issue closed),
|
||||||
|
# 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
|
- uses: oven-sh/setup-bun@v2 # skip if you use npm/pnpm
|
||||||
|
|
||||||
- run: bun install --frozen-lockfile
|
- run: bun install --frozen-lockfile
|
||||||
|
# blume.config.ts pulls the /changelog pages from this repo's GitHub
|
||||||
|
# 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
|
- run: bun run build
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: actions/upload-pages-artifact@v3
|
- uses: actions/upload-pages-artifact@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: Spaced Repetition
|
||||||
|
|
||||||
|
# Opens (or refreshes) today's `Spaced Repetition — <Month D, YYYY>` issue from
|
||||||
|
# whatever the SRS ladder has due (`POST /admin/due`, the same rows the 8 AM
|
||||||
|
# digest mails — so the two cannot disagree). The script stores nothing and
|
||||||
|
# schedules nothing: it asks D1 what is standing today, keyed by the ET date in
|
||||||
|
# the title, so a re-run rewrites one body instead of stacking duplicates.
|
||||||
|
# Sunday is the rest day and the ladder never books it, so nothing is written.
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# 10:00 UTC = 06:00 ET while the campaign runs (EDT through Nov 1), 05:00 ET
|
||||||
|
# after. No DST guard and no second cron: the issue is keyed by ET calendar
|
||||||
|
# date, not by the hour it was created, so the drift is cosmetic.
|
||||||
|
- cron: "0 10 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dry_run:
|
||||||
|
description: Print the issue body without creating or updating anything
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
date:
|
||||||
|
description: ET date to treat as today (YYYY-MM-DD) — backfill a missed day
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: spaced-repetition
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
drill:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
# No install step: the script only uses Bun builtins + fetch.
|
||||||
|
#
|
||||||
|
# SRS_ADMIN_KEY reads today's due list from the Worker. Unset is fatal
|
||||||
|
# here, unlike in close-solved: without D1 there is no schedule to render,
|
||||||
|
# and inventing one from issue close dates is exactly the second source of
|
||||||
|
# truth this replaced.
|
||||||
|
- run: bun apps/cli/spaced-repetition.ts
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
DRY_RUN: ${{ inputs.dry_run }}
|
||||||
|
SRS_TODAY: ${{ inputs.date }}
|
||||||
|
SRS_ADMIN_KEY: ${{ secrets.SRS_ADMIN_KEY }}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
node_modules
|
node_modules
|
||||||
tmp/
|
tmp/
|
||||||
|
*.log
|
||||||
|
.crush
|
||||||
|
|
||||||
# output
|
# output
|
||||||
out
|
out
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ flowchart LR
|
|||||||
work -->|close-solved.yml| PI[problem issues]
|
work -->|close-solved.yml| PI[problem issues]
|
||||||
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
|
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
|
||||||
PI -->|close-topics.yml| TI[topic issues]
|
PI -->|close-topics.yml| TI[topic issues]
|
||||||
|
PI -->|close-solved.yml release job| Rel[timever release per finished topic]
|
||||||
GH[GitHub issues = catalog] -->|nightly reconcile| D1
|
GH[GitHub issues = catalog] -->|nightly reconcile| D1
|
||||||
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
|
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
|
||||||
Mail -->|GET /log| D1
|
Mail -->|GET /log| D1
|
||||||
@@ -21,50 +22,73 @@ flowchart LR
|
|||||||
D1 -->|best-effort mirror: Target Date only| Proj[GitHub Project fields]
|
D1 -->|best-effort mirror: Target Date only| Proj[GitHub Project fields]
|
||||||
GH -->|close-solved.yml sync-project-fields| Proj
|
GH -->|close-solved.yml sync-project-fields| Proj
|
||||||
D1 -->|Sat midnight ET| Review[Review — Week N issue]
|
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]
|
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.
|
- **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.
|
||||||
- **Solving has three doors, one write path.** A digest one-tap, a `/done` comment, and a solution landing in `work/` all end in `logAttempt()`. Closing an issue is *not* one of them: the catalog reconcile never reads issue state, so a `work/` push that skipped the email would otherwise leave `stage='new'` — invisible to the charts, the digest's solved ticks, and the drill/gate pools. Hence `/admin/solved` (`source='commit'`, first solve only, no index needed for idempotency: it refuses any problem past stage `new`). `close-topics` needs no such call — D1 stores no topic completion, only problem rows.
|
- **Solving has three doors, one write path.** A digest one-tap, a `/done` comment, and a solution landing in `work/` all end in `logAttempt()`. Closing an issue is *not* one of them: the catalog reconcile never reads issue state, so a `work/` push that skipped the email would otherwise leave `stage='new'` — invisible to the charts, the digest's solved ticks, and the drill/gate pools. Hence `/admin/solved`, which takes `{lc, bucket}` pairs (`source='commit'`): the bucket names the rung the file settles — `work/1` settles `new`, `work/3` settles `+3`, `work/7` settles `+7` — so a pushed re-solve advances the ladder instead of vanishing, and re-posting the whole implemented set writes nothing because the rung named has already been left behind. That stage check is the entire idempotency mechanism; no index, no dedupe table, no state file. `close-topics` needs no such call — D1 stores no topic completion, only problem rows.
|
||||||
|
- **The rest day is structural, not cosmetic.** Sunday is never booked: topics run Mon–Fri (`apps/api/data/schedule.json`), the gate is Saturday, and the ladder's windows are 3 and 7 precisely because those return a solve to a working day. Every scheduled date — ladder review, levelled overflow, deferred-Hard release, review-issue window — comes out of `workingDay()` in `apps/api/src/srs.ts`, which slides the one exception (a Thursday solve's `+3`) forward to Monday. Forward, never back: a review may slip later than its interval, never shorten it. Never mint a scheduled date with bare `addDays()`, and never "fix" a Sunday landing downstream — the digest's Sunday rest branch is a courtesy, not the mechanism. `apps/api/src/srs.test.ts` sweeps the campaign calendar to keep this honest.
|
||||||
|
- **One schedule, one answer to "what is open today".** D1's ladder IS the schedule; `dueToday()` (`POST /admin/due`) is its read side and `DUE_WHERE` is the single SQL definition of "due", shared by the digest, the review issue and the docs queue chart. `spaced-repetition.yml` renders that answer into today's `Spaced Repetition — <Month D, YYYY>` issue — grouped by rung, `WINDOW_CAP` (3) required per rung with the overflow listed as optional, keyed by the ET date in the title so a re-run rewrites one body. It schedules nothing, stores nothing, and writes nothing on Sunday. Without `SRS_ADMIN_KEY` it FAILS rather than inventing a second schedule from issue close dates — that reconstruction was the drift, and it is gone. NEVER add a JSON state file (`.github/problems.json` and friends) or a second "when is this due" rule: D1 answers it, with one writer (`logAttempt`) and git-free history in `attempts`.
|
||||||
|
- **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.
|
- **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.
|
- **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).
|
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
|
||||||
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
|
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
|
||||||
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
||||||
|
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first — Blume's changelog makes it the page's meta description — then closed on the finish day / earlier / open at release, with solution links at the tag). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs `/changelog` is these releases (`githubReleases` in `apps/docs/blume.config.ts`, fetched at build with `GITHUB_TOKEN`), dated by the timever tag, not `published_at` (which the API cannot backdate): `releasesDatedByTag` rewrites each timever release's `published_at` before Blume maps it, via `custom(githubReleasesSource(…))` because the config-level `githubReleases()` takes no `fetchImpl`. Changing the tag format breaks the changelog dates.
|
||||||
|
|
||||||
## Key Directories
|
## Key Directories
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `work/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/Easy/Array/1.two-sum.py`. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
||||||
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
|
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
|
||||||
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `islands/`, `public/`). `content/(<category>)/<num>-<slug>.mdx` generated by sync |
|
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/<num>-<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync: the phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment (`/tree-dfs/invert-binary-tree`), the `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order with each topic a collapsible group. A problem page's badge row (above the title) is rendered, not written: the `PageHeader` slot (`components/ProblemBadges.astro`, wired in `components.ts`) reads the page's `topics:` (LeetCode's topic tags, from sync) and `video:` (NeetCode YouTube id, from `bun run videos`) frontmatter — both declared via `frontmatter.extend` in `blume.config.ts`, hand-rolled Standard Schemas, no zod — and links a tag the campaign teaches to its topic landing page (accent), any other tag to its guide page `/guides/<slug>` (neutral; LeetCode's problem list if the guide is gone), and the video as a red `Explanation` badge. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), read by both that component and sync. `content/guides/<slug>.mdx` (the `Guides` header tab) is one page per tag outside that map: sync creates a placeholder when the file is missing or empty and never touches it again (hand-owned; a guide whose tag no page carries any more is left alone), with frontmatter `list:` — the tag's LeetCode problem list, rendered by the same slot as an orange `LeetCode list` badge; sync also writes `guides/meta.ts`. A hand-written `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is that topic's landing page: Blume ≥ 1.7.3 links the sidebar group row to it (`sliding-window/index.mdx` → `/sliding-window`) and the hidden flag drops the duplicate row. Two kinds of custom MDX component, both import-free on every page: `islands/<Name>.tsx` is hydrated React (auto-discovered; `ProgressDashboard`, `DependencySpine` — the README topic map as data, zoom/pan, nodes link to whatever route Blume's `/api/docs/navigation.json` reports for the topic folder, inert until a topic has pages), `components/<Name>.astro` registered in `components.ts` is static markup plus a custom element for behaviour, the way Blume's own `Frame` is built (`Carousel`/`CarouselImage`: `src` takes a URL, `/file.png`, or the page-relative `../../public/file.png`) |
|
||||||
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (day → topic issue, human-edited, bundled at deploy), `migrations/`, `scripts/import-srs.ts` |
|
| `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, 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`) |
|
| `.github/workflows/` | deploy (docs → Pages with `GITHUB_TOKEN` for the releases-backed changelog, Worker → Cloudflare, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
|
||||||
|
| `.githooks/` | Tracked git hooks; `bun install` points `core.hooksPath` here (root `prepare` script, no husky). `pre-commit` runs `bun run sync` and stages only what sync changed (pages, `apps/docs/redirects.ts`), so a solution, its page and its `/<num>` short link land in one commit; it refuses to run while `work/1` has anything unstaged (sync reads the tree, so the page would not match the commit), and leaves pre-existing dirt under `apps/docs/content` (in-progress `## Approach` prose) unstaged. `git commit --no-verify` for a deliberate partial commit |
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
All from the repo root (a Bun workspace over `apps/*`):
|
All from the repo root (a Bun workspace over `apps/*`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bun run pick # scaffold a solution via leetcode-cli (fuzzy picker; hides problems
|
bun run pick # scaffold a solution via leetcode-cli (fuzzy picker; Tab cycles the
|
||||||
# already under work/, caches index+descriptions in
|
# sections: new problems → work/1, then the owed 3-day and 7-day
|
||||||
|
# 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)
|
# ~/.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;
|
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)
|
# most recently tested first, stamped in the db on exit 0)
|
||||||
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
|
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
|
||||||
# hidden; last_submitted stamped in the db on exit 0)
|
# hidden; last_submitted stamped in the db on exit 0)
|
||||||
bun run sync # work/ → apps/docs/content/ pages
|
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 dev|build # Blume docs site (runs in apps/docs/)
|
||||||
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
|
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
|
||||||
bun run close-topics -- --dry-run
|
bun run close-topics -- --dry-run
|
||||||
|
bun run release-topics -- --dry-run # prints every would-be topic release body
|
||||||
# close-solved also pushes the solved set to the Worker; point it at a local
|
# close-solved also pushes the solved set to the Worker; point it at a local
|
||||||
# one and nothing production is touched:
|
# one and nothing production is touched:
|
||||||
SRS_API=http://localhost:8787 SRS_ADMIN_KEY=$LINK_KEY bun run close-solved -- --dry-run
|
SRS_API=http://localhost:8787 SRS_ADMIN_KEY=$LINK_KEY bun run close-solved -- --dry-run
|
||||||
# Project Set/Difficulty from the issue labels (needs a `project`-scoped token;
|
# Project Set/Difficulty from the issue labels (needs a `project`-scoped token;
|
||||||
# runs as the last step of close-solved.yml in CI):
|
# runs as the last step of close-solved.yml in CI):
|
||||||
GH_TOKEN=$PROJECT_PAT bun run sync-project-fields -- --dry-run
|
GH_TOKEN=$PROJECT_PAT bun run sync-project-fields -- --dry-run
|
||||||
bun run api:dev # wrangler dev on :8787 (local D1); api:test = DST guard tests
|
# today's review issue from the +3d/+7d closes (--date backfills a missed day;
|
||||||
|
# a Sunday date writes nothing — the rest day has no assignment):
|
||||||
|
bun run spaced-repetition -- --dry-run --date=2026-08-31
|
||||||
|
bun run api:dev # wrangler dev on :8787 (local D1)
|
||||||
|
bun run api:test # DST guards + the rest-day calendar sweep
|
||||||
bun run api:deploy # deploy the Worker by hand (CI also deploys on main pushes)
|
bun run api:deploy # deploy the Worker by hand (CI also deploys on main pushes)
|
||||||
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
||||||
"localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing
|
"localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing
|
||||||
@@ -78,24 +102,25 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
|||||||
- **Dry-run everything:** reconcilers take `--dry-run`/`DRY_RUN=1`; Worker admin routes take `?dry=1&force=1&date=` (the `SRS_TODAY` equivalent) and `wrangler dev` runs against local D1 — exercise logic there before touching production state.
|
- **Dry-run everything:** reconcilers take `--dry-run`/`DRY_RUN=1`; Worker admin routes take `?dry=1&force=1&date=` (the `SRS_TODAY` equivalent) and `wrangler dev` runs against local D1 — exercise logic there before touching production state.
|
||||||
- **Dates:** always ET calendar strings (`YYYY-MM-DD`), arithmetic anchored at noon UTC (`atNoon` in `apps/api/src/srs.ts`) to dodge DST. Never `new Date()` math directly.
|
- **Dates:** always ET calendar strings (`YYYY-MM-DD`), arithmetic anchored at noon UTC (`atNoon` in `apps/api/src/srs.ts`) to dodge DST. Never `new Date()` math directly.
|
||||||
- **Style:** section-divider comments (`// ── name ───`), file-top doc comments explaining the *why* and invariants, 2-space JSON with trailing newline, `Map` for dynamic keys / `Record` for static tables, no tiny one-expression wrapper functions.
|
- **Style:** section-divider comments (`// ── name ───`), file-top doc comments explaining the *why* and invariants, 2-space JSON with trailing newline, `Map` for dynamic keys / `Record` for static tables, no tiny one-expression wrapper functions.
|
||||||
- **Known intentional duplication:** `close-solved.ts` re-implements header stripping instead of importing from `sync.ts` because `sync.ts` runs its pipeline on import. Don't "deduplicate" it.
|
- **Known intentional duplication:** `sync.ts` keeps its own header split instead of sharing `apps/cli/source.ts`, because `sync.ts` runs its whole pipeline on import and so cannot be imported from. Don't "deduplicate" it. `close-solved.ts` and `work.ts` do share it — the stub test decides both "close the issue" and "owes a review", and the two must never disagree.
|
||||||
|
|
||||||
## Important Files
|
## Important Files
|
||||||
|
|
||||||
- `apps/api/src/srs.ts` — SRS domain: ladder (`new → +2 → +5 → +10 → retired`; fail resets to `+2`; first-ever log enters at `+2`), ET date math, 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 date, ≤ 3 per day, oldest first, instead of letting the due pile grow.
|
- `apps/api/src/srs.ts` — SRS domain: the ladder (`new → +3 → +7 → retired`; fail resets to `+3`; first-ever log enters at `+3`), `WINDOWS`/`STAGES`/`TEMPERATURES`/`DIFFICULTIES`/`SETS` (the one vocabulary shared by the charts, the `work/<n>` buckets, the review issues and the stats dashboard), ET date math, `workingDay()` (no scheduled date is ever a Sunday), `temperatureOf()` (days since a concept was last exercised → `hot`/`fresh`/`fading`/`cold`, bands derived from `WINDOWS` so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
|
||||||
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`.
|
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`.
|
||||||
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/56) LeetCode Daily Digest`.
|
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/56) LeetCode Daily Digest`.
|
||||||
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
|
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
|
||||||
- `apps/cli/sync.ts` — work→docs contract: 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/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) is filled once — on create, or on any run that finds it missing — never refreshed (delete the key to refetch; offline, the page is written without it and the next sync retries). `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`, both `meta.ts` script-owned and rewritten every run (phase: `Phase N: Name`, `order: N`; topic: label, curriculum order, `TOPIC_ICONS` icon, `display: "group"`). The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
|
||||||
|
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
|
||||||
|
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, per-topic temperature, gates, the 14-day due queue, and `heat` (every campaign day, zeros and future days included — the docs page windows the tail off it rather than asking a second question). Two readers consume it, the docs island and `bun run stats`, so its shape is both their contract: change all three together. `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
|
||||||
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the docs `/progress` page fetches `/api/stats` client-side (`apps/docs/islands/ProgressDashboard.tsx`).
|
- `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/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 and the `srs-*` Actions are RETIRED — do not resurrect; `apps/api/scripts/import-srs.ts` documents the migration.
|
- Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
|
||||||
|
|
||||||
## Runtime/Tooling Preferences
|
## Runtime/Tooling Preferences
|
||||||
|
|
||||||
- **Bun only**: run scripts with `bun apps/cli/<name>.ts` (or the root `bun run` aliases), install with `bun install --frozen-lockfile`. One workspace (`apps/*`), one root `bun.lock`. Node 22 appears only in `deploy.yml` because Blume requires it.
|
- **Bun only**: run scripts with `bun apps/cli/<name>.ts` (or the root `bun run` aliases), install with `bun install --frozen-lockfile`. One workspace (`apps/*`), one root `bun.lock`. Node 22 appears only in `deploy.yml` because Blume requires it.
|
||||||
- **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, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. The **one** exception is the digest email: `apps/api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. Do not extend this exception to any other module.
|
||||||
- **Near-zero runtime npm dependencies** — SVG, PNG, 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.
|
|
||||||
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
|
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
|
||||||
|
|
||||||
## Testing & QA
|
## Testing & QA
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://x.com/basedprad"><img alt="follow" src="https://shieldcn.dev/x/follow/basedprad.svg" /></a>
|
<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://leetcode.com/progress"><img alt="Custom badge" src="https://shieldcn.dev/badge/view-@prdlk.svg?logo=leetcode" /></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://lc.prd.lk"><img alt="badge" src="https://shieldcn.dev/badge/Read-Documentation.svg?logo=lu%3ABook" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<sub>8-week interview-prep fight camp. Each phase is a milestone; each topic an issue; each problem a sub-issue.<br>
|
<sub>8-week interview-prep fight camp. Each phase is a milestone; each topic an issue; each problem a sub-issue.<br>
|
||||||
|
New topic Mon–Fri, gate on Saturday, Sunday off — every solve comes back at <strong>+3</strong> and <strong>+7</strong> days,
|
||||||
|
the two windows that keep the rest day clear (a Thursday solve's +3 slides to Monday).<br>
|
||||||
Charts are live from the SRS Worker, never committed: pushing a solution or tapping the daily digest moves them within ~5 min.</sub>
|
Charts are live from the SRS Worker, never committed: pushing a solution or tapping the daily digest moves them within ~5 min.</sub>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@
|
|||||||
<td colspan="2" align="center"><img alt="phase progress" width="100%" src="https://srs-api.prdlk.workers.dev/chart/progress.svg" /></td>
|
<td colspan="2" align="center"><img alt="phase progress" width="100%" src="https://srs-api.prdlk.workers.dev/chart/progress.svg" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" width="53%"><img alt="SRS ladder" width="100%" src="https://srs-api.prdlk.workers.dev/chart/ladder.svg" /></td>
|
<td align="center" width="53%"><img alt="SRS ladder — new / +3 / +7 / retired" width="100%" src="https://srs-api.prdlk.workers.dev/chart/ladder.svg" /></td>
|
||||||
<td align="center" width="47%"><img alt="attempt heatmap" width="100%" src="https://srs-api.prdlk.workers.dev/chart/heatmap.svg" /></td>
|
<td align="center" width="47%"><img alt="attempt heatmap" width="100%" src="https://srs-api.prdlk.workers.dev/chart/heatmap.svg" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -335,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>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
+52
-22
@@ -14,8 +14,12 @@ Live at `https://srs-api.prdlk.workers.dev`.
|
|||||||
labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via
|
labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via
|
||||||
`/admin/reconcile`); it recomputes from scratch, never invents rows, and
|
`/admin/reconcile`); it recomputes from scratch, never invents rows, and
|
||||||
never overwrites SRS-owned columns (`stage`, `next_review`).
|
never overwrites SRS-owned columns (`stage`, `next_review`).
|
||||||
- **The repo owns the schedule** — `data/schedule.json`, human-edited,
|
- **The repo owns the plan** — `data/schedule.json`, human-edited,
|
||||||
bundled at deploy. Git history is its audit log.
|
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
|
- The GitHub Project mirror writes `Target Date` and nothing else: it is
|
||||||
best-effort, so failures log warnings and never block a D1 write, and topic
|
best-effort, so failures log warnings and never block a D1 write, and topic
|
||||||
rows are never written. SRS stage and first-attempt result stay in D1 only —
|
rows are never written. SRS stage and first-attempt result stay in D1 only —
|
||||||
@@ -26,15 +30,31 @@ Live at `https://srs-api.prdlk.workers.dev`.
|
|||||||
|
|
||||||
## The ladder
|
## The ladder
|
||||||
|
|
||||||
`new → +2 → +5 → +10 → retired`; pass advances, fail resets to `+2`.
|
`new → +3 → +7 → retired`; pass advances, fail resets to `+3`. A problem's
|
||||||
A problem's first-ever log enters at `+2` regardless of result. Stage names
|
first-ever log enters at `+3` regardless of result. Stage names the NEXT
|
||||||
the NEXT review's interval. All dates are ET calendar strings anchored at
|
review's interval, and the two rungs are the same numbers as the `work/3` and
|
||||||
noon UTC (`src/srs.ts`) — never raw `Date` math.
|
`work/7` buckets `bun run pick` scaffolds re-solves into.
|
||||||
|
|
||||||
|
**Sunday is never booked.** Topics run Mon–Fri, the gate is Saturday, and 3/7
|
||||||
|
are chosen so a solve returns on a working day: only a Thursday solve's `+3`
|
||||||
|
would land on the rest day, and `workingDay()` in `src/srs.ts` slides it to
|
||||||
|
Monday. Every scheduled date in the Worker — ladder reviews, levelled
|
||||||
|
overflow, deferred-Hard release dates — is minted by that one function, so the
|
||||||
|
rest day cannot be booked and then swallowed by the digest's Sunday branch.
|
||||||
|
Reviews may slip later, never earlier: pulling one back to Saturday would
|
||||||
|
shorten the interval it exists to test. `src/srs.test.ts` sweeps the whole
|
||||||
|
campaign calendar to prove it.
|
||||||
|
|
||||||
|
All dates are ET calendar strings anchored at noon UTC (`src/srs.ts`) — never
|
||||||
|
raw `Date` math.
|
||||||
|
|
||||||
Blind drills draw only from topics **already learned**: scheduled in an
|
Blind drills draw only from topics **already learned**: scheduled in an
|
||||||
earlier week (the current week's optional pool is reserved for Saturday's
|
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
|
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
|
## Routes
|
||||||
|
|
||||||
@@ -44,7 +64,8 @@ day never feeds drills just because its calendar week lapsed.
|
|||||||
| `POST /webhook/github` | HMAC (`WEBHOOK_SECRET`) | `/done <n> pass\|fail` comments (owner only, any issue); `review`-issue close → gate scoring |
|
| `POST /webhook/github` | HMAC (`WEBHOOK_SECRET`) | `/done <n> pass\|fail` comments (owner only, any issue); `review`-issue close → gate scoring |
|
||||||
| `GET /chart/{progress,ladder,heatmap}.svg`, `GET /badge/gate.svg` | public | hand-rolled SVGs, `max-age=300` (GitHub Camo's freshness floor) |
|
| `GET /chart/{progress,ladder,heatmap}.svg`, `GET /badge/gate.svg` | public | hand-rolled SVGs, `max-age=300` (GitHub Camo's freshness floor) |
|
||||||
| `GET /api/stats` | public, CORS-pinned to the docs origin | one JSON document for `/progress` |
|
| `GET /api/stats` | public, CORS-pinned to the docs origin | one JSON document for `/progress` |
|
||||||
| `POST /admin/solved` | `Authorization: Bearer <LINK_KEY>` | `{"lc":[…]}` — solutions committed under `work/`; logs each *first* solve (`source='commit'`), skips anything past stage `new`, takes `?dry=1&date=` |
|
| `POST /admin/solved` | `Authorization: Bearer <LINK_KEY>` | `{"solved":[{"lc":1,"bucket":1},…]}` — solutions committed under `work/`; the bucket names the rung it settles (1 → `new`, 3 → `+3`, 7 → `+7`), so a first solve enters the ladder and a pushed re-solve advances it. Writes only when the problem stands on that rung, which is what makes re-posting the whole set a no-op. Takes `?dry=1&date=` |
|
||||||
|
| `POST /admin/due` | `Authorization: Bearer <LINK_KEY>` | read-only: what the ladder has due on `?date=` (default today), oldest first, with the rung/bucket, how late it is, and when it was last seen. The one answer behind the daily `Spaced Repetition` issue |
|
||||||
| `POST /admin/{digest,review,reconcile}` | `Authorization: Bearer <LINK_KEY>` | manual triggers; `digest` takes `?dry=1&force=1&date=` |
|
| `POST /admin/{digest,review,reconcile}` | `Authorization: Bearer <LINK_KEY>` | manual triggers; `digest` takes `?dry=1&force=1&date=` |
|
||||||
|
|
||||||
## Crons (DST-proof)
|
## Crons (DST-proof)
|
||||||
@@ -64,15 +85,23 @@ routes). Bindings in `wrangler.jsonc`: `DB` (D1 `srs`), `EMAIL`
|
|||||||
(`send_email`, restricted to the verified destination). Sender domain
|
(`send_email`, restricted to the verified destination). Sender domain
|
||||||
`prdlk.com` is onboarded to Email Sending.
|
`prdlk.com` is onboarded to Email Sending.
|
||||||
|
|
||||||
Two Actions workflows push into D1 with the `SRS_ADMIN_KEY` repo secret:
|
Three Actions workflows talk to this Worker with the `SRS_ADMIN_KEY` repo
|
||||||
`sync-d1.yml` sends curriculum issue edits to `POST /admin/reconcile`
|
secret:
|
||||||
(the morning cron is the backstop), and `close-solved.yml` sends the whole
|
|
||||||
implemented `work/` set to `POST /admin/solved` on every push to `main`.
|
- `sync-d1.yml` sends curriculum issue edits to `POST /admin/reconcile` (the
|
||||||
That second call is what keeps a committed solution from being invisible
|
morning cron is the backstop).
|
||||||
here: closing its issue is not a state change the Worker can see — the
|
- `close-solved.yml` sends the whole implemented `work/` set — each entry
|
||||||
catalog reconcile ignores issue state, and only `logAttempt()` moves the
|
tagged with its bucket — to `POST /admin/solved` on every push to `main`.
|
||||||
ladder. `close-solved` still owns the issue close itself (it knows the files
|
That call is what keeps a committed solution from being invisible here:
|
||||||
and the commit), so this path only writes D1 and mirrors Project fields.
|
closing an issue is not a state change the Worker can see (the catalog
|
||||||
|
reconcile ignores issue state, and only `logAttempt()` moves the ladder), and
|
||||||
|
the bucket is what lets a pushed re-solve advance `+3 → +7` instead of
|
||||||
|
being read as a stale first solve. `close-solved` still owns the issue close
|
||||||
|
itself (it knows the files and the commit), so this path only writes D1 and
|
||||||
|
mirrors Project fields.
|
||||||
|
- `spaced-repetition.yml` reads `POST /admin/due` at 06:00 ET and renders
|
||||||
|
today's `Spaced Repetition` issue. Read-only, and the only schedule involved
|
||||||
|
is this Worker's ladder.
|
||||||
|
|
||||||
## Local dev
|
## Local dev
|
||||||
|
|
||||||
@@ -83,13 +112,14 @@ bunx wrangler d1 migrations apply srs --local
|
|||||||
bun run dev # wrangler dev on :8787, local D1
|
bun run dev # wrangler dev on :8787, local D1
|
||||||
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
||||||
"localhost:8787/admin/digest?dry=1&date=2026-08-31" # prints HTML, sends nothing
|
"localhost:8787/admin/digest?dry=1&date=2026-08-31" # prints HTML, sends nothing
|
||||||
bun test src # DST guard + date math
|
bun test src # DST guards, date math, the rest-day sweep
|
||||||
```
|
```
|
||||||
|
|
||||||
`?date=` on admin routes is the `SRS_TODAY` equivalent. The one-shot
|
`?date=` on admin routes is the `SRS_TODAY` equivalent. Migrations are
|
||||||
migration from the retired `.github/srs/srs.json` lives at
|
append-only and applied in order; `0002_ladder_3_7.sql` is the +2/+5/+10 →
|
||||||
`scripts/import-srs.ts` (`--remote` for production D1); it is re-runnable —
|
`+3`/`+7` rebuild, which also lifts every date that was sitting on a Sunday.
|
||||||
import-sourced attempts are wiped and re-inserted.
|
The one-shot importer for the retired `.github/srs/srs.json` is gone — that
|
||||||
|
state no longer exists, and git history is its record.
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
|
|||||||
+26
-24
@@ -1,26 +1,28 @@
|
|||||||
{
|
{
|
||||||
"2026-08-17": 3,
|
"2026-08-17": { "topics": [3] },
|
||||||
"2026-08-18": 4,
|
"2026-08-18": { "topics": [4] },
|
||||||
"2026-08-24": 5,
|
"2026-08-24": { "topics": [5] },
|
||||||
"2026-08-25": 6,
|
"2026-08-25": { "topics": [6] },
|
||||||
"2026-08-26": 7,
|
"2026-08-26": { "topics": [7] },
|
||||||
"2026-08-27": 8,
|
"2026-08-27": { "topics": [8] },
|
||||||
"2026-08-28": 9,
|
"2026-08-28": { "topics": [9] },
|
||||||
"2026-08-31": 10,
|
"2026-08-31": { "topics": [10] },
|
||||||
"2026-09-01": 11,
|
"2026-09-01": { "topics": [11] },
|
||||||
"2026-09-02": 12,
|
"2026-09-02": { "topics": [12] },
|
||||||
"2026-09-03": 13,
|
"2026-09-03": { "topics": [13] },
|
||||||
"2026-09-04": 14,
|
"2026-09-04": { "topics": [14] },
|
||||||
"2026-09-07": 15,
|
"2026-09-07": { "topics": [15] },
|
||||||
"2026-09-08": 16,
|
"2026-09-08": { "topics": [16] },
|
||||||
"2026-09-09": 17,
|
"2026-09-21": { "topics": [17, 18] },
|
||||||
"2026-09-10": 18,
|
"2026-09-22": { "topics": [19, 20] },
|
||||||
"2026-09-11": 19,
|
"2026-09-23": { "topics": [21] },
|
||||||
"2026-09-14": 20,
|
"2026-09-24": { "topics": [22] },
|
||||||
"2026-09-15": 21,
|
"2026-09-25": { "topics": [23] },
|
||||||
"2026-09-16": 22,
|
"2026-09-26": { "gate": [648, 1091, 802, 547, 90] },
|
||||||
"2026-09-17": 23,
|
"2026-09-28": { "topics": [24] },
|
||||||
"2026-09-18": 24,
|
"2026-09-29": { "topics": [25] },
|
||||||
"2026-09-21": 25,
|
"2026-09-30": { "topics": [26] },
|
||||||
"2026-09-22": 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,31 @@
|
|||||||
|
-- Ladder rebuild: +2/+5/+10 → +3/+7, and no scheduled date on a Sunday.
|
||||||
|
--
|
||||||
|
-- The old three rungs scattered reviews across every weekday, so roughly one
|
||||||
|
-- review in seven came due on the rest day: the digest hid it (Sunday builds
|
||||||
|
-- an empty body) and Monday inherited the pile. The campaign's windows are 3
|
||||||
|
-- and 7 days — the same numbers as the work/3 and work/7 buckets and the
|
||||||
|
-- Spaced Repetition issue windows — and srs.ts now mints every date through
|
||||||
|
-- workingDay(), which never returns a Sunday.
|
||||||
|
--
|
||||||
|
-- Stage remap keeps earned progress: a problem at +5 has passed one review, so
|
||||||
|
-- it owes the 7-day one; a problem at +10 has passed two and is done. Existing
|
||||||
|
-- next_review dates keep their day (recomputing them would move work the
|
||||||
|
-- reader has already been told about) — only Sunday landings shift, forward to
|
||||||
|
-- Monday, because a review may come later than its interval but never sooner.
|
||||||
|
--
|
||||||
|
-- problems.stage is now: new | +3 | +7 | retired
|
||||||
|
|
||||||
|
UPDATE problems SET stage = '+3' WHERE stage = '+2';
|
||||||
|
UPDATE problems SET stage = '+7' WHERE stage = '+5';
|
||||||
|
UPDATE problems SET stage = 'retired', next_review = NULL WHERE stage = '+10';
|
||||||
|
|
||||||
|
UPDATE problems
|
||||||
|
SET next_review = date(next_review, '+1 day')
|
||||||
|
WHERE next_review IS NOT NULL AND strftime('%w', next_review) = '0';
|
||||||
|
|
||||||
|
-- defer_until is the deferred Hards' release date; catalog.ts spreads them two
|
||||||
|
-- per working day, so the same rule applies. next_review tracks it for a row
|
||||||
|
-- that is still `new`, and the shift above kept the two in step.
|
||||||
|
UPDATE problems
|
||||||
|
SET defer_until = date(defer_until, '+1 day')
|
||||||
|
WHERE defer_until IS NOT NULL AND strftime('%w', defer_until) = '0';
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
/**
|
|
||||||
* One-shot migration: .github/srs/srs.json → D1.
|
|
||||||
*
|
|
||||||
* Run AFTER the catalog reconcile has filled `problems` (the import only
|
|
||||||
* overlays SRS-owned fields — stage, next_review, defer_until — and inserts
|
|
||||||
* attempts/topic counters). Re-runnable without duplicates: import-sourced
|
|
||||||
* attempts are wiped and re-inserted, everything else upserts.
|
|
||||||
*
|
|
||||||
* bun apps/api/scripts/import-srs.ts # local D1 (wrangler dev state)
|
|
||||||
* bun apps/api/scripts/import-srs.ts --remote # production D1
|
|
||||||
*
|
|
||||||
* Prints row counts; verify them against srs.json before deleting anything.
|
|
||||||
*/
|
|
||||||
import { $ } from "bun";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
|
||||||
const API = join(ROOT, "api");
|
|
||||||
const REMOTE = process.argv.includes("--remote");
|
|
||||||
|
|
||||||
interface Attempt {
|
|
||||||
date: string;
|
|
||||||
kind: string;
|
|
||||||
result: string;
|
|
||||||
}
|
|
||||||
interface Problem {
|
|
||||||
issue: number;
|
|
||||||
topic: number;
|
|
||||||
difficulty: string;
|
|
||||||
set: string;
|
|
||||||
solved_on?: string;
|
|
||||||
stage: string;
|
|
||||||
next_review?: string;
|
|
||||||
defer_until?: string;
|
|
||||||
history: Attempt[];
|
|
||||||
}
|
|
||||||
interface State {
|
|
||||||
problems: Record<string, Problem>;
|
|
||||||
topics: Record<string, { misses: number; boost: boolean }>;
|
|
||||||
drill_pool_used: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const state: State = JSON.parse(
|
|
||||||
await Bun.file(join(ROOT, ".github", "srs", "srs.json")).text(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const q = (v: string | null | undefined) => (v == null ? "NULL" : `'${v}'`);
|
|
||||||
const lines: string[] = ["DELETE FROM attempts WHERE source = 'import';"];
|
|
||||||
|
|
||||||
let attempts = 0;
|
|
||||||
for (const [lc, p] of Object.entries(state.problems)) {
|
|
||||||
lines.push(
|
|
||||||
`UPDATE problems SET stage = ${q(p.stage)}, next_review = ${q(p.next_review ?? null)}, ` +
|
|
||||||
`defer_until = ${q(p.defer_until ?? null)} WHERE lc_number = ${Number(lc)};`,
|
|
||||||
);
|
|
||||||
for (const a of p.history) {
|
|
||||||
lines.push(
|
|
||||||
`INSERT INTO attempts (lc_number, date, kind, result, source) ` +
|
|
||||||
`VALUES (${Number(lc)}, ${q(a.date)}, ${q(a.kind)}, ${q(a.result)}, 'import');`,
|
|
||||||
);
|
|
||||||
attempts++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const [topic, t] of Object.entries(state.topics)) {
|
|
||||||
lines.push(
|
|
||||||
`UPDATE topics SET misses = ${t.misses}, boost = ${t.boost ? 1 : 0} WHERE issue = ${Number(topic)};`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const lc of state.drill_pool_used) {
|
|
||||||
lines.push(`INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (${lc});`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sqlPath = join(API, "migrations", ".import.sql");
|
|
||||||
await Bun.write(sqlPath, lines.join("\n") + "\n");
|
|
||||||
const flag = REMOTE ? "--remote" : "--local";
|
|
||||||
await $`bunx wrangler d1 execute srs ${flag} --file ${sqlPath}`.cwd(API);
|
|
||||||
await $`rm ${sqlPath}`;
|
|
||||||
|
|
||||||
const counts =
|
|
||||||
await $`bunx wrangler d1 execute srs ${flag} --json --command ${"SELECT (SELECT COUNT(*) FROM problems) AS problems, (SELECT COUNT(*) FROM problems WHERE stage != 'new') AS laddered, (SELECT COUNT(*) FROM problems WHERE defer_until IS NOT NULL) AS deferred, (SELECT COUNT(*) FROM attempts WHERE source='import') AS imported_attempts, (SELECT COUNT(*) FROM topics) AS topics, (SELECT COUNT(*) FROM drill_pool_used) AS drills_used"}`
|
|
||||||
.cwd(API)
|
|
||||||
.json();
|
|
||||||
|
|
||||||
const expected = {
|
|
||||||
json_problems: Object.keys(state.problems).length,
|
|
||||||
json_attempts: attempts,
|
|
||||||
json_laddered: Object.values(state.problems).filter((p) => p.stage !== "new").length,
|
|
||||||
json_deferred: Object.values(state.problems).filter((p) => p.defer_until).length,
|
|
||||||
json_topics: Object.keys(state.topics).length,
|
|
||||||
json_drills_used: state.drill_pool_used.length,
|
|
||||||
};
|
|
||||||
console.log("expected from srs.json:", JSON.stringify(expected));
|
|
||||||
console.log("in D1:", JSON.stringify(counts[0]?.results?.[0] ?? counts));
|
|
||||||
@@ -10,15 +10,12 @@
|
|||||||
* so relabelling a problem is enough to move it and titles stay readable.
|
* so relabelling a problem is enough to move it and titles stay readable.
|
||||||
* The `problem` label is what marks a sub-issue as curriculum.
|
* The `problem` label is what marks a sub-issue as curriculum.
|
||||||
*/
|
*/
|
||||||
import { addDays } from "./srs.ts";
|
import { DIFFICULTIES, SETS, workingDay } from "./srs.ts";
|
||||||
import type { GitHub } from "./github.ts";
|
import type { GitHub } from "./github.ts";
|
||||||
|
|
||||||
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
||||||
|
|
||||||
const DIFFICULTIES = ["easy", "medium", "hard"] as const;
|
/** Deferred Hards enter the queue from this date, two per working day. */
|
||||||
const SETS = ["core", "optional", "deferred"] as const;
|
|
||||||
|
|
||||||
/** Deferred Hards enter the queue from this date, two per day. */
|
|
||||||
const DEFER_FROM = "2026-09-28";
|
const DEFER_FROM = "2026-09-28";
|
||||||
|
|
||||||
export interface ReconcileReport {
|
export interface ReconcileReport {
|
||||||
@@ -96,9 +93,10 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const lc = Number(m[1]);
|
const lc = Number(m[1]);
|
||||||
// Two deferred Hards per day from DEFER_FROM, in catalog walk order —
|
// Two deferred Hards per WORKING day from DEFER_FROM, in catalog walk
|
||||||
// applied only when the row is first created (SRS owns it afterwards).
|
// order — applied only when the row is first created (SRS owns it
|
||||||
const defer = set === "deferred" ? addDays(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
|
// afterwards). Sunday is skipped like every other scheduled date.
|
||||||
|
const defer = set === "deferred" ? workingDay(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
|
||||||
if (set === "deferred") deferredSeen++;
|
if (set === "deferred") deferredSeen++;
|
||||||
statements.push(
|
statements.push(
|
||||||
db
|
db
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
* badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode
|
* badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode
|
||||||
* green ramp. Every function renders on a zero-row DB.
|
* green ramp. Every function renders on a zero-row DB.
|
||||||
*/
|
*/
|
||||||
|
import { STAGES } from "./srs.ts";
|
||||||
import { Canvas, textWidth } from "./png.ts";
|
import { Canvas, textWidth } from "./png.ts";
|
||||||
|
|
||||||
// D1Database comes from the generated worker-configuration.d.ts runtime types.
|
// D1Database comes from the generated worker-configuration.d.ts runtime types.
|
||||||
@@ -43,8 +44,6 @@ const PHASES = [
|
|||||||
{ milestone: 5, name: "V — Decision Space" },
|
{ milestone: 5, name: "V — Decision Space" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STAGES = ["new", "+2", "+5", "+10", "retired"];
|
|
||||||
|
|
||||||
// ── svg helpers ──────────────────────────────────────────────────
|
// ── svg helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Opening tag plus the rounded dark card every chart starts with. */
|
/** Opening tag plus the rounded dark card every chart starts with. */
|
||||||
@@ -159,6 +158,9 @@ export async function progressChart(db: D1Database): Promise<string> {
|
|||||||
|
|
||||||
// ── ladder: bar per stage ────────────────────────────────────────
|
// ── ladder: bar per stage ────────────────────────────────────────
|
||||||
|
|
||||||
|
// One bar per rung, labelled and ordered by srs.ts's STAGES, so changing the
|
||||||
|
// ladder can never leave a stale bar here: the slot width is derived from the
|
||||||
|
// rung count, only the bar width inside a slot is fixed.
|
||||||
export async function ladderChart(db: D1Database): Promise<string> {
|
export async function ladderChart(db: D1Database): Promise<string> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`)
|
.prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`)
|
||||||
|
|||||||
+70
-24
@@ -5,14 +5,16 @@
|
|||||||
* the HTML and the plain-text alternative from that single tree (React Email
|
* the HTML and the plain-text alternative from that single tree (React Email
|
||||||
* `render`), so the two can never drift.
|
* `render`), so the two can never drift.
|
||||||
*
|
*
|
||||||
* Retrieval rules: review and drill rows carry number + difficulty only —
|
* Retrieval rules: review, drill and sweep rows carry number + difficulty
|
||||||
* never the topic, never a solution link. `DigestRow` has no field for
|
* only — never the topic, never a solution link. `DigestRow` has no field for
|
||||||
* either, so the rule holds by construction. The learning day's core list is
|
* either, so the rule holds by construction. The plan's own sections — a
|
||||||
* the only labeled section. At most REVIEW_CAP (3) reviews per day; drills
|
* learning day's topics (core, then the optional "if time" list) and a set
|
||||||
* fill toward DAILY_CAP (6) only when the due queue is light. Overflow is
|
* day's problems — are the only labeled, linked ones. At most REVIEW_CAP (3)
|
||||||
* levelled, not stacked: sendDigest gives every review past the cap a
|
* reviews per day; drills fill toward DAILY_CAP (6) only when the due queue
|
||||||
* concrete future date, ≤ REVIEW_CAP per day, oldest first. Sunday is a
|
* is light, and never on a sweep day, which assigns every open miss and
|
||||||
* two-line rest note.
|
* 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
|
* Idempotency: email_log keys sends by ET date — a same-day re-send is a
|
||||||
* no-op unless forced; the body itself is deterministic (drill picks are
|
* no-op unless forced; the body itself is deterministic (drill picks are
|
||||||
@@ -22,20 +24,23 @@
|
|||||||
import {
|
import {
|
||||||
CAMPAIGN_DAYS,
|
CAMPAIGN_DAYS,
|
||||||
type ProblemRow,
|
type ProblemRow,
|
||||||
|
RESERVED,
|
||||||
REVIEW_CAP,
|
REVIEW_CAP,
|
||||||
SCHEDULE,
|
SCHEDULE,
|
||||||
|
type ScheduledDay,
|
||||||
addDays,
|
addDays,
|
||||||
campaignDay,
|
campaignDay,
|
||||||
campaignWeek,
|
campaignWeek,
|
||||||
dueReviews,
|
dueReviews,
|
||||||
isoWeek,
|
isoWeek,
|
||||||
levelReviews,
|
levelReviews,
|
||||||
|
lookupProblems,
|
||||||
pickDrills,
|
pickDrills,
|
||||||
prettyDate,
|
prettyDate,
|
||||||
streak,
|
streak,
|
||||||
weekdayOf,
|
weekdayOf,
|
||||||
} from "./srs.ts";
|
} 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";
|
import { signLink } from "./links.ts";
|
||||||
|
|
||||||
/** Total daily ceiling: drills only fill what the whole due queue leaves. */
|
/** Total daily ceiling: drills only fill what the whole due queue leaves. */
|
||||||
@@ -63,23 +68,50 @@ 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 a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number)
|
||||||
|
AND a.result = 'fail' AND p.stage != 'retired'
|
||||||
|
ORDER BY a.date, p.lc_number`,
|
||||||
|
)
|
||||||
|
.all<ProblemRow>();
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
/** Everything the email needs, read straight out of D1 and the schedule. */
|
/** Everything the email needs, read straight out of D1 and the schedule. */
|
||||||
export async function collectDigest(env: Env, date: string): Promise<DigestData> {
|
export async function collectDigest(env: Env, date: string): Promise<DigestData> {
|
||||||
const db = env.DB;
|
const db = env.DB;
|
||||||
const rest = weekdayOf(date) === 0;
|
const rest = weekdayOf(date) === 0;
|
||||||
|
const plan: ScheduledDay = rest ? {} : (SCHEDULE[date] ?? {});
|
||||||
|
|
||||||
// Reviews surface at most REVIEW_CAP; drills fill toward DAILY_CAP only
|
// 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
|
// 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 due = rest ? [] : await dueReviews(db, date);
|
||||||
const capped = due.slice(0, REVIEW_CAP);
|
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 = {
|
const data: DigestData = {
|
||||||
day: prettyDate(date),
|
day: prettyDate(date),
|
||||||
progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`,
|
progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`,
|
||||||
streak: await streak(db, date),
|
streak: await streak(db, date),
|
||||||
rest,
|
rest,
|
||||||
|
topics: [],
|
||||||
reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))),
|
reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))),
|
||||||
carried: due.length - capped.length,
|
carried: due.length - capped.length,
|
||||||
drills: await Promise.all(drills.map((p) => tapRow(env, p, date, false))),
|
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`,
|
progressUrl: `${env.DOCS_URL}/progress`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// New topic — the only section allowed to name problems and link them.
|
// New topics — the plan's sections, the only ones allowed to name problems
|
||||||
const topicIssue = rest ? undefined : SCHEDULE[date];
|
// and link them. Core first; the optional set is "if time", minus anything
|
||||||
if (topicIssue !== undefined) {
|
// the plan has reserved for a gate or a set day.
|
||||||
|
for (const topicIssue of plan.topics ?? []) {
|
||||||
const topic = await db
|
const topic = await db
|
||||||
.prepare("SELECT name FROM topics WHERE issue = ?")
|
.prepare("SELECT name FROM topics WHERE issue = ?")
|
||||||
.bind(topicIssue)
|
.bind(topicIssue)
|
||||||
.first<{ name: string }>();
|
.first<{ name: string }>();
|
||||||
const { results: core } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.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)
|
.bind(topicIssue)
|
||||||
.all<ProblemRow>();
|
.all<ProblemRow>();
|
||||||
data.topic = {
|
data.topics.push({
|
||||||
name: topic?.name ?? `#${topicIssue}`,
|
name: topic?.name ?? `#${topicIssue}`,
|
||||||
core: core.map((p) => ({
|
core: results.filter((p) => p.set_label === "core").map((p) => topicRow(env, p)),
|
||||||
lc: p.lc_number,
|
ifTime: results
|
||||||
difficulty: p.difficulty,
|
.filter((p) => p.set_label === "optional" && !RESERVED.has(p.lc_number))
|
||||||
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
|
.map((p) => topicRow(env, p)),
|
||||||
solved: p.stage !== "new",
|
});
|
||||||
})),
|
}
|
||||||
};
|
|
||||||
|
// 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).
|
// Saturday: the review issue already exists (created at midnight ET).
|
||||||
|
|||||||
+91
-23
@@ -62,12 +62,12 @@ const DIFFICULTY: Record<string, { label: string; color: string }> = {
|
|||||||
hard: { label: "Hard", color: RED },
|
hard: { label: "Hard", color: RED },
|
||||||
};
|
};
|
||||||
|
|
||||||
// The SRS ladder in plain English — "+5" means the last look was 5 days back.
|
// The SRS ladder in plain English — stage "+3" means the last clean look was
|
||||||
|
// three days back, so that is what the reader is told.
|
||||||
const LAST_SEEN: Record<string, string> = {
|
const LAST_SEEN: Record<string, string> = {
|
||||||
new: "first look",
|
new: "first look",
|
||||||
"+2": "2 days ago",
|
"+3": "3 days ago",
|
||||||
"+5": "5 days ago",
|
"+7": "7 days ago",
|
||||||
"+10": "10 days ago",
|
|
||||||
retired: "retired",
|
retired: "retired",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ export interface DigestRow {
|
|||||||
failUrl: string;
|
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 {
|
export interface TopicRow {
|
||||||
lc: number;
|
lc: number;
|
||||||
difficulty: string;
|
difficulty: string;
|
||||||
@@ -91,6 +91,13 @@ export interface TopicRow {
|
|||||||
solved: boolean;
|
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 {
|
export interface DigestData {
|
||||||
/** "Tuesday, August 25" */
|
/** "Tuesday, August 25" */
|
||||||
day: string;
|
day: string;
|
||||||
@@ -98,11 +105,16 @@ export interface DigestData {
|
|||||||
progress: string;
|
progress: string;
|
||||||
streak: number;
|
streak: number;
|
||||||
rest: boolean;
|
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: DigestRow[];
|
||||||
/** Reviews past the daily cap; levelled onto the coming days. */
|
/** Reviews past the daily cap; levelled onto the coming days. */
|
||||||
carried: number;
|
carried: number;
|
||||||
drills: DigestRow[];
|
drills: DigestRow[];
|
||||||
|
/** A sweep day's miss log: present (even empty) only on a sweep day. */
|
||||||
|
sweep?: DigestRow[];
|
||||||
gate?: { week: number; url: string };
|
gate?: { week: number; url: string };
|
||||||
yesterday: { total: number; failed: number[] };
|
yesterday: { total: number; failed: number[] };
|
||||||
gateRate?: number;
|
gateRate?: number;
|
||||||
@@ -278,10 +290,15 @@ function Block({
|
|||||||
// ── email ────────────────────────────────────────────────────────
|
// ── email ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DigestEmail({ data }: { data: DigestData }) {
|
function DigestEmail({ data }: { data: DigestData }) {
|
||||||
const load = data.reviews.length + data.drills.length;
|
const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
|
||||||
const preview = data.rest
|
const lead = data.topics[0]
|
||||||
? "Rest day — nothing to do but rest."
|
? `, starting with ${data.topics[0].name}`
|
||||||
: `${load} to work through today${data.topic ? `, starting with ${data.topic.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 passed = data.yesterday.total - data.yesterday.failed.length;
|
||||||
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
|
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
|
||||||
@@ -302,7 +319,7 @@ function DigestEmail({ data }: { data: DigestData }) {
|
|||||||
<Text style={{ color: MUTED, fontSize: "13px", margin: "8px 0 0" }}>
|
<Text style={{ color: MUTED, fontSize: "13px", margin: "8px 0 0" }}>
|
||||||
{data.progress}
|
{data.progress}
|
||||||
{data.rest
|
{data.rest
|
||||||
? " · nothing is due today, and anything overdue waits for Monday."
|
? " · the ladder never books a Sunday, so nothing is due — anything still open waits for Monday."
|
||||||
: data.streak === 0
|
: data.streak === 0
|
||||||
? " · no streak going yet — today is a good day to start one."
|
? " · no streak going yet — today is a good day to start one."
|
||||||
: ` · 🔥 ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`}
|
: ` · 🔥 ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`}
|
||||||
@@ -310,15 +327,47 @@ function DigestEmail({ data }: { data: DigestData }) {
|
|||||||
|
|
||||||
{data.rest ? null : (
|
{data.rest ? null : (
|
||||||
<>
|
<>
|
||||||
{data.topic ? (
|
{data.topics.map((topic, i) => (
|
||||||
<Section style={{ marginTop: "28px" }}>
|
<Section key={topic.name} style={{ marginTop: i === 0 ? "28px" : "20px" }}>
|
||||||
<Text style={label}>Something new today</Text>
|
{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" }}>
|
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
||||||
{data.topic.name}
|
{topic.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={hint}>Work through these from the top — a tick means it is already solved.</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>
|
</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}
|
) : null}
|
||||||
|
|
||||||
<Block
|
<Block
|
||||||
@@ -403,22 +452,33 @@ function plainDigest(data: DigestData): string {
|
|||||||
data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`,
|
data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`,
|
||||||
data.progress +
|
data.progress +
|
||||||
(data.rest
|
(data.rest
|
||||||
? " · nothing is due today, and anything overdue waits for Monday."
|
? " · the ladder never books a Sunday, so nothing is due — anything still open waits for Monday."
|
||||||
: data.streak === 0
|
: data.streak === 0
|
||||||
? " · no streak going yet — today is a good day to start one."
|
? " · no streak going yet — today is a good day to start one."
|
||||||
: ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`),
|
: ` · ${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.rest) {
|
||||||
if (data.topic) {
|
for (const topic of data.topics) {
|
||||||
out.push(
|
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.",
|
"Work through these from the top; the ones marked done you have already solved.",
|
||||||
...data.topic.core.map(
|
...topic.core.map(linked),
|
||||||
(p) =>
|
);
|
||||||
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`,
|
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.",
|
"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) {
|
for (const [title, note, rows, empty] of lists) {
|
||||||
out.push("", title);
|
out.push("", title);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
|
|||||||
+58
-40
@@ -2,21 +2,27 @@
|
|||||||
* Saturday review issue: created at midnight ET so the 8 AM digest can link
|
* Saturday review issue: created at midnight ET so the 8 AM digest can link
|
||||||
* to it; scored when the issue closes (webhook).
|
* to it; scored when the issue closes (webhook).
|
||||||
*
|
*
|
||||||
* The gate half is a topic-blind quiz — one unsolved optional problem per
|
* The gate half is a topic-blind quiz. When the plan fixes the set
|
||||||
* topic scheduled this week (which is why daily drills only draw from
|
* (`gate` in data/schedule.json) that list IS the gate, verbatim — every
|
||||||
* earlier weeks). The recap half is generated data, deliberately NOT a task
|
* number must be a catalog problem, or the issue is not created, because a
|
||||||
* list: the week's solved problems are already scheduled by the +2 ladder,
|
* `/done` on an uncatalogued number can never be logged. Otherwise the set
|
||||||
* and re-assigning them on Saturday would be massed practice.
|
* 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
|
* 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 {
|
import {
|
||||||
type ProblemRow,
|
type ProblemRow,
|
||||||
|
RESERVED,
|
||||||
type Result,
|
type Result,
|
||||||
SCHEDULE,
|
SCHEDULE,
|
||||||
campaignWeek,
|
campaignWeek,
|
||||||
isoWeek,
|
isoWeek,
|
||||||
|
lookupProblems,
|
||||||
rng,
|
rng,
|
||||||
sample,
|
sample,
|
||||||
streak,
|
streak,
|
||||||
@@ -32,7 +38,7 @@ function capitalized(difficulty: string): string {
|
|||||||
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
|
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[]> {
|
async function freshPool(db: D1Database, topic: number, set: string): Promise<ProblemRow[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -44,7 +50,7 @@ async function freshPool(db: D1Database, topic: number, set: string): Promise<Pr
|
|||||||
)
|
)
|
||||||
.bind(topic, set)
|
.bind(topic, set)
|
||||||
.all<ProblemRow>();
|
.all<ProblemRow>();
|
||||||
return results;
|
return results.filter((p) => !RESERVED.has(p.lc_number));
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateReport {
|
export interface CreateReport {
|
||||||
@@ -67,40 +73,52 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
|||||||
return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` };
|
return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Topics scheduled this week; review-only weeks (Sep 23 onward) sample
|
// The plan's fixed set wins; otherwise sample this week's topics, and in a
|
||||||
// across every topic that still has unsolved optional problems.
|
// week without topics, across every topic that still has an unsolved pool.
|
||||||
let topics = Object.entries(SCHEDULE)
|
|
||||||
.filter(([d]) => campaignWeek(d) === week)
|
|
||||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
||||||
.map(([, t]) => t);
|
|
||||||
if (topics.length === 0) {
|
|
||||||
const { results } = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT DISTINCT topic_issue AS t FROM problems
|
|
||||||
WHERE set_label = 'optional' AND stage = 'new'
|
|
||||||
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
|
||||||
ORDER BY topic_issue`,
|
|
||||||
)
|
|
||||||
.all<{ t: number }>();
|
|
||||||
topics = sample(results.map((r) => r.t), MAX_GATE_PROBLEMS, rng(`gate-topics-${iso}`)).sort(
|
|
||||||
(a, b) => a - b,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
|
|
||||||
|
|
||||||
const random = rng(`gate-${iso}`);
|
|
||||||
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
||||||
for (const topic of topics) {
|
const planned = SCHEDULE[date]?.gate;
|
||||||
const optional = await freshPool(db, topic, "optional");
|
if (planned) {
|
||||||
if (optional.length > 0) {
|
const { rows, missing } = await lookupProblems(db, planned);
|
||||||
picks.push({ p: sample(optional, 1, random)[0]!, fallback: false });
|
if (missing.length) {
|
||||||
continue;
|
return {
|
||||||
|
created: false,
|
||||||
|
reason: `planned gate names problems outside the catalog: LC ${missing.join(", LC ")}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const core = await freshPool(db, topic, "core");
|
picks.push(...rows.map((p) => ({ p, fallback: false })));
|
||||||
if (core.length > 0) picks.push({ p: sample(core, 1, random)[0]!, fallback: true });
|
} else {
|
||||||
// Both pools exhausted: the topic is fully solved; nothing to quiz.
|
let topics = Object.entries(SCHEDULE)
|
||||||
|
.filter(([d]) => campaignWeek(d) === week)
|
||||||
|
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||||
|
.flatMap(([, day]) => day.topics ?? []);
|
||||||
|
if (topics.length === 0) {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT DISTINCT topic_issue AS t FROM problems
|
||||||
|
WHERE set_label = 'optional' AND stage = 'new'
|
||||||
|
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||||
|
ORDER BY topic_issue`,
|
||||||
|
)
|
||||||
|
.all<{ t: number }>();
|
||||||
|
topics = sample(results.map((r) => r.t), MAX_GATE_PROBLEMS, rng(`gate-topics-${iso}`)).sort(
|
||||||
|
(a, b) => a - b,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
|
||||||
|
|
||||||
|
const random = rng(`gate-${iso}`);
|
||||||
|
for (const topic of topics) {
|
||||||
|
const optional = await freshPool(db, topic, "optional");
|
||||||
|
if (optional.length > 0) {
|
||||||
|
picks.push({ p: sample(optional, 1, random)[0]!, fallback: false });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const core = await freshPool(db, topic, "core");
|
||||||
|
if (core.length > 0) picks.push({ p: sample(core, 1, random)[0]!, fallback: true });
|
||||||
|
// 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` };
|
||||||
}
|
}
|
||||||
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.
|
// Recap: everything attempted this week, plus streak — data, not tasks.
|
||||||
const monday = weekMonday(date);
|
const monday = weekMonday(date);
|
||||||
@@ -154,7 +172,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
|||||||
|
|
||||||
const milestone = await db
|
const milestone = await db
|
||||||
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
|
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
|
||||||
.bind(topics[0])
|
.bind(picks[0]!.p.topic_issue)
|
||||||
.first<{ milestone: number }>();
|
.first<{ milestone: number }>();
|
||||||
|
|
||||||
const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
|
const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
|
||||||
|
|||||||
+37
-16
@@ -19,7 +19,9 @@ import { type Mirror, projectMirror } from "./mirror.ts";
|
|||||||
import {
|
import {
|
||||||
CAMPAIGN_START,
|
CAMPAIGN_START,
|
||||||
type LogOutcome,
|
type LogOutcome,
|
||||||
|
type Stage,
|
||||||
daysBetween,
|
daysBetween,
|
||||||
|
dueToday,
|
||||||
etDate,
|
etDate,
|
||||||
etHour,
|
etHour,
|
||||||
getProblem,
|
getProblem,
|
||||||
@@ -44,7 +46,7 @@ async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Pro
|
|||||||
try {
|
try {
|
||||||
await gh.closeIssue(
|
await gh.closeIssue(
|
||||||
outcome.issue,
|
outcome.issue,
|
||||||
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +2. Logged by the SRS Worker.`,
|
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +3. Logged by the SRS Worker.`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`close #${outcome.issue}: ${err}`);
|
console.warn(`close #${outcome.issue}: ${err}`);
|
||||||
@@ -96,7 +98,7 @@ async function handleTap(request: Request, env: Env, ctx: ExecutionContext): Pro
|
|||||||
}
|
}
|
||||||
ctx.waitUntil(mirrorOutcome(env, outcome, "email"));
|
ctx.waitUntil(mirrorOutcome(env, outcome, "email"));
|
||||||
return page(
|
return page(
|
||||||
result === "pass" ? "Logged ✅" : "Logged — back to +2",
|
result === "pass" ? "Logged ✅" : "Logged — back to +3",
|
||||||
outcomeLine(outcome),
|
outcomeLine(outcome),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -202,43 +204,56 @@ async function adminAuthorized(request: Request, env: Env): Promise<boolean> {
|
|||||||
/**
|
/**
|
||||||
* Solutions landing in `work/` are the third way a problem gets solved, after
|
* Solutions landing in `work/` are the third way a problem gets solved, after
|
||||||
* the digest tap and a /done comment. `close-solved` posts its whole
|
* the digest tap and a /done comment. `close-solved` posts its whole
|
||||||
* implemented set here on every push to main: logAttempt ignores anything
|
* implemented set here on every push to main, each entry tagged with the
|
||||||
* already on the ladder, so this is a total recompute like the reconcilers
|
* BUCKET the file sits in — 1 for the first solve, 3 and 7 for the blind
|
||||||
* that call it — a re-run changes nothing.
|
* re-solves the picker scaffolds. The bucket names the ladder rung the file
|
||||||
|
* settles, and logAttempt writes only when the problem is standing on that
|
||||||
|
* rung, so this is a total recompute like the reconcilers that call it: a
|
||||||
|
* re-run changes nothing, and a pushed re-solve advances +3 → +7 instead of
|
||||||
|
* being invisible.
|
||||||
*
|
*
|
||||||
* Closing the sub-issue stays with close-solved: it is the side that knows
|
* Closing the sub-issue stays with close-solved: it is the side that knows
|
||||||
* which files at which commit, so this path mirrors Project fields only.
|
* which files at which commit, so this path mirrors Project fields only.
|
||||||
* Charts read D1 live, so a push moves them within the 5-minute cache.
|
* Charts read D1 live, so a push moves them within the 5-minute cache.
|
||||||
*/
|
*/
|
||||||
|
const RUNG_OF_BUCKET: Record<number, Stage> = { 1: "new", 3: "+3", 7: "+7" };
|
||||||
|
|
||||||
async function handleSolved(request: Request, env: Env, date: string): Promise<Response> {
|
async function handleSolved(request: Request, env: Env, date: string): Promise<Response> {
|
||||||
const body: unknown = await request.json().catch(() => null);
|
const body: unknown = await request.json().catch(() => null);
|
||||||
const raw: unknown = body && typeof body === "object" && "lc" in body ? body.lc : null;
|
const raw: unknown = body && typeof body === "object" && "solved" in body ? body.solved : null;
|
||||||
if (!Array.isArray(raw)) return new Response('expected {"lc": [<number>, ...]}', { status: 400 });
|
const shape = 'expected {"solved": [{"lc": <number>, "bucket": 1|3|7}, ...]}';
|
||||||
const lcs: number[] = [];
|
if (!Array.isArray(raw)) return new Response(shape, { status: 400 });
|
||||||
|
const entries: { lc: number; rung: Stage; bucket: number }[] = [];
|
||||||
for (const item of raw) {
|
for (const item of raw) {
|
||||||
const lc: unknown = item;
|
if (!item || typeof item !== "object") return new Response(shape, { status: 400 });
|
||||||
|
const lc: unknown = "lc" in item ? item.lc : null;
|
||||||
|
const bucket: unknown = "bucket" in item ? item.bucket : null;
|
||||||
if (typeof lc !== "number" || !Number.isFinite(lc)) {
|
if (typeof lc !== "number" || !Number.isFinite(lc)) {
|
||||||
return new Response(`not an LC number: ${JSON.stringify(lc)}`, { status: 400 });
|
return new Response(`not an LC number: ${JSON.stringify(lc)}`, { status: 400 });
|
||||||
}
|
}
|
||||||
lcs.push(lc);
|
const rung = typeof bucket === "number" ? RUNG_OF_BUCKET[bucket] : undefined;
|
||||||
|
if (rung === undefined) return new Response(`not a work/ bucket: ${JSON.stringify(bucket)}`, { status: 400 });
|
||||||
|
entries.push({ lc, rung, bucket });
|
||||||
}
|
}
|
||||||
const dry = new URL(request.url).searchParams.get("dry") === "1";
|
const dry = new URL(request.url).searchParams.get("dry") === "1";
|
||||||
|
|
||||||
const logged: string[] = [];
|
const logged: string[] = [];
|
||||||
const skipped: string[] = [];
|
const skipped: string[] = [];
|
||||||
let mirror: Mirror | undefined;
|
let mirror: Mirror | undefined;
|
||||||
for (const lc of lcs) {
|
for (const { lc, rung, bucket } of entries) {
|
||||||
if (dry) {
|
if (dry) {
|
||||||
const p = await getProblem(env.DB, lc);
|
const p = await getProblem(env.DB, lc);
|
||||||
if (!p) skipped.push(`LC ${lc} is not in the curriculum`);
|
if (!p) skipped.push(`LC ${lc} is not in the curriculum`);
|
||||||
else if (p.stage !== "new") skipped.push(`LC ${lc}: already at stage ${p.stage}`);
|
else if (p.stage !== rung) skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${p.stage}`);
|
||||||
else logged.push(`LC ${lc}: would enter the ladder at +2`);
|
else if (rung === "new") logged.push(`LC ${lc}: would enter the ladder at +3`);
|
||||||
|
else logged.push(`LC ${lc}: would pass ${rung} and move on`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit" });
|
const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit", rung });
|
||||||
if (outcome.error) skipped.push(outcome.error);
|
if (outcome.error) skipped.push(outcome.error);
|
||||||
else if (outcome.duplicate) skipped.push(`LC ${lc}: already at stage ${outcome.stage}`);
|
else if (outcome.duplicate) {
|
||||||
else {
|
skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${outcome.stage}`);
|
||||||
|
} else {
|
||||||
logged.push(outcomeLine(outcome));
|
logged.push(outcomeLine(outcome));
|
||||||
// One mirror for the batch: field IDs resolve once, not per problem.
|
// One mirror for the batch: field IDs resolve once, not per problem.
|
||||||
mirror ??= projectMirror(github(env.GH_PAT, env.REPO), env.REPO.split("/")[0]!);
|
mirror ??= projectMirror(github(env.GH_PAT, env.REPO), env.REPO.split("/")[0]!);
|
||||||
@@ -271,6 +286,12 @@ async function handleAdmin(request: Request, env: Env, path: string): Promise<Re
|
|||||||
if (path === "/admin/review") {
|
if (path === "/admin/review") {
|
||||||
return Response.json(await createReviewIssue(env, gh(), date));
|
return Response.json(await createReviewIssue(env, gh(), date));
|
||||||
}
|
}
|
||||||
|
// What is due on `date`, straight out of the ladder — the ONE answer to
|
||||||
|
// "which problems are open today", so the daily review issue and the digest
|
||||||
|
// cannot disagree. Read-only; the caller decides what to render.
|
||||||
|
if (path === "/admin/due") {
|
||||||
|
return Response.json(await dueToday(env.DB, date));
|
||||||
|
}
|
||||||
return new Response("not found", { status: 404 });
|
return new Response("not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+148
-4
@@ -1,11 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* DST-guard and date-math checks with fixed instants — the acceptance proof
|
* Acceptance proof for the two rules that cannot be eyeballed in production:
|
||||||
* that the double crons fire exactly once at 8 AM ET (digest) and midnight
|
* the double crons fire exactly once at 8 AM ET (digest) and midnight Saturday
|
||||||
* Saturday ET (review issue) on BOTH UTC offsets.
|
* ET (review issue) on BOTH UTC offsets, and NOTHING is ever scheduled on the
|
||||||
|
* Sunday rest day — which is the whole reason the ladder's windows are 3 and 7.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
import { addDays, campaignDay, campaignWeek, etDate, etHour, isoWeek, rng, sample, weekdayOf } from "./srs.ts";
|
import {
|
||||||
|
INTERVAL,
|
||||||
|
RESERVED,
|
||||||
|
SCHEDULE,
|
||||||
|
STAGES,
|
||||||
|
WINDOWS,
|
||||||
|
addDays,
|
||||||
|
campaignDay,
|
||||||
|
campaignWeek,
|
||||||
|
etDate,
|
||||||
|
etHour,
|
||||||
|
isRestDay,
|
||||||
|
isoWeek,
|
||||||
|
rng,
|
||||||
|
sample,
|
||||||
|
TEMPERATURES,
|
||||||
|
temperatureOf,
|
||||||
|
topicDate,
|
||||||
|
weekdayOf,
|
||||||
|
workingDay,
|
||||||
|
} from "./srs.ts";
|
||||||
|
|
||||||
|
/** Every ET date of the campaign, plus the deferred-Hard tail. */
|
||||||
|
const CALENDAR = Array.from({ length: 70 }, (_, i) => addDays("2026-08-17", i));
|
||||||
|
|
||||||
describe("DST-proof cron guard", () => {
|
describe("DST-proof cron guard", () => {
|
||||||
test("digest fires only at ET hour 8 — EDT (UTC-4)", () => {
|
test("digest fires only at ET hour 8 — EDT (UTC-4)", () => {
|
||||||
@@ -50,6 +74,126 @@ describe("date math (noon-UTC anchored)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the rest day", () => {
|
||||||
|
test("Sunday is the only rest day", () => {
|
||||||
|
expect(isRestDay("2026-08-30")).toBe(true); // Sunday
|
||||||
|
expect(isRestDay("2026-08-29")).toBe(false); // Saturday: gate day, still work
|
||||||
|
expect(isRestDay("2026-08-31")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workingDay never returns a Sunday, from any day and any offset", () => {
|
||||||
|
for (const date of CALENDAR) {
|
||||||
|
for (let offset = 0; offset < 10; offset++) {
|
||||||
|
expect(isRestDay(workingDay(date, offset))).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workingDay(date) leaves a working day alone and slides Sunday to Monday", () => {
|
||||||
|
expect(workingDay("2026-09-04")).toBe("2026-09-04"); // Friday
|
||||||
|
expect(workingDay("2026-09-05")).toBe("2026-09-05"); // Saturday
|
||||||
|
expect(workingDay("2026-09-06")).toBe("2026-09-07"); // Sunday → Monday
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offsets count working days, so a spill skips the rest day", () => {
|
||||||
|
// Levelling from Saturday: tomorrow is Sunday, so the spill starts Monday.
|
||||||
|
expect(workingDay("2026-09-05", 1)).toBe("2026-09-07");
|
||||||
|
expect(workingDay("2026-09-05", 2)).toBe("2026-09-08");
|
||||||
|
// Two deferred Hards per working day from a Monday.
|
||||||
|
expect(workingDay("2026-09-28", 5)).toBe("2026-10-03"); // Saturday
|
||||||
|
expect(workingDay("2026-09-28", 6)).toBe("2026-10-05"); // Sunday skipped
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every ladder interval lands on a working day, from every learning day", () => {
|
||||||
|
for (const date of CALENDAR) {
|
||||||
|
if (isRestDay(date)) continue;
|
||||||
|
for (const days of WINDOWS) {
|
||||||
|
const review = workingDay(addDays(date, days));
|
||||||
|
expect(isRestDay(review)).toBe(false);
|
||||||
|
// Never earlier than the interval: a review may slip, never shorten.
|
||||||
|
expect(review >= addDays(date, days)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only Thursday's +3 needs the slide", () => {
|
||||||
|
const slid = CALENDAR.filter(
|
||||||
|
(date) => !isRestDay(date) && workingDay(addDays(date, 3)) !== addDays(date, 3),
|
||||||
|
);
|
||||||
|
expect(new Set(slid.map(weekdayOf))).toEqual(new Set([4])); // Thursday only
|
||||||
|
for (const date of CALENDAR) {
|
||||||
|
if (isRestDay(date)) continue;
|
||||||
|
expect(workingDay(addDays(date, 7))).toBe(addDays(date, 7)); // +7 keeps the weekday
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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"]);
|
||||||
|
expect(WINDOWS.map((w) => INTERVAL[`+${w}`])).toEqual([...WINDOWS]);
|
||||||
|
// Every non-terminal stage has an interval, and only those.
|
||||||
|
expect(Object.keys(INTERVAL).sort()).toEqual(STAGES.slice(1, -1).sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
describe("deterministic sampling", () => {
|
||||||
test("same seed, same picks", () => {
|
test("same seed, same picks", () => {
|
||||||
const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||||
|
|||||||
+267
-39
@@ -1,12 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* SRS domain: ET dates, the interval ladder, deterministic sampling, and the
|
* SRS domain: ET dates, the work week, the interval ladder, concept
|
||||||
* one write path for attempts — ported from scripts/srs.ts, re-homed on D1.
|
* 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
|
* D1 is the single source of truth. The stage names the review a problem must
|
||||||
* pass NEXT (`+2` = due 2 days after last clean solve). Passing advances
|
* pass NEXT (`+3` = due 3 working days after the last clean solve). Passing
|
||||||
* new → +2 → +5 → +10 → retired; any failure resets to +2. A problem's
|
* advances new → +3 → +7 → retired; any failure resets to +3. A problem's
|
||||||
* FIRST-ever log enters the ladder at +2 regardless of result: a pass earns
|
* FIRST-ever log enters the ladder at +3 regardless of result: a pass earns a
|
||||||
* a +2 review, a fail must be re-solved just as soon.
|
* 3-day review, a fail must be re-solved just as soon. Two rungs, 3 and 7 —
|
||||||
|
* the same numbers as the `work/3` and `work/7` buckets a re-solve is
|
||||||
|
* scaffolded into, and as the review-issue windows in
|
||||||
|
* apps/cli/spaced-repetition.ts.
|
||||||
|
*
|
||||||
|
* Sunday is off, structurally: every scheduled date comes out of
|
||||||
|
* workingDay(), which never returns one. Nothing "falls on" the rest day and
|
||||||
|
* gets swallowed or carried — it is simply never booked there.
|
||||||
*
|
*
|
||||||
* All dates are America/New_York calendar strings (YYYY-MM-DD); arithmetic is
|
* All dates are America/New_York calendar strings (YYYY-MM-DD); arithmetic is
|
||||||
* anchored at noon UTC so DST edges cannot shift a date. Never raw Date math.
|
* anchored at noon UTC so DST edges cannot shift a date. Never raw Date math.
|
||||||
@@ -56,6 +63,37 @@ export function weekdayOf(date: string): number {
|
|||||||
return atNoon(date).getUTCDay();
|
return atNoon(date).getUTCDay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── the work week ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sunday, the one day the campaign never schedules: topics run Mon–Fri
|
||||||
|
* (data/schedule.json), the gate is Saturday, and the review windows below are
|
||||||
|
* picked so a solve comes back on a working day.
|
||||||
|
*/
|
||||||
|
const REST_DAY = 0;
|
||||||
|
|
||||||
|
export function isRestDay(date: string): boolean {
|
||||||
|
return weekdayOf(date) === REST_DAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `offset`-th working day counting from `from` (0 = `from` itself), with
|
||||||
|
* Sundays skipped — the ONE place a scheduled date is minted.
|
||||||
|
*
|
||||||
|
* Work may slide later, never earlier: pulling a review back to Saturday would
|
||||||
|
* shorten the very interval it exists to test, so a Sunday landing becomes
|
||||||
|
* Monday. With the +3/+7 ladder that only ever happens to a Thursday solve's
|
||||||
|
* 3-day review; +7 lands on the solve's own weekday, which is never a Sunday.
|
||||||
|
*/
|
||||||
|
export function workingDay(from: string, offset = 0): string {
|
||||||
|
let cursor = isRestDay(from) ? addDays(from, 1) : from;
|
||||||
|
for (let i = 0; i < offset; i++) {
|
||||||
|
cursor = addDays(cursor, 1);
|
||||||
|
if (isRestDay(cursor)) cursor = addDays(cursor, 1);
|
||||||
|
}
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
export function campaignDay(date: string): number {
|
export function campaignDay(date: string): number {
|
||||||
return daysBetween(CAMPAIGN_START, date) + 1;
|
return daysBetween(CAMPAIGN_START, date) + 1;
|
||||||
}
|
}
|
||||||
@@ -88,16 +126,50 @@ export function prettyDate(date: string): string {
|
|||||||
|
|
||||||
// ── the schedule (bundled; humans edit apps/api/data/schedule.json) ───
|
// ── 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 const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
|
||||||
export function topicWeek(topic: number): number | undefined {
|
|
||||||
for (const [date, t] of Object.entries(SCHEDULE)) {
|
/** Date the schedule teaches a topic on, if it teaches it at all. */
|
||||||
if (t === topic) return campaignWeek(date);
|
export function topicDate(topic: number): string | undefined {
|
||||||
|
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||||
|
if (day.topics?.includes(topic)) return date;
|
||||||
}
|
}
|
||||||
return undefined;
|
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 ───────────────────────────────────────
|
// ── deterministic sampling ───────────────────────────────────────
|
||||||
|
|
||||||
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
|
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
|
||||||
@@ -126,10 +198,63 @@ export function sample<T>(pool: T[], n: number, random: () => number): T[] {
|
|||||||
|
|
||||||
// ── rows ─────────────────────────────────────────────────────────
|
// ── rows ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type Stage = "new" | "+2" | "+5" | "+10" | "retired";
|
/**
|
||||||
|
* Review windows in days. One vocabulary for the whole campaign: these are the
|
||||||
|
* ladder rungs, the `work/<n>` buckets the picker scaffolds into, and the
|
||||||
|
* windows the `Spaced Repetition — <date>` issues are built from.
|
||||||
|
*
|
||||||
|
* 3 and 7 keep the rest day free. +7 returns a solve to its own weekday, and
|
||||||
|
* of the five learning weekdays only Thursday's +3 touches a Sunday — which
|
||||||
|
* workingDay() slides to Monday.
|
||||||
|
*/
|
||||||
|
export const WINDOWS = [3, 7] as const;
|
||||||
|
export type Window = (typeof WINDOWS)[number];
|
||||||
|
|
||||||
|
export type Stage = "new" | `+${Window}` | "retired";
|
||||||
|
/** Chart / stats order, low to high. */
|
||||||
|
export const STAGES: Stage[] = ["new", "+3", "+7", "retired"];
|
||||||
export type Result = "pass" | "fail";
|
export type Result = "pass" | "fail";
|
||||||
export type Kind = "first" | "review" | "drill" | "gate";
|
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 {
|
export interface ProblemRow {
|
||||||
lc_number: number;
|
lc_number: number;
|
||||||
issue: number;
|
issue: number;
|
||||||
@@ -142,8 +267,8 @@ export interface ProblemRow {
|
|||||||
defer_until: string | null;
|
defer_until: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INTERVAL: Record<string, number> = { "+2": 2, "+5": 5, "+10": 10 };
|
export const INTERVAL: Record<string, number> = { "+3": 3, "+7": 7 };
|
||||||
const NEXT_STAGE: Record<string, Stage> = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" };
|
const NEXT_STAGE: Record<string, Stage> = { new: "+3", "+3": "+7", "+7": "retired" };
|
||||||
|
|
||||||
// ── queries ──────────────────────────────────────────────────────
|
// ── queries ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -151,31 +276,122 @@ export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow
|
|||||||
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<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
|
||||||
|
* this day" — the digest, the review issue, the docs queue chart — shares
|
||||||
|
* this clause so they cannot answer differently.
|
||||||
|
*/
|
||||||
|
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)`;
|
||||||
|
|
||||||
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
||||||
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(`SELECT * FROM problems WHERE ${DUE_WHERE} ORDER BY next_review, lc_number`)
|
||||||
`SELECT * FROM problems
|
|
||||||
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
|
||||||
AND (defer_until IS NULL OR defer_until <= ?1)
|
|
||||||
ORDER BY next_review, lc_number`,
|
|
||||||
)
|
|
||||||
.bind(date)
|
.bind(date)
|
||||||
.all<ProblemRow>();
|
.all<ProblemRow>();
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One problem the ladder wants re-solved today. */
|
||||||
|
export interface DueRow {
|
||||||
|
lc: number;
|
||||||
|
issue: number;
|
||||||
|
title: string;
|
||||||
|
difficulty: string;
|
||||||
|
/** The rung being settled: also the `work/<n>` bucket the re-solve goes in. */
|
||||||
|
stage: Stage;
|
||||||
|
bucket: number;
|
||||||
|
/** Scheduled day; earlier than `date` when a day was missed. */
|
||||||
|
due: string;
|
||||||
|
/** Days late (0 = due today), so a renderer can say so. */
|
||||||
|
late: number;
|
||||||
|
/** Last logged attempt, and how long ago — the "last seen" line. */
|
||||||
|
last_seen: string | null;
|
||||||
|
days_since: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the ladder wants re-solved on `date`, oldest first.
|
||||||
|
*
|
||||||
|
* The read side of "which problems are open today": the same rows the digest
|
||||||
|
* mails, shaped for whoever renders them (the daily Spaced Repetition issue).
|
||||||
|
* Nothing here writes, so it is safe to call repeatedly, and there is no
|
||||||
|
* second schedule to drift — D1's ladder is the schedule.
|
||||||
|
*/
|
||||||
|
export async function dueToday(db: D1Database, date: string): Promise<{ date: string; due: DueRow[] }> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT lc_number, issue, title, difficulty, stage, next_review,
|
||||||
|
(SELECT MAX(a.date) FROM attempts a WHERE a.lc_number = problems.lc_number) AS last_seen
|
||||||
|
FROM problems WHERE ${DUE_WHERE} ORDER BY next_review, lc_number`,
|
||||||
|
)
|
||||||
|
.bind(date)
|
||||||
|
.all<{
|
||||||
|
lc_number: number;
|
||||||
|
issue: number;
|
||||||
|
title: string;
|
||||||
|
difficulty: string;
|
||||||
|
stage: Stage;
|
||||||
|
next_review: string;
|
||||||
|
last_seen: string | null;
|
||||||
|
}>();
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
due: results.map((r) => ({
|
||||||
|
lc: r.lc_number,
|
||||||
|
issue: r.issue,
|
||||||
|
title: r.title,
|
||||||
|
difficulty: r.difficulty,
|
||||||
|
stage: r.stage,
|
||||||
|
// "+3" -> 3: the rung's number IS its work/ bucket.
|
||||||
|
bucket: Number(r.stage.slice(1)),
|
||||||
|
due: r.next_review,
|
||||||
|
late: daysBetween(r.next_review, date),
|
||||||
|
last_seen: r.last_seen,
|
||||||
|
days_since: r.last_seen === null ? null : daysBetween(r.last_seen, date),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Reviews surfaced per day; the digest levels everything past this forward. */
|
/** Reviews surfaced per day; the digest levels everything past this forward. */
|
||||||
export const REVIEW_CAP = 3;
|
export const REVIEW_CAP = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load-level an overloaded review queue: everything due beyond today's
|
* Load-level an overloaded review queue: everything due beyond today's
|
||||||
* REVIEW_CAP is pushed to a concrete future date — at most REVIEW_CAP per
|
* REVIEW_CAP is pushed to a concrete future WORKING day — at most REVIEW_CAP
|
||||||
* day, oldest first — instead of piling up as "due today". Runs every digest
|
* per day, oldest first — instead of piling up as "due today". Runs every
|
||||||
* morning, so a future day that grows past the cap (spill plus newly
|
* digest morning, so a future day that grows past the cap (spill plus newly
|
||||||
* maturing reviews) is simply re-levelled when it arrives. Idempotent within
|
* maturing reviews) is simply re-levelled when it arrives. Idempotent within
|
||||||
* a date: after one pass at most REVIEW_CAP problems remain due today, so a
|
* a date: after one pass at most REVIEW_CAP problems remain due today, so a
|
||||||
* second pass moves nothing.
|
* second pass moves nothing. The spill starts tomorrow, or Monday when
|
||||||
|
* tomorrow is the rest day.
|
||||||
*/
|
*/
|
||||||
export async function levelReviews(db: D1Database, date: string): Promise<number> {
|
export async function levelReviews(db: D1Database, date: string): Promise<number> {
|
||||||
const overflow = (await dueReviews(db, date)).slice(REVIEW_CAP);
|
const overflow = (await dueReviews(db, date)).slice(REVIEW_CAP);
|
||||||
@@ -184,7 +400,7 @@ export async function levelReviews(db: D1Database, date: string): Promise<number
|
|||||||
overflow.map((p, i) =>
|
overflow.map((p, i) =>
|
||||||
db
|
db
|
||||||
.prepare("UPDATE problems SET next_review = ? WHERE lc_number = ?")
|
.prepare("UPDATE problems SET next_review = ? WHERE lc_number = ?")
|
||||||
.bind(addDays(date, 1 + Math.floor(i / REVIEW_CAP)), p.lc_number),
|
.bind(workingDay(addDays(date, 1), Math.floor(i / REVIEW_CAP)), p.lc_number),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return overflow.length;
|
return overflow.length;
|
||||||
@@ -195,8 +411,9 @@ export async function levelReviews(db: D1Database, date: string): Promise<number
|
|||||||
* scheduled in an earlier week (the current week's optional pool is reserved
|
* 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
|
* 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
|
* topic's core problems has entered the ladder. A skipped learning day never
|
||||||
* feeds drills just because its calendar week lapsed. Never repeated,
|
* feeds drills just because its calendar week lapsed. Problems the plan names
|
||||||
* seeded by date. Boosted topics contribute up to 2 extra.
|
* outright (RESERVED) are never drawn. Never repeated, seeded by date.
|
||||||
|
* Boosted topics contribute up to 2 extra.
|
||||||
*/
|
*/
|
||||||
export async function pickDrills(
|
export async function pickDrills(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -219,6 +436,7 @@ export async function pickDrills(
|
|||||||
)
|
)
|
||||||
.all<ProblemRow & { boost: number }>();
|
.all<ProblemRow & { boost: number }>();
|
||||||
const pool = results.filter((p) => {
|
const pool = results.filter((p) => {
|
||||||
|
if (RESERVED.has(p.lc_number)) return false;
|
||||||
const w = topicWeek(p.topic_issue);
|
const w = topicWeek(p.topic_issue);
|
||||||
return w !== undefined && w < week;
|
return w !== undefined && w < week;
|
||||||
});
|
});
|
||||||
@@ -273,9 +491,14 @@ export interface LogOutcome {
|
|||||||
* Email idempotency comes from the partial unique index on
|
* Email idempotency comes from the partial unique index on
|
||||||
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
||||||
* nothing and must not touch the ladder. Webhook corrections (pass then fail
|
* nothing and must not touch the ladder. Webhook corrections (pass then fail
|
||||||
* on the same day) remain legal — every webhook attempt appends. Commit
|
* on the same day) remain legal — every webhook attempt appends.
|
||||||
* idempotency needs no index: a commit records a FIRST solve only, so the
|
*
|
||||||
* stage check below turns every re-push into a no-op.
|
* Commit idempotency needs no index either. A commit names the rung the file
|
||||||
|
* it landed in settles — `work/1` settles `new`, `work/3` settles `+3`,
|
||||||
|
* `work/7` settles `+7` — and the stage check below writes only when the
|
||||||
|
* ladder is actually standing on that rung. So re-pushing a solution, or the
|
||||||
|
* reconciler re-sending its whole implemented set on every push, moves
|
||||||
|
* nothing: the rung it names has already been left behind.
|
||||||
*/
|
*/
|
||||||
export async function logAttempt(
|
export async function logAttempt(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -284,6 +507,8 @@ export async function logAttempt(
|
|||||||
date: string;
|
date: string;
|
||||||
result: Result;
|
result: Result;
|
||||||
source: "email" | "webhook" | "commit";
|
source: "email" | "webhook" | "commit";
|
||||||
|
/** Commit only: the stage this file settles. Default `new` = first solve. */
|
||||||
|
rung?: Stage;
|
||||||
gate?: boolean;
|
gate?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<LogOutcome> {
|
): Promise<LogOutcome> {
|
||||||
@@ -300,11 +525,12 @@ export async function logAttempt(
|
|||||||
duplicate: false,
|
duplicate: false,
|
||||||
};
|
};
|
||||||
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
|
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
|
||||||
// A commit only ever reports a first solve: pushing the file again — or the
|
// A commit reports a file, not an event: it may only settle the rung that
|
||||||
// reconciler re-sending the whole solved set — must never move the ladder,
|
// file's bucket IS. Any other stage means the push is old news (the ladder
|
||||||
// and must never wake a retired problem. Reviews come from the digest tap,
|
// already moved past it) or premature — no write either way, and a retired
|
||||||
// a /done comment, or a gate.
|
// problem can never be woken. Fresh reviews otherwise come from the digest
|
||||||
if (opts.source === "commit" && p.stage !== "new") {
|
// tap, a /done comment, or a gate.
|
||||||
|
if (opts.source === "commit" && p.stage !== (opts.rung ?? "new")) {
|
||||||
return {
|
return {
|
||||||
...nothing,
|
...nothing,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
@@ -336,16 +562,18 @@ export async function logAttempt(
|
|||||||
return { ...nothing, title: p.title, kind, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true };
|
return { ...nothing, title: p.title, kind, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ladder move. First-ever logs enter at +2 for pass AND fail.
|
// Ladder move. First-ever logs enter at +3 for pass AND fail.
|
||||||
let stage: Stage;
|
let stage: Stage;
|
||||||
if (first) {
|
if (first) {
|
||||||
stage = "+2";
|
stage = "+3";
|
||||||
} else if (opts.result === "pass") {
|
} else if (opts.result === "pass") {
|
||||||
stage = NEXT_STAGE[p.stage] ?? "retired";
|
stage = NEXT_STAGE[p.stage] ?? "retired";
|
||||||
} else {
|
} else {
|
||||||
stage = "+2";
|
stage = "+3";
|
||||||
}
|
}
|
||||||
const next = stage === "retired" ? null : addDays(opts.date, INTERVAL[stage]!);
|
// workingDay(), not addDays(): a Thursday solve's +3 would land on the rest
|
||||||
|
// day, and it takes Monday instead.
|
||||||
|
const next = stage === "retired" ? null : workingDay(addDays(opts.date, INTERVAL[stage]!));
|
||||||
|
|
||||||
const writes = [
|
const writes = [
|
||||||
db.prepare(
|
db.prepare(
|
||||||
|
|||||||
+150
-33
@@ -1,17 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* GET /api/stats — the one JSON document behind the docs progress page.
|
* GET /api/stats — the one JSON document behind the docs progress page and the
|
||||||
* Read-only aggregation over D1; shape is the page's contract, change both
|
* `bun run stats` terminal dashboard. Read-only aggregation over D1; the shape
|
||||||
* together. A problem counts as "done" once it entered the ladder
|
* is both readers' contract, so change all three together. A problem counts as
|
||||||
* (stage != 'new').
|
* "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.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
CAMPAIGN_DAYS,
|
CAMPAIGN_DAYS,
|
||||||
CAMPAIGN_START,
|
CAMPAIGN_START,
|
||||||
|
DIFFICULTIES,
|
||||||
|
DUE_WHERE,
|
||||||
addDays,
|
addDays,
|
||||||
campaignDay,
|
campaignDay,
|
||||||
campaignWeek,
|
campaignWeek,
|
||||||
|
daysBetween,
|
||||||
isoWeek,
|
isoWeek,
|
||||||
|
STAGES,
|
||||||
streak,
|
streak,
|
||||||
|
temperatureOf,
|
||||||
|
topicDate,
|
||||||
|
type Temperature,
|
||||||
} from "./srs.ts";
|
} from "./srs.ts";
|
||||||
|
|
||||||
const PHASE_NAMES: Record<number, string> = {
|
const PHASE_NAMES: Record<number, string> = {
|
||||||
@@ -22,6 +34,91 @@ const PHASE_NAMES: Record<number, string> = {
|
|||||||
5: "V — Decision Space",
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
export async function buildStats(db: D1Database, today: string): Promise<object> {
|
||||||
const { results: phaseRows } = await db
|
const { results: phaseRows } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -59,9 +156,25 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
|||||||
const { results: ladderRows } = await db
|
const { results: ladderRows } = await db
|
||||||
.prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage")
|
.prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage")
|
||||||
.all<{ stage: string; n: number }>();
|
.all<{ stage: string; n: number }>();
|
||||||
const ladder: Record<string, number> = { new: 0, "+2": 0, "+5": 0, "+10": 0, retired: 0 };
|
// Every rung is present even at zero: the page's bar list is this object.
|
||||||
|
const ladder: Record<string, number> = Object.fromEntries(STAGES.map((s) => [s, 0]));
|
||||||
for (const row of ladderRows) ladder[row.stage] = row.n;
|
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
|
// 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
|
// the page speaks campaign weeks. The campaign never crosses a year
|
||||||
// boundary, so a plain offset converts safely.
|
// boundary, so a plain offset converts safely.
|
||||||
@@ -71,43 +184,47 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
|||||||
.all<{ week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }>();
|
.all<{ week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }>();
|
||||||
const gates = gateRows.map((g) => ({ ...g, week: g.week - isoWeekOffset }));
|
const gates = gateRows.map((g) => ({ ...g, week: g.week - isoWeekOffset }));
|
||||||
|
|
||||||
// Review-queue depth for the next 14 days: everything due by that day.
|
// Review-queue depth for the next 14 days: everything due by that day. One
|
||||||
const queue: { date: string; due: number }[] = [];
|
// statement per day, but DUE_WHERE stays the only definition of "due" — the
|
||||||
for (let i = 0; i < 14; i++) {
|
// batch is a single round trip, not fourteen.
|
||||||
const date = addDays(today, i);
|
const dates = Array.from({ length: 14 }, (_, i) => addDays(today, i));
|
||||||
const row = await db
|
const queueRows = await db.batch<{ n: number }>(
|
||||||
.prepare(
|
dates.map((date) =>
|
||||||
`SELECT COUNT(*) AS n FROM problems
|
db.prepare(`SELECT COUNT(*) AS n FROM problems WHERE ${DUE_WHERE}`).bind(date),
|
||||||
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
),
|
||||||
AND (defer_until IS NULL OR defer_until <= ?1)`,
|
);
|
||||||
)
|
const queue = dates.map((date, i) => ({ date, due: queueRows[i]?.results[0]?.n ?? 0 }));
|
||||||
.bind(date)
|
|
||||||
.first<{ n: number }>();
|
|
||||||
queue.push({ date, due: row?.n ?? 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const recent: { date: string; attempts: number; passes: number }[] = [];
|
// Attempts per campaign day, every day present. This is the heatmap's data
|
||||||
for (let i = 6; i >= 0; i--) {
|
// model — the docs page reads the tail of it for "last 7 days" rather than
|
||||||
const date = addDays(today, -i);
|
// asking a second question that could answer differently.
|
||||||
const row = await db
|
const end = addDays(CAMPAIGN_START, CAMPAIGN_DAYS - 1);
|
||||||
.prepare(
|
const { results: heatRows } = await db
|
||||||
`SELECT COUNT(*) AS attempts,
|
.prepare(
|
||||||
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes
|
`SELECT date,
|
||||||
FROM attempts WHERE date = ?`,
|
COUNT(*) AS attempts,
|
||||||
)
|
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes
|
||||||
.bind(date)
|
FROM attempts WHERE date BETWEEN ?1 AND ?2 GROUP BY 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 };
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generated: new Date().toISOString(),
|
generated: new Date().toISOString(),
|
||||||
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
|
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
|
||||||
phases: [...phases.values()],
|
phases: [...phases.values()],
|
||||||
ladder,
|
ladder,
|
||||||
|
difficulty,
|
||||||
|
topics: await buildTopics(db, today),
|
||||||
gates,
|
gates,
|
||||||
streak: await streak(db, today),
|
streak: await streak(db, today),
|
||||||
queue,
|
queue,
|
||||||
recent,
|
heat,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -32,8 +32,8 @@
|
|||||||
"REPO": "prdlk/leetcode",
|
"REPO": "prdlk/leetcode",
|
||||||
"FROM_EMAIL": "srs@prdlk.com",
|
"FROM_EMAIL": "srs@prdlk.com",
|
||||||
"TO_EMAIL": "prnk28@gmail.com",
|
"TO_EMAIL": "prnk28@gmail.com",
|
||||||
"DOCS_URL": "https://prdlk.github.io/leetcode",
|
"DOCS_URL": "https://lc.prd.lk",
|
||||||
"DOCS_ORIGIN": "https://prdlk.github.io,https://lc.prad.nu",
|
"DOCS_ORIGIN": "https://lc.prd.lk,https://prdlk.github.io",
|
||||||
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev"
|
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+31
-76
@@ -10,7 +10,7 @@
|
|||||||
*
|
*
|
||||||
* `bun run pick` scaffolds a statement header plus an empty function body, so
|
* `bun run pick` scaffolds a statement header plus an empty function body, so
|
||||||
* file existence alone means nothing — a stub must not close its issue. See
|
* file existence alone means nothing — a stub must not close its issue. See
|
||||||
* isImplemented().
|
* isImplemented() in source.ts.
|
||||||
*
|
*
|
||||||
* bun apps/cli/close-solved.ts # close matches, log them in D1
|
* bun apps/cli/close-solved.ts # close matches, log them in D1
|
||||||
* bun apps/cli/close-solved.ts --dry-run # report only, touch nothing
|
* bun apps/cli/close-solved.ts --dry-run # report only, touch nothing
|
||||||
@@ -19,68 +19,19 @@
|
|||||||
* SRS_ADMIN_KEY for the D1 push (unset = skip it); SRS_API overrides
|
* SRS_ADMIN_KEY for the D1 push (unset = skip it); SRS_API overrides
|
||||||
* the Worker URL for local `wrangler dev` runs.
|
* the Worker URL for local `wrangler dev` runs.
|
||||||
*/
|
*/
|
||||||
import { basename, join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { SRS_API } from "./api.ts";
|
||||||
import { github } from "./github.ts";
|
import { github } from "./github.ts";
|
||||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
|
import { isImplemented } from "./source.ts";
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
import { WORK, parseWorkPath, type Bucket } from "./work.ts";
|
||||||
const WORK = join(ROOT, "work");
|
|
||||||
|
|
||||||
const DRY =
|
const DRY =
|
||||||
process.argv.includes("--dry-run") ||
|
process.argv.includes("--dry-run") ||
|
||||||
process.env.DRY_RUN === "1" ||
|
process.env.DRY_RUN === "1" ||
|
||||||
process.env.DRY_RUN === "true";
|
process.env.DRY_RUN === "true";
|
||||||
|
|
||||||
// ── is the file a real solution or just a scaffolded stub? ────────
|
|
||||||
|
|
||||||
/** Placeholder bodies that leetcode-cli / a human leaves behind. */
|
|
||||||
const PLACEHOLDERS: Record<string, true> = { pass: true, "...": true, TODO: true };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide whether a work/ file contains an implementation.
|
|
||||||
*
|
|
||||||
* The header comment is dropped the same way sync.ts splitSource() does it
|
|
||||||
* (duplicated rather than imported, because sync.ts runs its whole pipeline on
|
|
||||||
* import). Comments must go before any brace analysis: the scaffold's JSDoc
|
|
||||||
* carries `@param {number[]}`, whose braces would otherwise read as a body.
|
|
||||||
*
|
|
||||||
* A brace-language file counts as implemented when at least one *innermost*
|
|
||||||
* brace pair holds real content. That distinguishes a bare stub
|
|
||||||
* (`function(nums) {}`) and a class-shaped design stub (every method body
|
|
||||||
* empty) from any genuine solution, whose innermost block always has code.
|
|
||||||
*/
|
|
||||||
function isImplemented(src: string, lang: "js" | "py"): boolean {
|
|
||||||
if (lang === "py") {
|
|
||||||
const open = src.indexOf('"""');
|
|
||||||
const close = src.indexOf('"""', open + 3);
|
|
||||||
const code = open === -1 || close === -1 ? src : src.slice(close + 3);
|
|
||||||
for (const raw of code.split("\n")) {
|
|
||||||
const line = raw.replace(/#.*$/, "").trim();
|
|
||||||
if (!line || PLACEHOLDERS[line]) continue;
|
|
||||||
if (/^(?:@|def\s|class\s)/.test(line)) continue;
|
|
||||||
return true; // a statement inside some def body
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerEnd = src.indexOf("*/");
|
|
||||||
const body = headerEnd === -1 ? src : src.slice(headerEnd + 2);
|
|
||||||
const code = body.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/.*$/gm, " ");
|
|
||||||
|
|
||||||
let innermost = -1;
|
|
||||||
for (let i = 0; i < code.length; i++) {
|
|
||||||
if (code[i] === "{") {
|
|
||||||
innermost = i;
|
|
||||||
} else if (code[i] === "}" && innermost !== -1) {
|
|
||||||
const inner = code.slice(innermost + 1, i).replace(/[\s;]/g, "");
|
|
||||||
if (inner && !PLACEHOLDERS[inner]) return true;
|
|
||||||
innermost = -1; // measured; the enclosing pair is not innermost
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── repo + auth ──────────────────────────────────────────────────
|
// ── repo + auth ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const gh = await github();
|
const gh = await github();
|
||||||
@@ -89,27 +40,27 @@ const gh = await github();
|
|||||||
|
|
||||||
interface WorkEntry {
|
interface WorkEntry {
|
||||||
files: string[];
|
files: string[];
|
||||||
|
/** Any bucket implemented — this is what closes the problem issue. */
|
||||||
implemented: boolean;
|
implemented: boolean;
|
||||||
|
/** Buckets holding a real solution: 1 = first solve, 3/7 = the re-solves. */
|
||||||
|
solved: Set<Bucket>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** LC number -> solution files (a problem may have both js and py). */
|
/** LC number -> its solution files across every bucket (js and py both count). */
|
||||||
const work = new Map<number, WorkEntry>();
|
const work = new Map<number, WorkEntry>();
|
||||||
for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
||||||
const m = basename(rel).match(/^(\d+)\.(.+)\.(?:js|py)$/);
|
const file = parseWorkPath(rel);
|
||||||
if (!m) {
|
if (!file) {
|
||||||
console.warn(`skip (unrecognized name): work/${rel}`);
|
console.warn(`skip (off-layout): work/${rel}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const num = Number(m[1]);
|
|
||||||
const src = await Bun.file(join(WORK, rel)).text();
|
const src = await Bun.file(join(WORK, rel)).text();
|
||||||
const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js");
|
const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js");
|
||||||
const entry = work.get(num);
|
const entry = work.get(file.lc) ?? { files: [], implemented: false, solved: new Set<Bucket>() };
|
||||||
if (entry) {
|
entry.files.push(`work/${rel}`);
|
||||||
entry.files.push(`work/${rel}`);
|
entry.implemented ||= implemented;
|
||||||
entry.implemented ||= implemented;
|
if (implemented) entry.solved.add(file.bucket);
|
||||||
} else {
|
work.set(file.lc, entry);
|
||||||
work.set(num, { files: [`work/${rel}`], implemented });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── open problem issues ──────────────────────────────────────────
|
// ── open problem issues ──────────────────────────────────────────
|
||||||
@@ -205,14 +156,18 @@ for (const num of [...work.keys()].sort((a, b) => a - b)) {
|
|||||||
// or the charts, the digest's "already solved" ticks, and the drill/gate
|
// or the charts, the digest's "already solved" ticks, and the drill/gate
|
||||||
// pools all keep treating it as untouched.
|
// pools all keep treating it as untouched.
|
||||||
//
|
//
|
||||||
// The whole implemented set goes over, not just this run's closes: the
|
// Each entry carries the BUCKET the file sits in, which is the rung it
|
||||||
// Worker drops anything already on the ladder, so the call is a total
|
// settles: work/1 is the first solve, work/3 the 3-day review, work/7 the
|
||||||
// recompute and backfills whatever earlier runs missed.
|
// 7-day one. That is what makes a pushed re-solve advance the ladder instead
|
||||||
const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
// of vanishing — and the Worker writes only when a problem is standing on the
|
||||||
|
// 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.
|
||||||
|
// SRS_API (apps/cli/api.ts) is the switch that points this at a wrangler dev
|
||||||
|
// instance instead of production.
|
||||||
const solved = [...work.entries()]
|
const solved = [...work.entries()]
|
||||||
.filter(([, entry]) => entry.implemented)
|
.flatMap(([lc, entry]) => [...entry.solved].sort().map((bucket) => ({ lc, bucket })))
|
||||||
.map(([lc]) => lc)
|
.sort((a, b) => a.lc - b.lc || a.bucket - b.bucket);
|
||||||
.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
let syncNote: string;
|
let syncNote: string;
|
||||||
let syncFailed = false;
|
let syncFailed = false;
|
||||||
@@ -228,7 +183,7 @@ if (!process.env.SRS_ADMIN_KEY) {
|
|||||||
authorization: `Bearer ${process.env.SRS_ADMIN_KEY}`,
|
authorization: `Bearer ${process.env.SRS_ADMIN_KEY}`,
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ lc: solved }),
|
body: JSON.stringify({ solved }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
||||||
// Narrow the report payload instead of casting it; a shape change should
|
// Narrow the report payload instead of casting it; a shape change should
|
||||||
@@ -257,7 +212,7 @@ printTable(rows);
|
|||||||
|
|
||||||
const actionable = report.filter((r) => r[3] !== "no open issue");
|
const actionable = report.filter((r) => r[3] !== "no open issue");
|
||||||
console.log(
|
console.log(
|
||||||
`\n${work.size} in work/ · ${solved.length} implemented · ` +
|
`\n${work.size} problems in work/ · ${solved.length} solved files across buckets · ` +
|
||||||
`${actionable.length} matched an open issue · ` +
|
`${actionable.length} matched an open issue · ` +
|
||||||
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}` +
|
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}` +
|
||||||
`\nd1: ${syncNote}`,
|
`\nd1: ${syncNote}`,
|
||||||
@@ -265,7 +220,7 @@ console.log(
|
|||||||
|
|
||||||
await writeStepSummary(
|
await writeStepSummary(
|
||||||
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
|
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
|
||||||
`${work.size} files in \`work/\`, ${solved.length} implemented, ` +
|
`${work.size} problems in \`work/\`, ${solved.length} solved files across buckets, ` +
|
||||||
`${actionable.length} matched an open issue.\n\n` +
|
`${actionable.length} matched an open issue.\n\n` +
|
||||||
(actionable.length
|
(actionable.length
|
||||||
? `${markdownTable([rows[0]!, ...actionable])}\n`
|
? `${markdownTable([rows[0]!, ...actionable])}\n`
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* `set:optional` and `set:deferred` are extra credit. A topic is finished when
|
* `set:optional` and `set:deferred` are extra credit. A topic is finished when
|
||||||
* every one of its core sub-issues is closed; optional/deferred state is
|
* every one of its core sub-issues is closed; optional/deferred state is
|
||||||
* ignored, matching the day rule in each topic body ("Core problems first").
|
* ignored, matching the day rule in each topic body ("Core problems first").
|
||||||
|
* The rule lives in topics.ts, shared with release-topics.ts.
|
||||||
*
|
*
|
||||||
* Like close-solved.ts this reconciles state instead of reacting to an event
|
* Like close-solved.ts this reconciles state instead of reacting to an event
|
||||||
* payload: re-runs are no-ops, backfilling needs no special casing, and only
|
* payload: re-runs are no-ops, backfilling needs no special casing, and only
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
import { github } from "./github.ts";
|
import { github } from "./github.ts";
|
||||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
|
import { coreSet, readProblems } from "./topics.ts";
|
||||||
|
|
||||||
const DRY =
|
const DRY =
|
||||||
process.argv.includes("--dry-run") ||
|
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);
|
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 ────────────────────────────────────────────────────
|
// ── reconcile ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const report: string[][] = [];
|
const report: string[][] = [];
|
||||||
let closed = 0;
|
let closed = 0;
|
||||||
|
|
||||||
for (const topic of topics) {
|
for (const topic of topics) {
|
||||||
const core = await readCore(topic.number);
|
const core = coreSet(await readProblems(gh, topic.number));
|
||||||
const progress = `${core.all.length - core.pending.length}/${core.all.length}`;
|
const progress = `${core.all.length - core.pending.length}/${core.all.length}`;
|
||||||
|
|
||||||
// A topic with no core sub-issues has nothing to complete: never close it,
|
// A topic with no core sub-issues has nothing to complete: never close it,
|
||||||
|
|||||||
@@ -4,15 +4,18 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"pick": "bun ./pick.ts",
|
"pick": "bun ./pick.ts",
|
||||||
|
"stats": "bun ./stats.ts",
|
||||||
"test": "bun ./test.ts",
|
"test": "bun ./test.ts",
|
||||||
"submit": "bun ./submit.ts",
|
"submit": "bun ./submit.ts",
|
||||||
"sync": "bun ./sync.ts",
|
"sync": "bun ./sync.ts",
|
||||||
|
"videos": "bun ./videos.ts",
|
||||||
"close-solved": "bun ./close-solved.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": {
|
"devDependencies": {
|
||||||
"@clack/prompts": "^1.7.0",
|
"@clack/prompts": "^1.7.0",
|
||||||
"@night-slayer18/leetcode-cli": "^3.5.0",
|
"@night-slayer18/leetcode-cli": "^3.5.2",
|
||||||
"@types/bun": "^1.3.14"
|
"@types/bun": "^1.3.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+186
-30
@@ -1,19 +1,24 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Fuzzy-pick a LeetCode problem and scaffold it via leetcode-cli.
|
* Fuzzy-pick a problem and scaffold it via leetcode-cli, in one of the three
|
||||||
|
* work/ buckets (see work.ts): a first solve under work/1, or the 3-day and
|
||||||
|
* 7-day blind re-solves under work/3 and work/7. Tab cycles the sections.
|
||||||
*
|
*
|
||||||
* The problem index lives in the local SQLite cache (see db.ts), refreshed at
|
* The problem index lives in the local SQLite cache (see db.ts), refreshed at
|
||||||
* most once per 24h; campaign problem descriptions from README.md are
|
* most once per 24h; campaign problem descriptions from README.md are
|
||||||
* backfilled into the same db on the way. Problems that already have a
|
* backfilled into the same db on the way. The new-problem section hides
|
||||||
* numbered file under work/ are excluded from the picker — re-picking them
|
* anything that already has a numbered file under work/1 — re-picking it would
|
||||||
* would only clobber the existing scaffold.
|
* only clobber the existing scaffold — and each review section hides whatever
|
||||||
|
* its own bucket already holds, so a finished re-solve leaves the list.
|
||||||
*/
|
*/
|
||||||
import { autocomplete, cancel, isCancel, spinner } from "@clack/prompts";
|
import { autocomplete, cancel, isCancel, log, spinner } from "@clack/prompts";
|
||||||
import { basename, join } from "node:path";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { fetchDescription, problemDb, readmeSlugs, type Problem } from "./db";
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
import { styleText } from "node:util";
|
||||||
const WORK_DIR = join(ROOT, "work");
|
import { etDate } from "../api/src/srs.ts";
|
||||||
|
import { fetchDescription, problemDb, readmeSlugs } from "./db";
|
||||||
|
import { ROOT, WINDOWS, bucketDir, reviewQueues, scanBucket, type Bucket } from "./work";
|
||||||
|
|
||||||
const db = problemDb();
|
const db = problemDb();
|
||||||
|
|
||||||
@@ -57,26 +62,110 @@ if (missing.length > 0) {
|
|||||||
s.stop(failed ? `Cached ${fetched} descriptions (${failed} failed, will retry next run)` : `Cached ${fetched} problem descriptions`);
|
s.stop(failed ? `Cached ${fetched} descriptions (${failed} failed, will retry next run)` : `Cached ${fetched} problem descriptions`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── exclude problems already picked or solved under work/ ────────
|
// ── the three sections ───────────────────────────────────────────
|
||||||
|
|
||||||
const done = new Set<number>();
|
/** What Enter scaffolds: this problem, into this bucket. */
|
||||||
for (const rel of new Bun.Glob("**/*.*").scanSync({ cwd: WORK_DIR })) {
|
interface Choice {
|
||||||
const m = basename(rel).match(/^(\d+)\./);
|
bucket: Bucket;
|
||||||
if (m) done.add(Number(m[1]));
|
lc: number;
|
||||||
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const problems = db.all().filter((p) => !done.has(p.id));
|
interface Section {
|
||||||
|
label: string;
|
||||||
|
options: { value: Choice; label: string; hint: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = scanBucket(1);
|
||||||
|
const problems = db.all();
|
||||||
|
const titles = new Map(problems.map((p) => [p.id, p.title] as const));
|
||||||
db.close();
|
db.close();
|
||||||
|
|
||||||
const picked = await autocomplete<Problem>({
|
const queues = reviewQueues(etDate(new Date()));
|
||||||
message: "Pick a problem",
|
|
||||||
placeholder: "Type to search by number or title...",
|
const sections: Section[] = [
|
||||||
maxItems: 12,
|
{
|
||||||
options: problems.map((p) => ({
|
label: "new",
|
||||||
value: p,
|
options: problems
|
||||||
label: `${p.id}. ${p.title}`,
|
.filter((p) => !first.has(p.id))
|
||||||
hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty,
|
.map((p) => ({
|
||||||
|
value: { bucket: 1 as Bucket, lc: p.id, slug: p.slug },
|
||||||
|
label: `${p.id}. ${p.title}`,
|
||||||
|
hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
...WINDOWS.map((window) => ({
|
||||||
|
label: `${window}-day`,
|
||||||
|
options: queues.get(window)!.map((owed) => ({
|
||||||
|
value: { bucket: window as Bucket, lc: owed.lc, slug: owed.slug },
|
||||||
|
label: `${owed.lc}. ${titles.get(owed.lc) ?? owed.slug}`,
|
||||||
|
hint: `${owed.difficulty} · ${owed.category} · solved ${owed.solved}, ${owed.days}d ago`,
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
let active = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slice of clack's AutocompletePrompt this picker drives. @clack/prompts
|
||||||
|
* re-exports neither the class nor its type, and apps/cli does not depend on
|
||||||
|
* @clack/core directly, so the contract is spelled out structurally.
|
||||||
|
*/
|
||||||
|
interface Autocomplete {
|
||||||
|
userInput: string;
|
||||||
|
on(event: "key", cb: (char: string | undefined, key: { name?: string }) => void): void;
|
||||||
|
emit(event: "userInput", input: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search text that cannot match any option — see refresh() below. */
|
||||||
|
const NO_MATCH = "\u0000";
|
||||||
|
|
||||||
|
let hooked = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind Tab to the section switch, once per prompt.
|
||||||
|
*
|
||||||
|
* clack has no hook for extra keys, but it calls `options` with the prompt as
|
||||||
|
* `this`, which is all a subscription needs. Switching sections also has to
|
||||||
|
* re-filter the list, and the prompt only recomputes `filteredOptions` when
|
||||||
|
* the search text *changes* — hence the pair of userInput events: an
|
||||||
|
* unmatchable string, then the real one, which recomputes against the new
|
||||||
|
* section, resets the cursor onto its first match and leaves the typed query
|
||||||
|
* on screen untouched. clack repaints as soon as the key handlers return.
|
||||||
|
*
|
||||||
|
* No `placeholder` is passed to the prompt on purpose: clack's own Tab binding
|
||||||
|
* (fill the input with the placeholder) is inert without one.
|
||||||
|
*/
|
||||||
|
function hookTab(prompt: Autocomplete): void {
|
||||||
|
if (hooked) return;
|
||||||
|
hooked = true;
|
||||||
|
prompt.on("key", (_char, key) => {
|
||||||
|
if (key.name !== "tab") return;
|
||||||
|
active = (active + 1) % sections.length;
|
||||||
|
prompt.emit("userInput", NO_MATCH);
|
||||||
|
prompt.emit("userInput", prompt.userInput);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widened, not asserted: clack resolves `undefined` when Enter lands on an
|
||||||
|
// empty list, which its own types do not admit.
|
||||||
|
const picked: Choice | symbol | undefined = await autocomplete<Choice>({
|
||||||
|
// Getter, not a string: clack reads `message` on every repaint, so the tab
|
||||||
|
// bar tracks the active section.
|
||||||
|
get message(): string {
|
||||||
|
const bar = sections
|
||||||
|
.map((section, i) => {
|
||||||
|
const text = `${section.label} (${section.options.length})`;
|
||||||
|
return i === active ? styleText("cyan", `▸ ${text}`) : styleText("dim", text);
|
||||||
|
})
|
||||||
|
.join(styleText("dim", " · "));
|
||||||
|
return `Pick a problem ${bar} ${styleText("dim", "Tab: next section")}`;
|
||||||
|
},
|
||||||
|
maxItems: 12,
|
||||||
|
options(this: Autocomplete) {
|
||||||
|
hookTab(this);
|
||||||
|
return sections[active]!.options;
|
||||||
|
},
|
||||||
filter: (search, option) => {
|
filter: (search, option) => {
|
||||||
const haystack = option.label!.toLowerCase();
|
const haystack = option.label!.toLowerCase();
|
||||||
return search
|
return search
|
||||||
@@ -90,10 +179,77 @@ if (isCancel(picked)) {
|
|||||||
cancel("Nothing picked.");
|
cancel("Nothing picked.");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
if (picked === undefined) {
|
||||||
|
// Enter on an empty section: nothing was focused, so there is nothing to do.
|
||||||
|
cancel(`Nothing owed in the ${sections[active]!.label} section.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const proc = Bun.spawn(
|
// ── scaffold into the picked bucket ──────────────────────────────
|
||||||
// bun installs workspace-dep bins into this package's node_modules, not the root's
|
|
||||||
[join(import.meta.dir, "node_modules", ".bin", "leetcode"), "pick", picked.slug],
|
// The active workspace names the config file; everything else about it belongs
|
||||||
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
|
// to leetcode-cli.
|
||||||
);
|
const registry = join(homedir(), ".leetcode", "workspaces.json");
|
||||||
process.exit(await proc.exited);
|
let workspace = "default";
|
||||||
|
if (existsSync(registry)) {
|
||||||
|
const parsed: unknown = JSON.parse(readFileSync(registry, "utf8"));
|
||||||
|
if (parsed && typeof parsed === "object" && "active" in parsed && typeof parsed.active === "string") {
|
||||||
|
workspace = parsed.active || workspace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const configPath = join(homedir(), ".leetcode", "workspaces", workspace, "config.json");
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
log.error(`No leetcode-cli config at ${configPath} — run \`leetcode config -i\` once.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point leetcode-cli at one bucket. It reads its output directory from the
|
||||||
|
* active workspace config and from nowhere else — no flag, no environment
|
||||||
|
* override — so `workDir` is the whole handover.
|
||||||
|
*
|
||||||
|
* Each write MERGES the file as it is on disk at that moment; a saved copy is
|
||||||
|
* never played back. The CLI owns this file too (theme, language, whatever a
|
||||||
|
* `leetcode login` adds), and replaying stale bytes over it would silently
|
||||||
|
* undo what it wrote while the child ran. After the pick, `workDir` is set to
|
||||||
|
* work/1 instead of to whatever it was before: the first-solve bucket is the
|
||||||
|
* one resting value that is always right, so even a killed run leaves a bare
|
||||||
|
* `leetcode pick` on-layout.
|
||||||
|
*/
|
||||||
|
function setWorkDir(target: string): void {
|
||||||
|
const raw: unknown = JSON.parse(readFileSync(configPath, "utf8"));
|
||||||
|
if (raw === null || typeof raw !== "object") {
|
||||||
|
log.error(`${configPath} holds no JSON object — run \`leetcode config -i\` to rewrite it.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
// 2-space JSON with a trailing newline: leetcode-cli's own format.
|
||||||
|
writeFileSync(configPath, `${JSON.stringify({ ...raw, workDir: target }, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = bucketDir(picked.bucket);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
setWorkDir(dir);
|
||||||
|
|
||||||
|
let code: number;
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
// bun installs workspace-dep bins into this package's node_modules, not the root's
|
||||||
|
[join(import.meta.dir, "node_modules", ".bin", "leetcode"), "pick", picked.slug],
|
||||||
|
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
|
||||||
|
);
|
||||||
|
code = await proc.exited;
|
||||||
|
} finally {
|
||||||
|
setWorkDir(bucketDir(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// leetcode-cli exits 0 even when it wrote nothing (an expired session only
|
||||||
|
// prints a warning), so the scaffold itself is the acceptance test: without a
|
||||||
|
// file for this problem in the bucket, the pick failed.
|
||||||
|
if (!scanBucket(picked.bucket).has(picked.lc)) {
|
||||||
|
log.error(
|
||||||
|
`leetcode-cli scaffolded nothing for LC ${picked.lc} under work/${picked.bucket}/.\n` +
|
||||||
|
"If it printed “Session expired”, run `leetcode login` and pick again.",
|
||||||
|
);
|
||||||
|
process.exit(code === 0 ? 1 : code);
|
||||||
|
}
|
||||||
|
process.exit(code);
|
||||||
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")}` : ""),
|
||||||
|
);
|
||||||
+39
-22
@@ -2,6 +2,11 @@
|
|||||||
* Shared work/ solution picker for the test and submit entries: fuzzy-select
|
* Shared work/ solution picker for the test and submit entries: fuzzy-select
|
||||||
* a solution file, then hand it to leetcode-cli.
|
* a solution file, then hand it to leetcode-cli.
|
||||||
*
|
*
|
||||||
|
* Every bucket is listed — a 3-day or 7-day re-solve is tested and submitted
|
||||||
|
* exactly like a first solve — and the hint says which one a file belongs to.
|
||||||
|
* The db's per-solution timestamps are keyed by LC number, so the buckets of
|
||||||
|
* one problem share them.
|
||||||
|
*
|
||||||
* Every run backfills the db's solutions table with created_at (from the
|
* Every run backfills the db's solutions table with created_at (from the
|
||||||
* file's birthtime, mtime when the filesystem has none) so ordering works for
|
* file's birthtime, mtime when the filesystem has none) so ordering works for
|
||||||
* files that predate the db. Ordering is per command — test lists the most
|
* files that predate the db. Ordering is per command — test lists the most
|
||||||
@@ -16,9 +21,7 @@ import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
|
|||||||
import { statSync } from "node:fs";
|
import { statSync } from "node:fs";
|
||||||
import { basename, join, relative } from "node:path";
|
import { basename, join, relative } from "node:path";
|
||||||
import { problemDb } from "./db";
|
import { problemDb } from "./db";
|
||||||
|
import { ROOT, WORK as WORK_DIR, parseWorkPath } from "./work";
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
|
||||||
const WORK_DIR = join(ROOT, "work");
|
|
||||||
|
|
||||||
/** Leading LC number of a work/ file, or undefined for unnumbered names. */
|
/** Leading LC number of a work/ file, or undefined for unnumbered names. */
|
||||||
function lcNumber(rel: string): number | undefined {
|
function lcNumber(rel: string): number | undefined {
|
||||||
@@ -27,17 +30,27 @@ function lcNumber(rel: string): number | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
|
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
|
||||||
export async function runOnSolution(command: "test" | "submit", message: string): Promise<never> {
|
export async function runOnSolution(
|
||||||
|
command: "test" | "submit",
|
||||||
|
message: string,
|
||||||
|
): Promise<never> {
|
||||||
const db = problemDb();
|
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
|
// register creation times so files older than the db still sort correctly
|
||||||
for (const f of scanned) {
|
for (const f of scanned) {
|
||||||
const num = lcNumber(f);
|
const num = lcNumber(f);
|
||||||
if (num === undefined) continue;
|
if (num === undefined) continue;
|
||||||
const stat = statSync(join(WORK_DIR, f));
|
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();
|
const activity = db.solutionActivity();
|
||||||
|
|
||||||
@@ -48,20 +61,21 @@ export async function runOnSolution(command: "test" | "submit", message: string)
|
|||||||
return (command === "test" ? row?.lastTested : row?.createdAt) ?? undefined;
|
return (command === "test" ? row?.lastTested : row?.createdAt) ?? undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const files = scanned
|
const files = scanned.sort((a, b) => {
|
||||||
.sort((a, b) => {
|
const ka = sortKey(a);
|
||||||
const ka = sortKey(a);
|
const kb = sortKey(b);
|
||||||
const kb = sortKey(b);
|
if (ka !== kb) {
|
||||||
if (ka !== kb) {
|
if (ka === undefined) return 1;
|
||||||
if (ka === undefined) return 1;
|
if (kb === undefined) return -1;
|
||||||
if (kb === undefined) return -1;
|
return kb.localeCompare(ka); // ISO timestamps: lexicographic = chronological
|
||||||
return kb.localeCompare(ka); // ISO timestamps: lexicographic = chronological
|
}
|
||||||
}
|
return a.localeCompare(b, undefined, { numeric: true });
|
||||||
return a.localeCompare(b, undefined, { numeric: true });
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (files.length === 0) {
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,12 +84,15 @@ export async function runOnSolution(command: "test" | "submit", message: string)
|
|||||||
placeholder: "Type to search...",
|
placeholder: "Type to search...",
|
||||||
maxItems: 12,
|
maxItems: 12,
|
||||||
options: files.map((f) => {
|
options: files.map((f) => {
|
||||||
// work layout: Difficulty/Category/<id>.<slug>.<ext>
|
const parsed = parseWorkPath(f);
|
||||||
const [difficulty, category, name] = f.split("/");
|
const stage =
|
||||||
|
parsed?.bucket === 1 ? "first solve" : `${parsed?.bucket}-day review`;
|
||||||
return {
|
return {
|
||||||
value: f,
|
value: f,
|
||||||
label: name ?? f,
|
label: basename(f),
|
||||||
hint: category ? `${difficulty} · ${category}` : difficulty,
|
hint: parsed
|
||||||
|
? `${stage} · ${parsed.difficulty} · ${parsed.category}`
|
||||||
|
: f,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
filter: (search, option) => {
|
filter: (search, option) => {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Is a work/ file a real solution or just a scaffolded stub?
|
||||||
|
*
|
||||||
|
* `bun run pick` writes a statement header plus an empty function body, so
|
||||||
|
* file existence alone means nothing: close-solved.ts must not close an issue
|
||||||
|
* on a stub, and work.ts must not ask for the spaced-repetition re-solve of a
|
||||||
|
* problem that was never solved in the first place.
|
||||||
|
*
|
||||||
|
* sync.ts keeps its own copy of the header split — it runs its whole pipeline
|
||||||
|
* on import, so it cannot be imported from. Don't "deduplicate" that one.
|
||||||
|
*
|
||||||
|
* Library: side-effect-free on import.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Placeholder bodies that leetcode-cli / a human leaves behind. */
|
||||||
|
const PLACEHOLDERS: Record<string, true> = { pass: true, "...": true, TODO: true };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a work/ file contains an implementation.
|
||||||
|
*
|
||||||
|
* The header comment is dropped the same way sync.ts splitSource() does it.
|
||||||
|
* Comments must go before any brace analysis: the scaffold's JSDoc carries
|
||||||
|
* `@param {number[]}`, whose braces would otherwise read as a body.
|
||||||
|
*
|
||||||
|
* A brace-language file counts as implemented when at least one *innermost*
|
||||||
|
* brace pair holds real content. That distinguishes a bare stub
|
||||||
|
* (`function(nums) {}`) and a class-shaped design stub (every method body
|
||||||
|
* empty) from any genuine solution, whose innermost block always has code.
|
||||||
|
* Every non-Python language the campaign has ever used is brace-shaped, so
|
||||||
|
* "not .py" is the only branch that matters.
|
||||||
|
*/
|
||||||
|
export function isImplemented(src: string, lang: "js" | "py"): boolean {
|
||||||
|
if (lang === "py") {
|
||||||
|
const open = src.indexOf('"""');
|
||||||
|
const close = src.indexOf('"""', open + 3);
|
||||||
|
const code = open === -1 || close === -1 ? src : src.slice(close + 3);
|
||||||
|
for (const raw of code.split("\n")) {
|
||||||
|
const line = raw.replace(/#.*$/, "").trim();
|
||||||
|
if (!line || PLACEHOLDERS[line]) continue;
|
||||||
|
if (/^(?:@|def\s|class\s)/.test(line)) continue;
|
||||||
|
return true; // a statement inside some def body
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerEnd = src.indexOf("*/");
|
||||||
|
const body = headerEnd === -1 ? src : src.slice(headerEnd + 2);
|
||||||
|
const code = body.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/.*$/gm, " ");
|
||||||
|
|
||||||
|
let innermost = -1;
|
||||||
|
for (let i = 0; i < code.length; i++) {
|
||||||
|
if (code[i] === "{") {
|
||||||
|
innermost = i;
|
||||||
|
} else if (code[i] === "}" && innermost !== -1) {
|
||||||
|
const inner = code.slice(innermost + 1, i).replace(/[\s;]/g, "");
|
||||||
|
if (inner && !PLACEHOLDERS[inner]) return true;
|
||||||
|
innermost = -1; // measured; the enclosing pair is not innermost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
Executable
+384
@@ -0,0 +1,384 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Open today's spaced-repetition issue: whatever the SRS ladder has due,
|
||||||
|
* linked straight to LeetCode.
|
||||||
|
*
|
||||||
|
* D1 is the schedule and this script is a renderer. `POST /admin/due` returns
|
||||||
|
* exactly the rows the 8 AM digest mails — reviews standing at the `+3` and
|
||||||
|
* `+7` rungs, oldest first — so the issue and the email can never disagree
|
||||||
|
* about a day, and there is no second scheduler to drift. The rung is also
|
||||||
|
* the `work/<n>` bucket the blind re-solve belongs in, which is the section
|
||||||
|
* `bun run pick` opens on Tab.
|
||||||
|
*
|
||||||
|
* How a problem gets here: closing its issue (a push, a digest tap, a `/done`
|
||||||
|
* comment) enters it on the ladder at `+3` with a concrete next_review, and
|
||||||
|
* passing that review moves it to `+7`. Nothing is "activated" by this script
|
||||||
|
* — it asks what is standing today and writes it down. A day's issue is keyed
|
||||||
|
* by its title (`Spaced Repetition — <Month D, YYYY>`), so re-runs rewrite
|
||||||
|
* that one body instead of stacking duplicates.
|
||||||
|
*
|
||||||
|
* Sunday writes nothing: the ladder never schedules the rest day (WINDOWS of
|
||||||
|
* 3 and 7 exist for exactly that reason, see apps/api/src/srs.ts), so there is
|
||||||
|
* nothing to render. Each rung shows at most WINDOW_CAP problems, with the
|
||||||
|
* overflow listed as optional — the digest's leveller moves that overflow to
|
||||||
|
* later working days the next morning.
|
||||||
|
*
|
||||||
|
* Links: D1 stores titles, not URLs, so each due problem's issue body is read
|
||||||
|
* for the canonical LeetCode link. A body with no URL falls back to a slug
|
||||||
|
* guessed from the title and is flagged in the report, because repo titles are
|
||||||
|
* sometimes abbreviated ("LC 167 · Two Sum II") and a guess can 404.
|
||||||
|
*
|
||||||
|
* bun apps/cli/spaced-repetition.ts # create/update today's issue
|
||||||
|
* bun apps/cli/spaced-repetition.ts --dry-run # print the body, touch nothing
|
||||||
|
* bun apps/cli/spaced-repetition.ts --date=2026-09-02 # pretend it is that ET day
|
||||||
|
*
|
||||||
|
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
||||||
|
* SRS_ADMIN_KEY reads the due list; SRS_API overrides the Worker URL for
|
||||||
|
* local `wrangler dev` runs.
|
||||||
|
*/
|
||||||
|
import { WINDOWS, etDate, isRestDay } from "../api/src/srs.ts";
|
||||||
|
import { github } from "./github.ts";
|
||||||
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
|
|
||||||
|
const DRY =
|
||||||
|
process.argv.includes("--dry-run") ||
|
||||||
|
process.env.DRY_RUN === "1" ||
|
||||||
|
process.env.DRY_RUN === "true";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required re-solves per window: 3 + 3 on top of the day's 3 new core problems
|
||||||
|
* is already a 90-minute session. A window that closed more than this (the
|
||||||
|
* catalog backfill closed 19 issues in one day) keeps the rest listed under a
|
||||||
|
* collapsed "optional" block rather than pretending they were never due.
|
||||||
|
*/
|
||||||
|
const WINDOW_CAP = 3;
|
||||||
|
|
||||||
|
const LABEL = "spaced-repetition";
|
||||||
|
|
||||||
|
// ── ET calendar dates ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Every date here is an ET calendar string (YYYY-MM-DD): the campaign runs on
|
||||||
|
// ET days, and an issue closed at 21:30 ET belongs to that ET day, not to the
|
||||||
|
// next UTC one. The helpers come from the Worker's SRS domain so this script,
|
||||||
|
// the picker and D1 cannot disagree about a date, a window, or the rest day.
|
||||||
|
|
||||||
|
const LONG = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: "UTC",
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** `2026-08-26` -> `August 26, 2026`, or with the weekday prefix. */
|
||||||
|
function longDate(date: string, weekday = false): string {
|
||||||
|
// Noon-UTC anchor, like every date in srs.ts: no DST edge can move the day.
|
||||||
|
const text = LONG.format(new Date(`${date}T12:00:00Z`));
|
||||||
|
return weekday ? text : text.slice(text.indexOf(", ") + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── today ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// `||`, not `??`: the workflow passes SRS_TODAY="" on scheduled runs, where the
|
||||||
|
// input has no value.
|
||||||
|
const override = process.argv.find((a) => a.startsWith("--date="))?.slice(7) || process.env.SRS_TODAY;
|
||||||
|
if (override && !/^\d{4}-\d{2}-\d{2}$/.test(override)) {
|
||||||
|
throw new Error(`--date wants YYYY-MM-DD, got ${override}`);
|
||||||
|
}
|
||||||
|
const today = override || etDate(new Date());
|
||||||
|
|
||||||
|
// Sunday is the campaign's rest day and the reason the windows are 3 and 7:
|
||||||
|
// nothing is ever booked on it, so there is no issue to write. The cron still
|
||||||
|
// fires — the guard lives here, not in the workflow, so a manual run agrees.
|
||||||
|
if (isRestDay(today)) {
|
||||||
|
console.log(`${today} is the rest day — no spaced-repetition issue.`);
|
||||||
|
await writeStepSummary(`### Spaced Repetition — ${longDate(today)}\n\nRest day: nothing scheduled.\n`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── what is due today (D1, via the Worker) ───────────────────────
|
||||||
|
|
||||||
|
// The ladder is the schedule: `POST /admin/due` returns exactly the rows the
|
||||||
|
// morning digest mails, so the issue and the email can never disagree about a
|
||||||
|
// day. This script adds no scheduling of its own — it renders, caps, and
|
||||||
|
// links.
|
||||||
|
const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
||||||
|
|
||||||
|
/** One problem the ladder wants re-solved today, as the Worker reports it. */
|
||||||
|
interface Due {
|
||||||
|
lc: number;
|
||||||
|
issue: number;
|
||||||
|
title: string;
|
||||||
|
difficulty: string;
|
||||||
|
/** The rung being settled: "+3" or "+7". */
|
||||||
|
stage: string;
|
||||||
|
/** The rung's number — also the `work/<n>` bucket the re-solve belongs in. */
|
||||||
|
bucket: number;
|
||||||
|
due: string;
|
||||||
|
late: number;
|
||||||
|
last_seen: string | null;
|
||||||
|
days_since: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDue(value: unknown): Due | undefined {
|
||||||
|
if (!value || typeof value !== "object") return;
|
||||||
|
const num = (key: string): number | undefined =>
|
||||||
|
key in value && typeof (value as Record<string, unknown>)[key] === "number"
|
||||||
|
? ((value as Record<string, unknown>)[key] as number)
|
||||||
|
: undefined;
|
||||||
|
const str = (key: string): string | undefined =>
|
||||||
|
key in value && typeof (value as Record<string, unknown>)[key] === "string"
|
||||||
|
? ((value as Record<string, unknown>)[key] as string)
|
||||||
|
: undefined;
|
||||||
|
const lc = num("lc");
|
||||||
|
const issue = num("issue");
|
||||||
|
const bucket = num("bucket");
|
||||||
|
const stage = str("stage");
|
||||||
|
const title = str("title");
|
||||||
|
const dueOn = str("due");
|
||||||
|
if (lc === undefined || issue === undefined || bucket === undefined) return;
|
||||||
|
if (stage === undefined || title === undefined || dueOn === undefined) return;
|
||||||
|
const difficulty = str("difficulty") ?? "unrated";
|
||||||
|
return {
|
||||||
|
lc,
|
||||||
|
issue,
|
||||||
|
title,
|
||||||
|
difficulty: difficulty.charAt(0).toUpperCase() + difficulty.slice(1),
|
||||||
|
stage,
|
||||||
|
bucket,
|
||||||
|
due: dueOn,
|
||||||
|
late: num("late") ?? 0,
|
||||||
|
last_seen: str("last_seen") ?? null,
|
||||||
|
days_since: num("days_since") ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.SRS_ADMIN_KEY) {
|
||||||
|
// Failing loudly beats writing an issue from a second, guessed schedule:
|
||||||
|
// there is only one schedule now, and it lives in D1.
|
||||||
|
console.error("SRS_ADMIN_KEY unset — cannot read today's due list from the Worker.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const res = await fetch(`${SRS_API}/admin/due?date=${today}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${process.env.SRS_ADMIN_KEY}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`GET due list -> ${res.status} ${await res.text()}`);
|
||||||
|
const payload: unknown = await res.json();
|
||||||
|
const dueRaw: unknown = payload && typeof payload === "object" && "due" in payload ? payload.due : null;
|
||||||
|
const due: Due[] = (Array.isArray(dueRaw) ? dueRaw : []).flatMap((r) => {
|
||||||
|
const row = readDue(r);
|
||||||
|
return row ? [row] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── repo + auth ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const gh = await github();
|
||||||
|
|
||||||
|
// ── canonical LeetCode links ─────────────────────────────────────
|
||||||
|
|
||||||
|
const LC_URL = /https:\/\/leetcode\.com\/problems\/[a-z0-9-]+/;
|
||||||
|
|
||||||
|
/** LeetCode's own slug shape, for bodies that lost their URL line. */
|
||||||
|
function slugify(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D1 stores titles, not URLs, so each due problem's issue body is read for the
|
||||||
|
* canonical LeetCode link written by the campaign-issues convention. That is
|
||||||
|
* one request per due problem — at most REVIEW_CAP per rung — instead of the
|
||||||
|
* old walk over every closed issue in the repo. A body with no URL falls back
|
||||||
|
* to a slug guessed from the title and is flagged in the report, because repo
|
||||||
|
* titles are sometimes abbreviated ("LC 167 · Two Sum II") and a guess can 404.
|
||||||
|
*/
|
||||||
|
interface Linked extends Due {
|
||||||
|
url: string;
|
||||||
|
linked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function link(row: Due): Promise<Linked> {
|
||||||
|
let url: string | undefined;
|
||||||
|
try {
|
||||||
|
const issue: unknown = await gh.api(`/repos/${gh.repo}/issues/${row.issue}`);
|
||||||
|
const body =
|
||||||
|
issue && typeof issue === "object" && "body" in issue && typeof issue.body === "string"
|
||||||
|
? issue.body
|
||||||
|
: "";
|
||||||
|
url = LC_URL.exec(body)?.[0];
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`#${row.issue}: could not read the issue body (${err})`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
url: `${url ?? `https://leetcode.com/problems/${slugify(row.title)}`}/`,
|
||||||
|
linked: url !== undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── issue body ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const title = `Spaced Repetition — ${longDate(today)}`;
|
||||||
|
|
||||||
|
/** The window's due problems, capped, oldest scheduled first. */
|
||||||
|
const byWindow = new Map<number, Linked[]>();
|
||||||
|
for (const days of WINDOWS) {
|
||||||
|
const rows = due.filter((d) => d.bucket === days);
|
||||||
|
byWindow.set(days, await Promise.all(rows.map(link)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucket(days: number): Linked[] {
|
||||||
|
return byWindow.get(days) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One checkbox line, straight to LeetCode. */
|
||||||
|
function line(s: Linked, box: boolean): string {
|
||||||
|
const seen = s.days_since === null ? "unlogged" : `last seen ${s.days_since}d ago`;
|
||||||
|
return (
|
||||||
|
`${box ? "- [ ] " : "- "}[LC ${s.lc} · ${s.title}](${s.url}) · ${s.difficulty} · #${s.issue}` +
|
||||||
|
` · ${seen}${s.late > 0 ? ` · ⏰ ${s.late}d late` : ""}` +
|
||||||
|
(s.linked ? "" : " · ⚠️ link guessed from title")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = WINDOWS.map((days) => {
|
||||||
|
const rows = bucket(days);
|
||||||
|
const required = rows.slice(0, WINDOW_CAP);
|
||||||
|
const spill = rows.slice(WINDOW_CAP);
|
||||||
|
return [
|
||||||
|
`### +${days} days — re-solve into \`work/${days}/\``,
|
||||||
|
"",
|
||||||
|
...(required.length
|
||||||
|
? required.map((s) => line(s, true))
|
||||||
|
: [`_Nothing at the +${days} rung today._`]),
|
||||||
|
// Overflow is shown, not dropped: the ladder levels everything past the
|
||||||
|
// cap onto later working days, but until that morning's digest runs this
|
||||||
|
// issue is the only record of what was standing.
|
||||||
|
...(spill.length
|
||||||
|
? [
|
||||||
|
"",
|
||||||
|
`<details><summary>${spill.length} more at this rung — optional</summary>`,
|
||||||
|
"",
|
||||||
|
...spill.map((s) => line(s, false)),
|
||||||
|
"",
|
||||||
|
"</details>",
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = WINDOWS.reduce((n, days) => n + Math.min(bucket(days).length, WINDOW_CAP), 0);
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
`Re-solve from memory, ${longDate(today, true)}. Blind: no notes, no \`work/\` file, no editor history.`,
|
||||||
|
"",
|
||||||
|
"- 15 minutes per problem, then stop and read your own solution.",
|
||||||
|
"- Say the invariant and the complexity out loud before typing.",
|
||||||
|
"- Failed one? Tick it anyway and expect it back at +3.",
|
||||||
|
"- `bun run pick` → Tab to this rung's section scaffolds the blind re-solve.",
|
||||||
|
"",
|
||||||
|
...sections,
|
||||||
|
"<sub>Today's due list straight from the SRS ladder in D1 (`POST /admin/due`), rendered by " +
|
||||||
|
"`.github/workflows/spaced-repetition.yml` — re-runs on the same day rewrite this body. " +
|
||||||
|
"The ladder never schedules a Sunday, so there is no issue on the rest day.</sub>",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// ── create or refresh today's issue ──────────────────────────────
|
||||||
|
|
||||||
|
interface Existing {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExisting(value: unknown): Existing | 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 text = "body" in value && typeof value.body === "string" ? value.body : "";
|
||||||
|
return { number: value.number, title: value.title, body: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
// state=all: a day's issue that was already closed must not be recreated.
|
||||||
|
let existing: Existing | undefined;
|
||||||
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=${LABEL}&state=all`)) {
|
||||||
|
const row = readExisting(raw);
|
||||||
|
if (row?.title === title) {
|
||||||
|
existing = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status: string;
|
||||||
|
if (total === 0) {
|
||||||
|
status = existing
|
||||||
|
? `nothing due — left #${existing.number} alone`
|
||||||
|
: "nothing due — no issue created";
|
||||||
|
} else if (existing && existing.body.trim() === body.trim()) {
|
||||||
|
status = `#${existing.number} already current`;
|
||||||
|
} else if (DRY) {
|
||||||
|
status = existing ? `would update #${existing.number}` : "would create";
|
||||||
|
} else if (existing) {
|
||||||
|
await gh.api(`/repos/${gh.repo}/issues/${existing.number}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
});
|
||||||
|
status = `updated #${existing.number}`;
|
||||||
|
} else {
|
||||||
|
// Ensure the label exists before it is used (422 = someone already made it).
|
||||||
|
try {
|
||||||
|
await gh.api(`/repos/${gh.repo}/labels`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: LABEL,
|
||||||
|
color: "0E8A16",
|
||||||
|
description: "Daily blind re-solve of whatever the SRS ladder has due",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (!String(err).includes("422")) throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = (await gh.api(`/repos/${gh.repo}/issues`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ title, body, labels: [LABEL] }),
|
||||||
|
})) as { number: number };
|
||||||
|
status = `created #${created.number}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── report ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const report = WINDOWS.flatMap((days) =>
|
||||||
|
bucket(days).map((s, i) => [
|
||||||
|
`+${days}d`,
|
||||||
|
String(s.lc),
|
||||||
|
`#${s.issue}`,
|
||||||
|
s.difficulty,
|
||||||
|
s.due + (s.late > 0 ? ` (${s.late}d late)` : ""),
|
||||||
|
i < WINDOW_CAP ? "due" : "optional",
|
||||||
|
s.linked ? s.url : `${s.url} (inferred)`,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const rows = [["rung", "lc", "issue", "diff", "scheduled", "state", "leetcode"], ...report];
|
||||||
|
if (report.length) printTable(rows);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n${title}${DRY ? " (dry run)" : ""}\n` +
|
||||||
|
WINDOWS.map((days) => {
|
||||||
|
const n = bucket(days).length;
|
||||||
|
return `+${days}d: ${Math.min(n, WINDOW_CAP)} due${n > WINDOW_CAP ? ` (+${n - WINDOW_CAP} optional)` : ""}`;
|
||||||
|
}).join(" · ") +
|
||||||
|
`\n${status}`,
|
||||||
|
);
|
||||||
|
if (DRY) console.log(`\n${body}`);
|
||||||
|
|
||||||
|
await writeStepSummary(
|
||||||
|
`### ${title}${DRY ? " (dry run)" : ""}\n\n` +
|
||||||
|
`${total} problem${total === 1 ? "" : "s"} due · ${status}\n\n` +
|
||||||
|
(report.length ? `${markdownTable(rows)}\n` : "The ladder has nothing due today.\n"),
|
||||||
|
);
|
||||||
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();
|
||||||
|
}
|
||||||
+384
-24
@@ -1,21 +1,61 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Sync work/ leetcode solutions into apps/docs/content/ pages.
|
* 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.
|
* constraints, solution) in the gold-standard format.
|
||||||
* - Already-ported pages only get their `## Solution` section regenerated,
|
* - Already-ported pages only get their script-owned parts regenerated — the
|
||||||
* so hand-curated prose is never touched.
|
* 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
|
* - When a problem is solved in both JavaScript and Python the solution
|
||||||
* renders as a <CodeGroup> (Python first); single-language solutions
|
* renders as a <CodeGroup> (Python first); single-language solutions
|
||||||
* render as a plain fence.
|
* 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 `PageHeader` slot
|
||||||
|
* (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the
|
||||||
|
* `video:` key `videos.ts` writes into the badge row above the title.
|
||||||
|
* - 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; the tag's
|
||||||
|
* badge on a problem page links to it.
|
||||||
|
*
|
||||||
|
* 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 with its own
|
||||||
|
* script-owned `meta.ts`, 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. 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 { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises";
|
||||||
import { basename, join } from "node:path";
|
import { basename, dirname, join } from "node:path";
|
||||||
|
|
||||||
|
import { TOPIC_LANDINGS } from "../docs/topic-landings.ts";
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
const WORK = join(ROOT, "work");
|
const WORK = join(ROOT, "work", "1");
|
||||||
const DOCS = join(ROOT, "apps", "docs", "content");
|
const DOCS = join(ROOT, "apps", "docs", "content");
|
||||||
|
const REDIRECTS = join(ROOT, "apps", "docs", "redirects.ts");
|
||||||
|
|
||||||
type Lang = "js" | "py";
|
type Lang = "js" | "py";
|
||||||
|
|
||||||
@@ -154,24 +194,219 @@ function parseHeader(header: string): Header {
|
|||||||
|
|
||||||
// ── README topic map ─────────────────────────────────────────────
|
// ── 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> = {
|
const TOPIC_ALIASES: Record<string, string> = {
|
||||||
"Hash-Based Lookup": "Hash Table",
|
"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 md = await Bun.file(join(ROOT, "README.md")).text();
|
||||||
const map = new Map<number, string>();
|
const map = new Map<number, Topic>();
|
||||||
let topic = "";
|
let phase: Phase | undefined;
|
||||||
|
let topic: Topic | null = null;
|
||||||
for (const line of md.split("\n")) {
|
for (const line of md.split("\n")) {
|
||||||
const t = line.match(/<strong>\d+\.\s*([^<]+)<\/strong>/);
|
const p = line.match(/^## Phase ([IVX]+) — (.+?) · /);
|
||||||
if (t) topic = TOPIC_ALIASES[t[1]!.trim()] ?? t[1]!.trim();
|
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;
|
if (!topic) continue;
|
||||||
const n = line.match(/<td align="center">(\d+)<\/td>/);
|
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;
|
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. */
|
||||||
|
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 `PageHeader` slot. 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`;
|
||||||
|
|
||||||
// ── inline-code formatting heuristics ────────────────────────────
|
// ── inline-code formatting heuristics ────────────────────────────
|
||||||
|
|
||||||
/** Identifiers worth backticking in prose, harvested from the code. */
|
/** Identifiers worth backticking in prose, harvested from the code. */
|
||||||
@@ -273,7 +508,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 h = sol.header;
|
||||||
const ids = identifiers(sol);
|
const ids = identifiers(sol);
|
||||||
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
|
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
|
||||||
@@ -283,13 +521,13 @@ function renderPage(sol: Solution, topic: string): string {
|
|||||||
"---",
|
"---",
|
||||||
`title: '${h.num}. ${h.title.replaceAll("'", "''")}'`,
|
`title: '${h.num}. ${h.title.replaceAll("'", "''")}'`,
|
||||||
/[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`,
|
/[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`,
|
||||||
|
`icon: ${PAGE_ICON}`,
|
||||||
|
...(tags ? [topicsYaml(tags)] : []),
|
||||||
"sidebar:",
|
"sidebar:",
|
||||||
` label: '${h.title.replaceAll("'", "''")}'`,
|
` label: '${h.title.replaceAll("'", "''")}'`,
|
||||||
` badge: '${h.difficulty}'`,
|
` badge: '${h.difficulty}'`,
|
||||||
"---",
|
"---",
|
||||||
"",
|
"",
|
||||||
`<Badge variant="accent">${topic}</Badge>`,
|
|
||||||
"",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const warning = warningText(h);
|
const warning = warningText(h);
|
||||||
@@ -323,7 +561,7 @@ for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
|||||||
const file = basename(rel);
|
const file = basename(rel);
|
||||||
const m = file.match(/^(\d+)\.(.+)\.(?:js|py)$/);
|
const m = file.match(/^(\d+)\.(.+)\.(?:js|py)$/);
|
||||||
if (!m) {
|
if (!m) {
|
||||||
console.warn(`skip (unrecognized name): work/${rel}`);
|
console.warn(`skip (unrecognized name): work/1/${rel}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const num = Number(m[1]);
|
const num = Number(m[1]);
|
||||||
@@ -346,6 +584,11 @@ for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const topics = await readmeTopics();
|
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. */
|
/** Insert a number-free `sidebar.label` into existing frontmatter when missing. */
|
||||||
function ensureLabel(page: string, h: Header): string {
|
function ensureLabel(page: string, h: Header): string {
|
||||||
@@ -355,6 +598,44 @@ function ensureLabel(page: string, h: Header): string {
|
|||||||
return page.replace(/^sidebar:$/m, `sidebar:\n${label}`);
|
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.
|
// Existing docs pages, keyed by leetcode number from the frontmatter title.
|
||||||
const pages = new Map<number, string>(); // num -> path relative to DOCS
|
const pages = new Map<number, string>(); // num -> path relative to DOCS
|
||||||
for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).sort()) {
|
for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).sort()) {
|
||||||
@@ -365,15 +646,25 @@ for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).s
|
|||||||
|
|
||||||
const report: [string, string, string][] = [];
|
const report: [string, string, string][] = [];
|
||||||
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
|
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
|
||||||
|
const 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
|
||||||
|
|
||||||
for (const sol of ordered) {
|
for (const sol of ordered) {
|
||||||
const langs = Object.keys(sol.code).sort().join("+");
|
const langs = Object.keys(sol.code).sort().join("+");
|
||||||
const workRef = `${sol.num}.${sol.slug} (${langs})`;
|
const workRef = `${sol.num}.${sol.slug} (${langs})`;
|
||||||
// (category) group folder + leetcode-number prefix: numeric sidebar order, both stripped from the URL.
|
// (0N-<phase>)/<topic>/ folder (the topic is a real route segment, so its
|
||||||
const group = `(${sol.category.toLowerCase().replaceAll(" ", "-")})`;
|
// index.mdx is the landing page) + leetcode-number prefix: numeric sidebar
|
||||||
const name = `${group}/${sol.num}-${sol.slug}.mdx`;
|
// 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);
|
const existing = pages.get(sol.num);
|
||||||
await mkdir(join(DOCS, group), { recursive: true });
|
await mkdir(join(DOCS, dir), { recursive: true });
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const page = await Bun.file(join(DOCS, existing)).text();
|
const page = await Bun.file(join(DOCS, existing)).text();
|
||||||
@@ -382,10 +673,13 @@ for (const sol of ordered) {
|
|||||||
report.push([workRef, existing, "ERROR: no '## Solution' heading"]);
|
report.push([workRef, existing, "ERROR: no '## Solution' heading"]);
|
||||||
continue;
|
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[] = [];
|
const actions: string[] = [];
|
||||||
if (existing !== name) {
|
if (existing !== name) {
|
||||||
await rename(join(DOCS, existing), join(DOCS, name));
|
await rename(join(DOCS, existing), join(DOCS, name));
|
||||||
|
vacated.add(dirname(existing));
|
||||||
actions.push("moved");
|
actions.push("moved");
|
||||||
}
|
}
|
||||||
if (next !== page) {
|
if (next !== page) {
|
||||||
@@ -393,10 +687,76 @@ for (const sol of ordered) {
|
|||||||
actions.push("updated");
|
actions.push("updated");
|
||||||
}
|
}
|
||||||
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
|
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
|
||||||
|
for (const tag of frontmatterTopics(next)) tags.set(tag.slug, tag);
|
||||||
} else {
|
} else {
|
||||||
const topic = topics.get(sol.num) ?? sol.category;
|
const page = renderPage(sol, await fetchTopicTags(sol));
|
||||||
await Bun.write(join(DOCS, name), renderPage(sol, topic));
|
await Bun.write(join(DOCS, name), page);
|
||||||
report.push([workRef, name, "created"]);
|
report.push([workRef, name, "created"]);
|
||||||
|
for (const tag of frontmatterTopics(page)) tags.set(tag.slug, tag);
|
||||||
|
}
|
||||||
|
// 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 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));
|
||||||
|
for (const tag of guideTags) {
|
||||||
|
const file = Bun.file(join(DOCS, "guides", `${tag.slug}.mdx`));
|
||||||
|
if ((await file.exists()) && file.size > 0) continue;
|
||||||
|
await Bun.write(file, guidePage(tag));
|
||||||
|
report.push([`tag ${tag.slug}`, `guides/${tag.slug}.mdx`, "created"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script-owned folder metas — one per live phase and topic folder, plus
|
||||||
|
// guides/ — rewritten only when they differ.
|
||||||
|
const metas: [label: string, dir: string, meta: string][] = [
|
||||||
|
...[...phases].map(([dir, phase]): [string, string, string] => [`phase ${phase.order}`, dir, phaseMeta(phase)]),
|
||||||
|
...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]),
|
||||||
|
...(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 `PageHeader` slot
|
||||||
|
* (`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` : ""}`);
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* The work/ layout: one directory per spaced-repetition window.
|
||||||
|
*
|
||||||
|
* work/1/<Difficulty>/<Category>/<num>.<slug>.<ext> first solve
|
||||||
|
* work/3/<Difficulty>/<Category>/<num>.<slug>.<ext> 3-day blind re-solve
|
||||||
|
* work/7/<Difficulty>/<Category>/<num>.<slug>.<ext> 7-day blind re-solve
|
||||||
|
*
|
||||||
|
* The bucket name is the review window in days — the same +3/+7 windows the
|
||||||
|
* campaign's `Spaced Repetition — <date>` issues use (WINDOWS in
|
||||||
|
* spaced-repetition.ts). Below the bucket the layout is leetcode-cli's own
|
||||||
|
* `workDir/Difficulty/Category/` shape, so a re-solve lands beside its first
|
||||||
|
* solve under a different bucket.
|
||||||
|
*
|
||||||
|
* A window is *owed* when the first solve is at least that many days old and
|
||||||
|
* the bucket has no file for that problem yet. Reconciled from the filesystem
|
||||||
|
* on every run the way close-solved.ts reconciles issues: no state file, no
|
||||||
|
* bookkeeping, and a re-solve closes its window just by existing — presence,
|
||||||
|
* not content, so a stub in work/3 counts as done the same way `bun run pick`
|
||||||
|
* treats a scaffold under work/1 as picked. The first solve is the one place
|
||||||
|
* content is read: an empty scaffold in work/1 owes nothing, it is simply
|
||||||
|
* unsolved (isImplemented() in source.ts, the same gate close-solved.ts uses
|
||||||
|
* before it closes an issue).
|
||||||
|
*
|
||||||
|
* "First solve" is the ET date of the commit that first added the problem's
|
||||||
|
* work/ file — the same push that closes its problem issue and therefore
|
||||||
|
* starts the campaign's +3/+7 windows. An uncommitted solution has no date and
|
||||||
|
* is never owed a review yet: you just solved it. ET dates and the date
|
||||||
|
* arithmetic come from apps/api/src/srs.ts so the local picker and D1 cannot
|
||||||
|
* disagree about what day it is.
|
||||||
|
*
|
||||||
|
* Library: side-effect-free on import — nothing scans or shells out until a
|
||||||
|
* function is called.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { WINDOWS, type Window, daysBetween } from "../api/src/srs.ts";
|
||||||
|
import { isImplemented } from "./source.ts";
|
||||||
|
|
||||||
|
export const ROOT = join(import.meta.dir, "..", "..");
|
||||||
|
export const WORK = join(ROOT, "work");
|
||||||
|
|
||||||
|
// The windows are the SRS ladder's rungs: srs.ts defines them once and the
|
||||||
|
// bucket directories are named after them. Re-exported so the picker needs
|
||||||
|
// only this module to know the work/ layout.
|
||||||
|
export { WINDOWS, type Window };
|
||||||
|
|
||||||
|
/** work/1 is the first solve, the rest are the review windows. */
|
||||||
|
export const BUCKETS = [1, ...WINDOWS] as const;
|
||||||
|
export type Bucket = (typeof BUCKETS)[number];
|
||||||
|
const BUCKET_BY_DIR: Record<string, Bucket> = { "1": 1, "3": 3, "7": 7 };
|
||||||
|
|
||||||
|
export function bucketDir(bucket: Bucket): string {
|
||||||
|
return join(WORK, String(bucket));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkFile {
|
||||||
|
lc: number;
|
||||||
|
slug: string;
|
||||||
|
bucket: Bucket;
|
||||||
|
/** Path relative to work/, bucket included. */
|
||||||
|
rel: string;
|
||||||
|
difficulty: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `<num>.<slug>.<ext>` — the only file name the campaign scripts parse. */
|
||||||
|
function parseName(name: string): { lc: number; slug: string } | null {
|
||||||
|
const m = name.match(/^(\d+)\.(.+)\.[A-Za-z0-9]+$/);
|
||||||
|
return m ? { lc: Number(m[1]), slug: m[2]! } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse `1/Medium/Array/15.3sum.py` — null for anything off-layout. */
|
||||||
|
export function parseWorkPath(rel: string): WorkFile | null {
|
||||||
|
const parts = rel.split("/");
|
||||||
|
const name = parts.pop();
|
||||||
|
const bucket = BUCKET_BY_DIR[parts[0] ?? ""];
|
||||||
|
if (name === undefined || bucket === undefined) return null;
|
||||||
|
const parsed = parseName(name);
|
||||||
|
if (!parsed) return null;
|
||||||
|
return { ...parsed, bucket, rel, difficulty: parts[1] ?? "", category: parts[2] ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LC number -> its files in that bucket (a problem may have both js and py). */
|
||||||
|
export function scanBucket(bucket: Bucket): Map<number, WorkFile[]> {
|
||||||
|
const files = new Map<number, WorkFile[]>();
|
||||||
|
for (const name of new Bun.Glob("**/*.*").scanSync({ cwd: bucketDir(bucket) })) {
|
||||||
|
const file = parseWorkPath(`${bucket}/${name}`);
|
||||||
|
if (!file) continue;
|
||||||
|
const found = files.get(file.lc);
|
||||||
|
if (found) found.push(file);
|
||||||
|
else files.set(file.lc, [file]);
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* One `git log` over the whole of work/, oldest commit first, so the first
|
||||||
|
* date seen for a problem wins. Keyed by LC number rather than by path, which
|
||||||
|
* makes every past reshuffle of the directory layout — including the move into
|
||||||
|
* work/1 — irrelevant: the original add still counts. TZ + `format-local`
|
||||||
|
* put the dates on the ET calendar srs.ts speaks.
|
||||||
|
*/
|
||||||
|
export function firstSolved(): Map<number, string> {
|
||||||
|
const git = Bun.spawnSync(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"log",
|
||||||
|
"--reverse",
|
||||||
|
"--no-merges",
|
||||||
|
"--diff-filter=A",
|
||||||
|
"--date=format-local:%Y-%m-%d",
|
||||||
|
"--format=%ad",
|
||||||
|
"--name-only",
|
||||||
|
"--",
|
||||||
|
"work",
|
||||||
|
],
|
||||||
|
{ cwd: ROOT, env: { ...process.env, TZ: "America/New_York" } },
|
||||||
|
);
|
||||||
|
if (git.exitCode !== 0) {
|
||||||
|
throw new Error(`git log failed: ${git.stderr.toString().trim()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dates = new Map<number, string>();
|
||||||
|
let date = "";
|
||||||
|
for (const line of git.stdout.toString().split("\n")) {
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(line)) {
|
||||||
|
date = line;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = line === "" ? null : parseName(line.slice(line.lastIndexOf("/") + 1));
|
||||||
|
if (parsed && !dates.has(parsed.lc)) dates.set(parsed.lc, date);
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Owed extends WorkFile {
|
||||||
|
/** ET date of the first solve. */
|
||||||
|
solved: string;
|
||||||
|
/** Days since the first solve — at least the window. */
|
||||||
|
days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both review windows for `today`, oldest solve first.
|
||||||
|
*
|
||||||
|
* Uncapped, unlike the review issue's WINDOW_CAP: the issue is one day's
|
||||||
|
* assignment, this is everything still owed. A window skipped for a day stays
|
||||||
|
* on the list until its file exists.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
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(isSolution);
|
||||||
|
if (file) solved.push({ file, date });
|
||||||
|
}
|
||||||
|
|
||||||
|
const queues = new Map<Window, Owed[]>();
|
||||||
|
for (const window of WINDOWS) {
|
||||||
|
const done = scanBucket(window);
|
||||||
|
const owed: Owed[] = [];
|
||||||
|
for (const { file, date } of solved) {
|
||||||
|
if (done.has(file.lc)) continue;
|
||||||
|
const days = daysBetween(date, today);
|
||||||
|
if (days >= window) owed.push({ ...file, solved: date, days });
|
||||||
|
}
|
||||||
|
owed.sort((a, b) => b.days - a.days || a.lc - b.lc);
|
||||||
|
queues.set(window, owed);
|
||||||
|
}
|
||||||
|
return queues;
|
||||||
|
}
|
||||||
+151
-9
@@ -1,21 +1,163 @@
|
|||||||
import { defineConfig } from "blume";
|
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({
|
export default defineConfig({
|
||||||
title: "Leetcode",
|
title: "prdlk",
|
||||||
description: "Documentation for LeetCode solutions",
|
description: "Documentation for my LeetCode solutions",
|
||||||
|
logo: "./public/logo.svg",
|
||||||
deployment: {
|
deployment: {
|
||||||
site: "https://prdlk.github.io",
|
site: "https://lc.prd.lk",
|
||||||
base: "/leetcode",
|
},
|
||||||
|
github: {
|
||||||
|
owner: "prdlk",
|
||||||
|
repo: "leetcode",
|
||||||
|
},
|
||||||
|
lastModified: "git",
|
||||||
|
redirects,
|
||||||
|
frontmatter: {
|
||||||
|
extend: { topics: topicsSchema, video: videoSchema, list: listSchema },
|
||||||
},
|
},
|
||||||
content: {
|
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: {
|
navigation: {
|
||||||
|
sidebar: {
|
||||||
|
display: "flat", // "flat" | "group" | "page"
|
||||||
|
},
|
||||||
tabs: [
|
tabs: [
|
||||||
{
|
{ label: "Guides", path: "/guides" },
|
||||||
label: "Reference",
|
{ label: "Changelog", path: "/changelog" },
|
||||||
path: "/reference",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 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",
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
// Problem pages' topic + explanation badges, from `topics:` / `video:` frontmatter.
|
||||||
|
PageHeader: "./components/ProblemBadges.astro",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,92 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* The `PageHeader` slot: the badge row above a page's title. Blume hands the
|
||||||
|
* slot only the route, so the page's frontmatter is 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
|
||||||
|
* nothing.
|
||||||
|
*
|
||||||
|
* - `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 reads accent; any other tag links to its guide
|
||||||
|
* page, `/guides/<slug>` (sync creates one per such tag), and reads neutral.
|
||||||
|
* - `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.
|
||||||
|
*
|
||||||
|
* A tag naming two campaign topics (`Depth-First Search` is both Tree DFS and
|
||||||
|
* Graph DFS) links to the page's own topic — its route's first segment — when
|
||||||
|
* that is one of them; otherwise DFS/BFS prefer the tree topic for a problem
|
||||||
|
* LeetCode also tags `Tree` and the graph topic for the rest. A route the site
|
||||||
|
* does not serve (a landing or guide page removed) falls back to LeetCode's
|
||||||
|
* problem list for the tag, so a badge never links to a 404.
|
||||||
|
*/
|
||||||
|
import { getEntry } from "astro:content";
|
||||||
|
import data from "blume:data";
|
||||||
|
import Badge from "blume/components/content/Badge.astro";
|
||||||
|
import { withBase } from "blume/components/islands/base-path.ts";
|
||||||
|
import { TOPIC_LANDINGS } from "../topic-landings.ts";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
route: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopicTag {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { route } = Astro.props;
|
||||||
|
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(data.routes.map((r: { path: string }) => r.path));
|
||||||
|
const own = `/${route.split("/")[1]}`;
|
||||||
|
const prefer = tags.some((t) => t.slug === "tree") ? "/tree-" : "/graph-";
|
||||||
|
|
||||||
|
const badges = tags.map((tag) => {
|
||||||
|
const landings = (TOPIC_LANDINGS[tag.slug] ?? []).filter((path) => served.has(path));
|
||||||
|
const landing = landings.includes(own) ? own : (landings.find((p) => p.startsWith(prefer)) ?? landings[0]);
|
||||||
|
if (landing) return { name: tag.name, href: withBase(landing), accent: true, external: false };
|
||||||
|
const guide = `/guides/${tag.slug}`;
|
||||||
|
if (served.has(guide)) return { name: tag.name, href: withBase(guide), accent: false, external: false };
|
||||||
|
return { name: tag.name, href: `https://leetcode.com/problem-list/${tag.slug}/`, accent: false, external: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkClass = "no-underline transition-opacity hover:opacity-80";
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
(badges.length > 0 || video || list) && (
|
||||||
|
<div class="not-prose mx-auto mb-4 flex max-w-content 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 variant={b.accent ? "accent" : "default"}>{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">
|
||||||
|
Explanation
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{list && (
|
||||||
|
<a href={list} target="_blank" rel="noopener" class={linkClass}>
|
||||||
|
<Badge variant="warning" icon="list">
|
||||||
|
LeetCode list
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+20
-2
@@ -1,13 +1,16 @@
|
|||||||
---
|
---
|
||||||
title: '153. Find Minimum in Rotated Sorted Array'
|
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:'
|
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:
|
sidebar:
|
||||||
label: 'Find Minimum in Rotated Sorted Array'
|
label: 'Find Minimum in Rotated Sorted Array'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Binary Search</Badge>
|
|
||||||
|
|
||||||
::::warning
|
::::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]].
|
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.
|
- All the integers of `nums` are unique.
|
||||||
- `nums` is sorted and rotated between 1 and `n` times.
|
- `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
|
## Solution
|
||||||
|
|
||||||
```py
|
```py
|
||||||
+28
-2
@@ -1,13 +1,16 @@
|
|||||||
---
|
---
|
||||||
title: '33. Search in Rotated Sorted Array'
|
title: '33. Search in Rotated Sorted Array'
|
||||||
description: There is an integer array nums sorted in ascending order (with distinct values)
|
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:
|
sidebar:
|
||||||
label: 'Search in Rotated Sorted Array'
|
label: 'Search in Rotated Sorted Array'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Binary Search</Badge>
|
|
||||||
|
|
||||||
::::warning
|
::::warning
|
||||||
You must write an algorithm with O(log n) runtime complexity.
|
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.
|
- `nums` is an ascending array that is possibly rotated.
|
||||||
- `-10^4 <= target <= 10^4`
|
- `-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
|
## Solution
|
||||||
|
|
||||||
```js
|
```js
|
||||||
+21
-2
@@ -1,13 +1,16 @@
|
|||||||
---
|
---
|
||||||
title: '35. Search Insert Position'
|
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
|
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:
|
sidebar:
|
||||||
label: 'Search Insert Position'
|
label: 'Search Insert Position'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Binary Search</Badge>
|
|
||||||
|
|
||||||
::::warning
|
::::warning
|
||||||
You must write an algorithm with O(log n) runtime complexity.
|
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.
|
- `nums` contains distinct values sorted in ascending order.
|
||||||
- `-10^4 <= target <= 10^4`
|
- `-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
|
## Solution
|
||||||
|
|
||||||
```py
|
```py
|
||||||
+21
-2
@@ -1,13 +1,16 @@
|
|||||||
---
|
---
|
||||||
title: '704. Binary Search'
|
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
|
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:
|
sidebar:
|
||||||
label: 'Binary Search'
|
label: 'Binary Search'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Binary Search</Badge>
|
|
||||||
|
|
||||||
::::warning
|
::::warning
|
||||||
You must write an algorithm with O(log n) runtime complexity.
|
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.
|
- All the integers in `nums` are unique.
|
||||||
- `nums` is sorted in ascending order.
|
- `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
|
## Solution
|
||||||
|
|
||||||
```js
|
```js
|
||||||
+32
-2
@@ -1,13 +1,17 @@
|
|||||||
---
|
---
|
||||||
title: '74. Search a 2D Matrix'
|
title: '74. Search a 2D Matrix'
|
||||||
description: 'You are given an m x n integer matrix matrix with the following two properties:'
|
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:
|
sidebar:
|
||||||
label: 'Search a 2D Matrix'
|
label: 'Search a 2D Matrix'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Binary Search</Badge>
|
|
||||||
|
|
||||||
::::warning
|
::::warning
|
||||||
You must write a solution in O(log(m * n)) time complexity.
|
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`
|
- `1 <= m, n <= 100`
|
||||||
- `-10^4 <= matrix[i][j], target <= 10^4`
|
- `-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
|
## Solution
|
||||||
|
|
||||||
```py
|
```py
|
||||||
+21
-2
@@ -1,13 +1,16 @@
|
|||||||
---
|
---
|
||||||
title: '875. Koko Eating Bananas'
|
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
|
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:
|
sidebar:
|
||||||
label: 'Koko Eating Bananas'
|
label: 'Koko Eating Bananas'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Binary Search</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `piles = [3,6,7,11], h = 8`
|
- Input: `piles = [3,6,7,11], h = 8`
|
||||||
- Output: `4`
|
- Output: `4`
|
||||||
@@ -26,6 +29,22 @@ sidebar:
|
|||||||
- `piles.length <= h <= 10^9`
|
- `piles.length <= h <= 10^9`
|
||||||
- `1 <= piles[i] <= 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
|
## Solution
|
||||||
|
|
||||||
```js
|
```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,3 @@
|
|||||||
|
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'
|
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
|
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:
|
sidebar:
|
||||||
label: 'Two Sum'
|
label: 'Two Sum'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Hash Table</Badge>
|
|
||||||
|
|
||||||
::::warning
|
::::warning
|
||||||
Can you come up with an algorithm that is less than O(n^2) time complexity?
|
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`
|
- `-10^9 <= target <= 10^9`
|
||||||
- Only one valid answer exists.
|
- 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
|
## Solution
|
||||||
|
|
||||||
```py
|
```py
|
||||||
+17
-2
@@ -1,13 +1,15 @@
|
|||||||
---
|
---
|
||||||
title: '1832. Check if the Sentence Is Pangram'
|
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
|
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:
|
sidebar:
|
||||||
label: 'Check if the Sentence Is Pangram'
|
label: 'Check if the Sentence Is Pangram'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Hash Table</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `sentence = "thequickbrownfoxjumpsoverthelazydog"`
|
- Input: `sentence = "thequickbrownfoxjumpsoverthelazydog"`
|
||||||
- Output: `true`
|
- Output: `true`
|
||||||
@@ -22,6 +24,19 @@ sidebar:
|
|||||||
- `1 <= sentence.length <= 1000`
|
- `1 <= sentence.length <= 1000`
|
||||||
- `sentence` consists of lowercase English letters.
|
- `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
|
## Solution
|
||||||
|
|
||||||
```js
|
```js
|
||||||
+23
-17
@@ -1,13 +1,17 @@
|
|||||||
---
|
---
|
||||||
title: '217. Contains Duplicate'
|
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
|
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:
|
sidebar:
|
||||||
label: 'Contains Duplicate'
|
label: 'Contains Duplicate'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Hash Table</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `nums = [1,2,3,1]`
|
- Input: `nums = [1,2,3,1]`
|
||||||
- Output: `true`
|
- Output: `true`
|
||||||
@@ -27,21 +31,23 @@ sidebar:
|
|||||||
- `1 <= nums.length <= 10^5`
|
- `1 <= nums.length <= 10^5`
|
||||||
- `-10^9 <= nums[i] <= 10^9`
|
- `-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
|
## Solution
|
||||||
|
|
||||||
```js
|
```py
|
||||||
/**
|
class Solution:
|
||||||
* @param {number[]} nums
|
def containsDuplicate(self, nums: List[int]) -> bool:
|
||||||
* @return {boolean}
|
return len(set(nums)) != len(nums)
|
||||||
*/
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
+19
-2
@@ -1,13 +1,17 @@
|
|||||||
---
|
---
|
||||||
title: '242. Valid Anagram'
|
title: '242. Valid Anagram'
|
||||||
description: Given two strings s and t, return true if t is an anagram of s, and false otherwise
|
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:
|
sidebar:
|
||||||
label: 'Valid Anagram'
|
label: 'Valid Anagram'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Hash Table</Badge>
|
|
||||||
|
|
||||||
::::warning
|
::::warning
|
||||||
What if the inputs contain Unicode characters? How would you adapt your solution to such a case?
|
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`
|
- `1 <= s.length, t.length <= 5 * 10^4`
|
||||||
- `s` and `t` consist of lowercase English letters.
|
- `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
|
## Solution
|
||||||
|
|
||||||
```js
|
```js
|
||||||
+21
-2
@@ -1,13 +1,22 @@
|
|||||||
---
|
---
|
||||||
title: '347. Top K Frequent Elements'
|
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
|
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:
|
sidebar:
|
||||||
label: 'Top K Frequent Elements'
|
label: 'Top K Frequent Elements'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Hash Table</Badge>
|
|
||||||
|
|
||||||
::::warning
|
::::warning
|
||||||
Your algorithm's time complexity must be better than O(n log n), where n is the array's size.
|
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]`.
|
- `k` is in the range `[1, the number of unique elements in the array]`.
|
||||||
- It is guaranteed that the answer is unique.
|
- 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
|
## Solution
|
||||||
|
|
||||||
```js
|
```js
|
||||||
+19
-2
@@ -1,13 +1,18 @@
|
|||||||
---
|
---
|
||||||
title: '387. First Unique Character in a String'
|
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
|
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:
|
sidebar:
|
||||||
label: 'First Unique Character in a String'
|
label: 'First Unique Character in a String'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Hash Table</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `s = "leetcode"`
|
- Input: `s = "leetcode"`
|
||||||
- Output: `0`
|
- Output: `0`
|
||||||
@@ -26,6 +31,18 @@ sidebar:
|
|||||||
- `1 <= s.length <= 10^5`
|
- `1 <= s.length <= 10^5`
|
||||||
- `s` consists of only lowercase English letters.
|
- `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
|
## Solution
|
||||||
|
|
||||||
```js
|
```js
|
||||||
+21
-2
@@ -1,13 +1,20 @@
|
|||||||
---
|
---
|
||||||
title: '451. Sort Characters By Frequency'
|
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
|
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:
|
sidebar:
|
||||||
label: 'Sort Characters By Frequency'
|
label: 'Sort Characters By Frequency'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Hash Table</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `s = "tree"`
|
- Input: `s = "tree"`
|
||||||
- Output: `"eert"`
|
- Output: `"eert"`
|
||||||
@@ -28,6 +35,18 @@ sidebar:
|
|||||||
- `1 <= s.length <= 5 * 10^5`
|
- `1 <= s.length <= 5 * 10^5`
|
||||||
- `s` consists of uppercase and lowercase English letters and digits.
|
- `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
|
## Solution
|
||||||
|
|
||||||
```js
|
```js
|
||||||
+22
-2
@@ -1,13 +1,18 @@
|
|||||||
---
|
---
|
||||||
title: '49. Group Anagrams'
|
title: '49. Group Anagrams'
|
||||||
description: Given an array of strings strs, group the anagrams together. You can return the answer in any order
|
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:
|
sidebar:
|
||||||
label: 'Group Anagrams'
|
label: 'Group Anagrams'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Hash Table</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `strs = ["eat","tea","tan","ate","nat","bat"]`
|
- Input: `strs = ["eat","tea","tan","ate","nat","bat"]`
|
||||||
- Output: `[["bat"],["nat","tan"],["ate","eat","tea"]]`
|
- Output: `[["bat"],["nat","tan"],["ate","eat","tea"]]`
|
||||||
@@ -27,6 +32,21 @@ sidebar:
|
|||||||
- `0 <= strs[i].length <= 100`
|
- `0 <= strs[i].length <= 100`
|
||||||
- `strs[i]` consists of lowercase English letters.
|
- `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
|
## Solution
|
||||||
|
|
||||||
```js
|
```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,3 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({ title: "Hash Table", order: 1, icon: "hash", display: "group" });
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({ title: "Phase 1: Linear Structures", order: 1 });
|
||||||
+20
-2
@@ -1,13 +1,17 @@
|
|||||||
---
|
---
|
||||||
title: '739. Daily Temperatures'
|
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
|
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:
|
sidebar:
|
||||||
label: 'Daily Temperatures'
|
label: 'Daily Temperatures'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Monotonic Stack</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `temperatures = [73,74,75,71,69,72,76,73]`
|
- Input: `temperatures = [73,74,75,71,69,72,76,73]`
|
||||||
- Output: `[1,1,4,2,1,1,0,0]`
|
- Output: `[1,1,4,2,1,1,0,0]`
|
||||||
@@ -25,6 +29,20 @@ sidebar:
|
|||||||
- `1 <= temperatures.length <= 10^5`
|
- `1 <= temperatures.length <= 10^5`
|
||||||
- `30 <= temperatures[i] <= 100`
|
- `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
|
## Solution
|
||||||
|
|
||||||
```py
|
```py
|
||||||
+23
-2
@@ -1,13 +1,18 @@
|
|||||||
---
|
---
|
||||||
title: '853. Car Fleet'
|
title: '853. Car Fleet'
|
||||||
description: There are n cars at given miles away from the starting mile 0, traveling to reach the mile target
|
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:
|
sidebar:
|
||||||
label: 'Car Fleet'
|
label: 'Car Fleet'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Monotonic Stack</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `target = 12, position = [10,8,0,5,3], speed = [2,4,1,1,3]`
|
- Input: `target = 12, position = [10,8,0,5,3], speed = [2,4,1,1,3]`
|
||||||
- Output: `3`
|
- Output: `3`
|
||||||
@@ -32,6 +37,22 @@ sidebar:
|
|||||||
- All the values of `position` are unique.
|
- All the values of `position` are unique.
|
||||||
- `0 < speed[i] <= 10^6`
|
- `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
|
## Solution
|
||||||
|
|
||||||
```py
|
```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'
|
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]
|
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:
|
sidebar:
|
||||||
label: 'Product of Array Except Self'
|
label: 'Product of Array Except Self'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Prefix Sum</Badge>
|
|
||||||
|
|
||||||
::::warning
|
::::warning
|
||||||
You must write an algorithm that runs in O(n) time and without using the division operation.
|
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.)
|
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`
|
- `-30 <= nums[i] <= 30`
|
||||||
- The input is generated such that `answer[i]` is guaranteed to fit in a 32-bit integer.
|
- 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
|
## Solution
|
||||||
|
|
||||||
```js
|
```js
|
||||||
+21
-2
@@ -1,13 +1,17 @@
|
|||||||
---
|
---
|
||||||
title: '303. Range Sum Query - Immutable'
|
title: '303. Range Sum Query - Immutable'
|
||||||
description: Given an integer array nums, handle multiple queries of the following type
|
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:
|
sidebar:
|
||||||
label: 'Range Sum Query - Immutable'
|
label: 'Range Sum Query - Immutable'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Prefix Sum</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `["NumArray", "sumRange", "sumRange", "sumRange"]` `[[[-2, 0, 3, -5, 2, -1]], [0, 2], [2, 5], [0, 5]]`
|
- Input: `["NumArray", "sumRange", "sumRange", "sumRange"]` `[[[-2, 0, 3, -5, 2, -1]], [0, 2], [2, 5], [0, 5]]`
|
||||||
- Output: `[null, 1, -1, -3]`
|
- Output: `[null, 1, -1, -3]`
|
||||||
@@ -20,6 +24,21 @@ sidebar:
|
|||||||
- `0 <= left <= right < nums.length`
|
- `0 <= left <= right < nums.length`
|
||||||
- At most `10^4` calls will be made to `sumRange`.
|
- 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
|
## Solution
|
||||||
|
|
||||||
```js
|
```js
|
||||||
+21
-2
@@ -1,13 +1,17 @@
|
|||||||
---
|
---
|
||||||
title: '560. Subarray Sum Equals K'
|
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
|
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:
|
sidebar:
|
||||||
label: 'Subarray Sum Equals K'
|
label: 'Subarray Sum Equals K'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Prefix Sum</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `nums = [1,1,1], k = 2`
|
- Input: `nums = [1,1,1], k = 2`
|
||||||
- Output: `2`
|
- Output: `2`
|
||||||
@@ -22,6 +26,21 @@ sidebar:
|
|||||||
- `-1000 <= nums[i] <= 1000`
|
- `-1000 <= nums[i] <= 1000`
|
||||||
- `-10^7 <= k <= 10^7`
|
- `-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
|
## Solution
|
||||||
|
|
||||||
```js
|
```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'
|
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
|
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:
|
sidebar:
|
||||||
label: 'Best Time to Buy and Sell Stock'
|
label: 'Best Time to Buy and Sell Stock'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Sliding Window</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `prices = [7,1,5,3,6,4]`
|
- Input: `prices = [7,1,5,3,6,4]`
|
||||||
- Output: `5`
|
- Output: `5`
|
||||||
@@ -23,6 +26,24 @@ sidebar:
|
|||||||
- `1 <= prices.length <= 10^5`
|
- `1 <= prices.length <= 10^5`
|
||||||
- `0 <= prices[i] <= 10^4`
|
- `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
|
## Solution
|
||||||
|
|
||||||
```py
|
```py
|
||||||
+21
-2
@@ -1,13 +1,17 @@
|
|||||||
---
|
---
|
||||||
title: '3. Longest Substring Without Repeating Characters'
|
title: '3. Longest Substring Without Repeating Characters'
|
||||||
description: Given a string s, find the length of the longest substring without duplicate 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:
|
sidebar:
|
||||||
label: 'Longest Substring Without Repeating Characters'
|
label: 'Longest Substring Without Repeating Characters'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Sliding Window</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `s = "abcabcbb"`
|
- Input: `s = "abcabcbb"`
|
||||||
- Output: `3`
|
- Output: `3`
|
||||||
@@ -28,6 +32,21 @@ sidebar:
|
|||||||
- `0 <= s.length <= 10^5`
|
- `0 <= s.length <= 10^5`
|
||||||
- `s` consists of English letters, digits, symbols and spaces.
|
- `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
|
## Solution
|
||||||
|
|
||||||
```py
|
```py
|
||||||
+21
-2
@@ -1,13 +1,17 @@
|
|||||||
---
|
---
|
||||||
title: '424. Longest Repeating Character Replacement'
|
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
|
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:
|
sidebar:
|
||||||
label: 'Longest Repeating Character Replacement'
|
label: 'Longest Repeating Character Replacement'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Sliding Window</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `s = "ABAB", k = 2`
|
- Input: `s = "ABAB", k = 2`
|
||||||
- Output: `4`
|
- Output: `4`
|
||||||
@@ -24,6 +28,21 @@ sidebar:
|
|||||||
- `s` consists of only uppercase English letters.
|
- `s` consists of only uppercase English letters.
|
||||||
- `0 <= k <= s.length`
|
- `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
|
## Solution
|
||||||
|
|
||||||
```py
|
```py
|
||||||
+25
-2
@@ -1,13 +1,18 @@
|
|||||||
---
|
---
|
||||||
title: '567. Permutation in String'
|
title: '567. Permutation in String'
|
||||||
description: Given two strings s1 and s2, return true if s2 contains a permutation of s1, or false otherwise
|
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:
|
sidebar:
|
||||||
label: 'Permutation in String'
|
label: 'Permutation in String'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Sliding Window</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `s1 = "ab", s2 = "eidbaooo"`
|
- Input: `s1 = "ab", s2 = "eidbaooo"`
|
||||||
- Output: `true`
|
- Output: `true`
|
||||||
@@ -22,6 +27,24 @@ sidebar:
|
|||||||
- `1 <= s1.length, s2.length <= 10^4`
|
- `1 <= s1.length, s2.length <= 10^4`
|
||||||
- `s1` and `s2` consist of lowercase English letters.
|
- `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
|
## Solution
|
||||||
|
|
||||||
```py
|
```py
|
||||||
+4
-3
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: Sliding Window
|
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.'
|
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
|
:::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.
|
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
|
### 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.
|
Here the window expands until invalid, then shrinks until valid again.
|
||||||
:::
|
:::
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
### 3. Frequency-tracking windows
|
### 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.
|
The difficulty comes from keeping the bookkeeping correct as characters enter and leave.
|
||||||
:::
|
:::
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## How Sliding Window Differs From Other Two-Pointer Families
|
## 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'
|
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
|
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:
|
sidebar:
|
||||||
label: 'Evaluate Reverse Polish Notation'
|
label: 'Evaluate Reverse Polish Notation'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Stack</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `tokens = ["2","1","+","3","*"]`
|
- Input: `tokens = ["2","1","+","3","*"]`
|
||||||
- Output: `9`
|
- Output: `9`
|
||||||
@@ -28,6 +32,21 @@ sidebar:
|
|||||||
- `1 <= tokens.length <= 10^4`
|
- `1 <= tokens.length <= 10^4`
|
||||||
- `tokens[i]` is either an operator: "+", "-", "*", or "/", or an integer in the range [-200, 200].
|
- `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
|
## Solution
|
||||||
|
|
||||||
```py
|
```py
|
||||||
+27
-2
@@ -1,13 +1,16 @@
|
|||||||
---
|
---
|
||||||
title: '155. Min Stack'
|
title: '155. Min Stack'
|
||||||
description: Design a stack that supports push, pop, top, and retrieving the minimum element in constant time
|
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:
|
sidebar:
|
||||||
label: 'Min Stack'
|
label: 'Min Stack'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Stack</Badge>
|
|
||||||
|
|
||||||
::::warning
|
::::warning
|
||||||
You must implement a solution with O(1) time complexity for each function.
|
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.
|
- 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.
|
- 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
|
## Solution
|
||||||
|
|
||||||
```py
|
```py
|
||||||
+24
-2
@@ -1,13 +1,17 @@
|
|||||||
---
|
---
|
||||||
title: '20. Valid Parentheses'
|
title: '20. Valid Parentheses'
|
||||||
description: Given a string s containing just the characters '(', ')', '{', '}', '[' and ']', determine if the input string is valid
|
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:
|
sidebar:
|
||||||
label: 'Valid Parentheses'
|
label: 'Valid Parentheses'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Stack</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `s = "()"`
|
- Input: `s = "()"`
|
||||||
- Output: `true`
|
- Output: `true`
|
||||||
@@ -33,6 +37,24 @@ sidebar:
|
|||||||
- `1 <= s.length <= 10^4`
|
- `1 <= s.length <= 10^4`
|
||||||
- `s` consists of parentheses only '()[]{}'.
|
- `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
|
## Solution
|
||||||
|
|
||||||
```py
|
```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'
|
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])
|
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:
|
sidebar:
|
||||||
label: 'Container With Most Water'
|
label: 'Container With Most Water'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Two Pointers</Badge>
|
|
||||||
|
|
||||||
::::warning
|
::::warning
|
||||||
Notice that you may not slant the container.
|
Notice that you may not slant the container.
|
||||||
::::
|
::::
|
||||||
@@ -27,6 +31,22 @@ Notice that you may not slant the container.
|
|||||||
- `2 <= n <= 10^5`
|
- `2 <= n <= 10^5`
|
||||||
- `0 <= height[i] <= 10^4`
|
- `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
|
## Solution
|
||||||
|
|
||||||
```py
|
```py
|
||||||
+19
-2
@@ -1,13 +1,16 @@
|
|||||||
---
|
---
|
||||||
title: '125. Valid Palindrome'
|
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
|
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:
|
sidebar:
|
||||||
label: 'Valid Palindrome'
|
label: 'Valid Palindrome'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Two Pointers</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
### Example 1:
|
||||||
- Input: `s = "A man, a plan, a canal: Panama"`
|
- Input: `s = "A man, a plan, a canal: Panama"`
|
||||||
- Output: `true`
|
- Output: `true`
|
||||||
@@ -28,6 +31,20 @@ sidebar:
|
|||||||
- `1 <= s.length <= 2 * 10^5`
|
- `1 <= s.length <= 2 * 10^5`
|
||||||
- `s` consists only of printable ASCII characters.
|
- `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
|
## Solution
|
||||||
|
|
||||||
```js
|
```js
|
||||||
+28
-2
@@ -1,13 +1,17 @@
|
|||||||
---
|
---
|
||||||
title: '15. 3Sum'
|
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
|
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:
|
sidebar:
|
||||||
label: '3Sum'
|
label: '3Sum'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Two Pointers</Badge>
|
|
||||||
|
|
||||||
::::warning
|
::::warning
|
||||||
Notice that the solution set must not contain duplicate triplets.
|
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`
|
- `3 <= nums.length <= 3000`
|
||||||
- `-10^5 <= nums[i] <= 10^5`
|
- `-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
|
## Solution
|
||||||
|
|
||||||
```py
|
```py
|
||||||
+22
-2
@@ -1,13 +1,17 @@
|
|||||||
---
|
---
|
||||||
title: '167. Two Sum II - Input Array Is Sorted'
|
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
|
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:
|
sidebar:
|
||||||
label: 'Two Sum II - Input Array Is Sorted'
|
label: 'Two Sum II - Input Array Is Sorted'
|
||||||
badge: 'Medium'
|
badge: 'Medium'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Two Pointers</Badge>
|
|
||||||
|
|
||||||
::::warning
|
::::warning
|
||||||
Your solution must use only constant extra space.
|
Your solution must use only constant extra space.
|
||||||
::::
|
::::
|
||||||
@@ -35,6 +39,22 @@ Your solution must use only constant extra space.
|
|||||||
- `-1000 <= target <= 1000`
|
- `-1000 <= target <= 1000`
|
||||||
- The tests are generated such that there is exactly one solution.
|
- 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
|
## Solution
|
||||||
|
|
||||||
```py
|
```py
|
||||||
+17
-2
@@ -1,13 +1,16 @@
|
|||||||
---
|
---
|
||||||
title: '344. Reverse String'
|
title: '344. Reverse String'
|
||||||
description: Write a function that reverses a string. The input string is given as an array of characters s
|
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:
|
sidebar:
|
||||||
label: 'Reverse String'
|
label: 'Reverse String'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Two Pointers</Badge>
|
|
||||||
|
|
||||||
:::warning
|
:::warning
|
||||||
You must do this by modifying the input array in-place with O(1) extra memory.
|
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`
|
- `1 <= s.length <= 10^5`
|
||||||
- `s[i]` is a printable ascii character.
|
- `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
|
## Solution
|
||||||
|
|
||||||
```js
|
```js
|
||||||
+22
-2
@@ -1,13 +1,17 @@
|
|||||||
---
|
---
|
||||||
title: '392. Is Subsequence'
|
title: '392. Is Subsequence'
|
||||||
description: Given two strings s and t, return true if s is a subsequence of t, or false otherwise
|
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:
|
sidebar:
|
||||||
label: 'Is Subsequence'
|
label: 'Is Subsequence'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Two Pointers</Badge>
|
|
||||||
|
|
||||||
::::warning
|
::::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?
|
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`
|
- `0 <= t.length <= 10^4`
|
||||||
- `s` and `t` consist only of lowercase English letters.
|
- `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
|
## Solution
|
||||||
|
|
||||||
```py
|
```py
|
||||||
+21
-2
@@ -1,13 +1,17 @@
|
|||||||
---
|
---
|
||||||
title: '977. Squares of a Sorted Array'
|
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
|
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:
|
sidebar:
|
||||||
label: 'Squares of a Sorted Array'
|
label: 'Squares of a Sorted Array'
|
||||||
badge: 'Easy'
|
badge: 'Easy'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Badge variant="accent">Two Pointers</Badge>
|
|
||||||
|
|
||||||
::::warning
|
::::warning
|
||||||
Squaring each element and sorting the new array is very trivial, could you find an O(n) solution using a different approach?
|
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`
|
- `-10^4 <= nums[i] <= 10^4`
|
||||||
- `nums` is sorted in non-decreasing order.
|
- `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
|
## Solution
|
||||||
|
|
||||||
```js
|
```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" });
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
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'
|
||||||
|
---
|
||||||
|
|
||||||
|
::::warning
|
||||||
|
Can you solve it using O(1) (i.e. constant) memory?
|
||||||
|
::::
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `head = [3,2,0,-4], pos = 1`
|
||||||
|
- Output: `true`
|
||||||
|
- Explanation: There is a cycle in the linked list, where the tail connects to the 1st node (0-indexed).
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `head = [1,2], pos = 0`
|
||||||
|
- Output: `true`
|
||||||
|
- Explanation: There is a cycle in the linked list, where the tail connects to the 0th node.
|
||||||
|
|
||||||
|
### Example 3:
|
||||||
|
- Input: `head = [1], pos = -1`
|
||||||
|
- Output: `false`
|
||||||
|
- Explanation: There is no cycle in the linked list.
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- The number of the nodes in the list is in the range [0, 10^4].
|
||||||
|
- `-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
|
||||||
|
# Definition for singly-linked list.
|
||||||
|
# class ListNode:
|
||||||
|
# def __init__(self, x):
|
||||||
|
# self.val = x
|
||||||
|
# self.next = None
|
||||||
|
|
||||||
|
|
||||||
|
class Solution:
|
||||||
|
def hasCycle(self, head: Optional[ListNode]) -> bool:
|
||||||
|
fast = slow = head
|
||||||
|
|
||||||
|
while fast and fast.next:
|
||||||
|
fast = fast.next.next
|
||||||
|
slow = slow.next
|
||||||
|
if fast is slow:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
```
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
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'
|
||||||
|
---
|
||||||
|
|
||||||
|
::::warning
|
||||||
|
A linked list can be reversed either iteratively or recursively. Could you implement both?
|
||||||
|
::::
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `head = [1,2,3,4,5]`
|
||||||
|
- Output: `[5,4,3,2,1]`
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `head = [1,2]`
|
||||||
|
- Output: `[2,1]`
|
||||||
|
|
||||||
|
### Example 3:
|
||||||
|
- Input: `head = []`
|
||||||
|
- Output: `[]`
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- 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
|
||||||
|
# Definition for singly-linked list.
|
||||||
|
# class ListNode:
|
||||||
|
# def __init__(self, val=0, next=None):
|
||||||
|
# self.val = val
|
||||||
|
# self.next = next
|
||||||
|
class Solution:
|
||||||
|
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
|
||||||
|
prev = None
|
||||||
|
curr = head
|
||||||
|
while curr:
|
||||||
|
next_ = curr.next
|
||||||
|
curr.next = prev
|
||||||
|
prev = curr
|
||||||
|
curr = next_
|
||||||
|
return prev
|
||||||
|
```
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
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'
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `list1 = [1,2,4], list2 = [1,3,4]`
|
||||||
|
- Output: `[1,1,2,3,4,4]`
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `list1 = [], list2 = []`
|
||||||
|
- Output: `[]`
|
||||||
|
|
||||||
|
### Example 3:
|
||||||
|
- Input: `list1 = [], list2 = [0]`
|
||||||
|
- Output: `[0]`
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- The number of nodes in both lists is in the range [0, 50].
|
||||||
|
- `-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
|
||||||
|
# Definition for singly-linked list.
|
||||||
|
# class ListNode:
|
||||||
|
# def __init__(self, val=0, next=None):
|
||||||
|
# self.val = val
|
||||||
|
# self.next = next
|
||||||
|
class Solution:
|
||||||
|
def mergeTwoLists(
|
||||||
|
self, list1: Optional[ListNode], list2: Optional[ListNode]
|
||||||
|
) -> Optional[ListNode]:
|
||||||
|
|
||||||
|
if not list1:
|
||||||
|
return list2
|
||||||
|
if not list2:
|
||||||
|
return list1
|
||||||
|
|
||||||
|
if list1.val < list2.val:
|
||||||
|
head = list1
|
||||||
|
list1 = list1.next
|
||||||
|
else:
|
||||||
|
head = list2
|
||||||
|
list2 = list2.next
|
||||||
|
|
||||||
|
current = head
|
||||||
|
while list1 and list2:
|
||||||
|
if list1.val < list2.val:
|
||||||
|
current.next = list1
|
||||||
|
list1 = list1.next
|
||||||
|
else:
|
||||||
|
current.next = list2
|
||||||
|
list2 = list2.next
|
||||||
|
current = current.next
|
||||||
|
|
||||||
|
current.next = list1 or list2
|
||||||
|
return head
|
||||||
|
```
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
title: Linked List
|
||||||
|
description: 'Each node points to the next node, and the pointers give the path through the data.'
|
||||||
|
hidden: true
|
||||||
|
---
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({ title: "Linked List", order: 8, icon: "link", display: "group" });
|
||||||
Loaded 100 of 339 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user