mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
Compare commits
344
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83cb284e87 | ||
|
|
f33ded37ec | ||
|
|
ddf5dc483c | ||
|
|
e3d915513d | ||
|
|
f6c580b14b | ||
|
|
c753937852 | ||
|
|
8e07899157 | ||
|
|
886024c4f0 | ||
|
|
37a2d39269 | ||
|
|
a7ab989d57 | ||
|
|
0ccd9794f0 | ||
|
|
b922205bd0 | ||
|
|
def494d524 | ||
|
|
0a4b8d8b49 | ||
|
|
f1a952f2bd | ||
|
|
ea44a99c40 | ||
|
|
00fa322a14 | ||
|
|
bf56e170a4 | ||
|
|
4cf976f683 | ||
|
|
ace1944786 | ||
|
|
13734e2e27 | ||
|
|
cb4a03b6fa | ||
|
|
e14432d32a | ||
|
|
8843cb776c | ||
|
|
0e58b63d4d | ||
|
|
b5c3f1a159 | ||
|
|
37d3d9ceaf | ||
|
|
5627d0ca64 | ||
|
|
598688977a | ||
|
|
a3e846c606 | ||
|
|
52d9654ade | ||
|
|
f202c3a0a1 | ||
|
|
030887c7df | ||
|
|
733c71d364 | ||
|
|
115b901fbe | ||
|
|
ceaa9a2e75 | ||
|
|
7c7019ec0c | ||
|
|
cd5f416939 | ||
|
|
6247915ae5 | ||
|
|
31a1fcfdbd | ||
|
|
66affbe128 | ||
|
|
9de25fecff | ||
|
|
d388b3000e | ||
|
|
f14dcf2723 | ||
|
|
d171fe1565 | ||
|
|
e9d40020f4 | ||
|
|
0b004fcba2 | ||
|
|
33022c806d | ||
|
|
d59b9dccd9 | ||
|
|
17c691c481 | ||
|
|
ea62cda955 | ||
|
|
74939c31d7 | ||
|
|
0d5c40ac4a | ||
|
|
4a1838ab28 | ||
|
|
c2a375fffc | ||
|
|
0e9e06eb8d | ||
|
|
937266b629 | ||
|
|
88c0c9b898 | ||
|
|
099123539e | ||
|
|
8c39b3ac23 | ||
|
|
7ead021ee2 | ||
|
|
0d066b8104 | ||
|
|
9af5ab1fbb | ||
|
|
f770408ea2 | ||
|
|
cf6f09825a | ||
|
|
ab09e40a2e | ||
|
|
91dad73ebf | ||
|
|
400bd5abf5 | ||
|
|
a258b46cf0 | ||
|
|
4875fd5139 | ||
|
|
7b14673e95 | ||
|
|
1c45e03ddd | ||
|
|
cbc858b667 | ||
|
|
c890fd35a7 | ||
|
|
3e62d6fa6f | ||
|
|
017f111594 | ||
|
|
9899813281 | ||
|
|
a19fd95cc4 | ||
|
|
799dc2bb80 | ||
|
|
978589b53e | ||
|
|
876868f2a4 | ||
|
|
16317c8645 | ||
|
|
fdd54e4a1c | ||
|
|
24ef28d8b1 | ||
|
|
b461d9cab7 | ||
|
|
fd57f53d34 | ||
|
|
4b8496c113 | ||
|
|
7f6a7d4776 | ||
|
|
45bd3f771d | ||
|
|
c3c482d954 | ||
|
|
65f5965a12 | ||
|
|
a20be5182f | ||
|
|
c05e7f0ef2 | ||
|
|
76c2a64fc6 | ||
|
|
eb1002727b | ||
|
|
41c8c2036e | ||
|
|
31054c01a7 | ||
|
|
239c7013f8 | ||
|
|
7e9ea8d7c2 | ||
|
|
4548289ecf | ||
|
|
d6b483f84e | ||
|
|
a64a6c2379 | ||
|
|
ffa8a11f51 | ||
|
|
d722feda1c | ||
|
|
9c7d48aad8 | ||
|
|
1d77c5a8b5 | ||
|
|
b2dffa4615 | ||
|
|
f8ff12b55e | ||
|
|
c791974271 | ||
|
|
e1c8f1a0f1 | ||
|
|
57a5917e09 | ||
|
|
f8e90d7f55 | ||
|
|
a39336f7ed | ||
|
|
6396f1fbec | ||
|
|
82fe05b225 | ||
|
|
d7fbe88eb8 | ||
|
|
856ab0667a | ||
|
|
2c574018fb | ||
|
|
1f6413f3d8 | ||
|
|
3b9079f87f | ||
|
|
d5496581df | ||
|
|
e931391200 | ||
|
|
790e256127 | ||
|
|
a8bd4fbcd7 | ||
|
|
a7737502e1 | ||
|
|
2ac54e9930 | ||
|
|
ef91c0d1fe | ||
|
|
910e83fa64 | ||
|
|
3c9feb33fe | ||
|
|
b76b199b17 | ||
|
|
cd9aef1229 | ||
|
|
34b944d32d | ||
|
|
b710c777c3 | ||
|
|
19dedc52ce | ||
|
|
d9dc90e433 | ||
|
|
9c2aaf5969 | ||
|
|
a30e593202 | ||
|
|
d194cedf7a | ||
|
|
5d2d1dff3c | ||
|
|
dde06daea3 | ||
|
|
6e9c407698 | ||
|
|
63f0a81af3 | ||
|
|
5ed33057ed | ||
|
|
f4fc0fd0e7 | ||
|
|
f2a2d2d7de | ||
|
|
fa74adeda7 | ||
|
|
e11c9723f8 | ||
|
|
d2c645bef8 | ||
|
|
2f94b02e15 | ||
|
|
6f1cb93395 | ||
|
|
b310f410a2 | ||
|
|
436ff3080e | ||
|
|
d3f7b1c6f9 | ||
|
|
8a331f4b54 | ||
|
|
bc6eb3e66b | ||
|
|
dd929cca6c | ||
|
|
5987fdce7d | ||
|
|
6879afa61c | ||
|
|
40e965aa06 | ||
|
|
eea8d242e0 | ||
|
|
50b42108eb | ||
|
|
6f375e6c14 | ||
|
|
667bc06965 | ||
|
|
e63d517c9a | ||
|
|
4c297b4524 | ||
|
|
32dadf5928 | ||
|
|
5b6a33088c | ||
|
|
e9f76a32be | ||
|
|
951f958bd9 | ||
|
|
d2888360e2 | ||
|
|
b035d1ffa0 | ||
|
|
0473a3651f | ||
|
|
a9550769f0 | ||
|
|
51c8c771c6 | ||
|
|
7cedd235b5 | ||
|
|
107de04587 | ||
|
|
8787246db7 | ||
|
|
85b54b565e | ||
|
|
8e8522a773 | ||
|
|
6f9e4ecb3b | ||
|
|
5dc2265ee5 | ||
|
|
0ca130156d | ||
|
|
0d64df58f1 | ||
|
|
88d1767184 | ||
|
|
93381f2b69 | ||
|
|
58ab9db1d7 | ||
|
|
e189bb876e | ||
|
|
314f2bff10 | ||
|
|
082b67c44d | ||
|
|
ad7c0a2972 | ||
|
|
931c2c64be | ||
|
|
0a99557aac | ||
|
|
7875b43981 | ||
|
|
f34a9bb56d | ||
|
|
bfeadc0cdf | ||
|
|
ca05c32f6e | ||
|
|
ad129b99fd | ||
|
|
717feb7f70 | ||
|
|
bf0ac34a43 | ||
|
|
403a2d7f23 | ||
|
|
3445c47ad2 | ||
|
|
24a9c52260 | ||
|
|
9d535c6826 | ||
|
|
a2d32c5f06 | ||
|
|
c69582552b | ||
|
|
f0f479d6cf | ||
|
|
ac4dc654fc | ||
|
|
273148c08b | ||
|
|
7960dee431 | ||
|
|
0009075138 | ||
|
|
ca73d3235c | ||
|
|
97b62f95c9 | ||
|
|
1a440e55ce | ||
|
|
fd3b1952b1 | ||
|
|
82c26e2b6c | ||
|
|
f78ecb2bd4 | ||
|
|
b3d3c5fbc9 | ||
|
|
99a9248368 | ||
|
|
2a925f6a9a | ||
|
|
7d57faa958 | ||
|
|
26085cc027 | ||
|
|
216152d946 | ||
|
|
d0ace85bbc | ||
|
|
5b99a6a589 | ||
|
|
8446623607 | ||
|
|
3a1df27d30 | ||
|
|
ae1d1ceede | ||
|
|
f5f31ba1c3 | ||
|
|
d1c99c307e | ||
|
|
021b1fd06e | ||
|
|
edd37ed0e6 | ||
|
|
c2a8a31ede | ||
|
|
891a1f0c5a | ||
|
|
2e5d991d47 | ||
|
|
73d63c2565 | ||
|
|
cafc8a4c25 | ||
|
|
7c5061dcd1 | ||
|
|
f9e69960c4 | ||
|
|
0c12df3eba | ||
|
|
bfb53c14e8 | ||
|
|
2c30b3d017 | ||
|
|
1084410832 | ||
|
|
76c9c77307 | ||
|
|
23eb71d387 | ||
|
|
b8c779903f | ||
|
|
5059eb72cb | ||
|
|
204e178fa4 | ||
|
|
61dc19fb29 | ||
|
|
5c644ca050 | ||
|
|
fb2d4c88c2 | ||
|
|
1fa0731ea7 | ||
|
|
86107d5ef7 | ||
|
|
00f44a1c47 | ||
|
|
24312437eb | ||
|
|
4c9c8f39ae | ||
|
|
88ff63f5c4 | ||
|
|
2066856d15 | ||
|
|
393d72c9fe | ||
|
|
6c18301aa6 | ||
|
|
02f6a20cc1 | ||
|
|
9288aaa152 | ||
|
|
d5ea71dbc1 | ||
|
|
3e1a66eb37 | ||
|
|
9a2b80949b | ||
|
|
883dc69dba | ||
|
|
e3f0b5420a | ||
|
|
987eb7ab5b | ||
|
|
e4118fccd3 | ||
|
|
95db4ca46c | ||
|
|
0cc7ca607e | ||
|
|
71ec39fe34 | ||
|
|
51decd23bd | ||
|
|
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 | ||
|
|
7384a33f3a | ||
|
|
0064d15844 | ||
|
|
e061aca54c | ||
|
|
1eb85ee2fb | ||
|
|
cac2f744e4 | ||
|
|
9e90cfb729 | ||
|
|
dc15237381 | ||
|
|
af7063b71f | ||
|
|
c5f3c95fcc | ||
|
|
fe2a7bb4e9 | ||
|
|
8689075ac9 | ||
|
|
9b5879b6eb | ||
|
|
53136b076c | ||
|
|
95f10980d4 | ||
|
|
c6ecb85507 | ||
|
|
2a69f36e6a | ||
|
|
5e4628b20f | ||
|
|
72a8399e6d | ||
|
|
6ebca68ff8 | ||
|
|
957ddf9849 | ||
|
|
e67cc79f39 | ||
|
|
f086c2b39a |
No files matched your search
@@ -0,0 +1,180 @@
|
||||
---
|
||||
name: campaign-issues
|
||||
description: Create GitHub issues for this LeetCode campaign — individual problem issues and the topic issues that own them — using the repo's exact title, label, milestone, body, and sub-issue conventions. Use when asked to add/create an issue for a LeetCode problem or a topic, attach a problem to its topic, or backfill the issue catalog.
|
||||
---
|
||||
|
||||
# Campaign issues
|
||||
|
||||
The GitHub issue tracker **is the campaign catalog**: ~24 `topic` issues each own a
|
||||
set of `problem` sub-issues. The Worker reconciles this catalog into D1 nightly
|
||||
(`/admin/reconcile`), so a correctly-labelled issue is all that's needed — the SRS,
|
||||
digest, charts, and Project mirror pick it up automatically. Get the labels/body shape
|
||||
right; everything downstream is reconciliation.
|
||||
|
||||
All commands use `gh` against `prdlk/leetcode` and fall back to `gh auth token`.
|
||||
|
||||
## Problem issue
|
||||
|
||||
One issue per LeetCode problem, attached as a sub-issue of its topic.
|
||||
|
||||
- **Title:** `LC <num> · <Title>` — the LC number and the problem's name, nothing
|
||||
else. Separator is a middle dot `·` (U+00B7) with single spaces. Difficulty and
|
||||
set are **labels only**; never repeat them in the title (they used to be a `·
|
||||
Hard · core` suffix — that is gone, and `catalog.ts` no longer parses it).
|
||||
- **Labels** (four, all required — these are what the Worker reads):
|
||||
- `problem` — marks the sub-issue as curriculum; without it the reconcile skips it.
|
||||
- `diff:easy` | `diff:medium` | `diff:hard` — LeetCode's own rating, do not invent.
|
||||
- `set:core` | `set:optional` | `set:deferred` — see set semantics below.
|
||||
- `phase:N` — the **topic's** phase (same phase label the topic issue carries).
|
||||
- **Milestone:**
|
||||
- `core` / `optional` → the topic's phase milestone.
|
||||
- `deferred` → `Review — Transfer & Mocks` (deferred Hards are parked until week 8).
|
||||
- Pass the milestone **title**, not its number, to `--milestone`.
|
||||
- **Body** (exact shape; `<slug>` is the LeetCode URL slug, `<topic-issue>` the owning topic's number):
|
||||
|
||||
```
|
||||
https://leetcode.com/problems/<slug>/
|
||||
|
||||
- **Topic:** #<topic-issue>
|
||||
- **Set:** <core|optional|deferred>
|
||||
- **Language:** Python
|
||||
|
||||
### Close-out (paste before closing this issue)
|
||||
- Time: O(?)
|
||||
- Space: O(?)
|
||||
- Edge case traced:
|
||||
- First attempt passed: yes / no
|
||||
- Miss note:
|
||||
```
|
||||
|
||||
Create it:
|
||||
|
||||
```sh
|
||||
gh issue create --repo prdlk/leetcode \
|
||||
--title "LC 42 · Trapping Rain Water" \
|
||||
--label problem --label set:core --label diff:hard --label phase:1 \
|
||||
--milestone "Phase I — Linear Structures" \
|
||||
--body "https://leetcode.com/problems/trapping-rain-water/
|
||||
|
||||
- **Topic:** #9
|
||||
- **Set:** core
|
||||
- **Language:** Python
|
||||
|
||||
### Close-out (paste before closing this issue)
|
||||
- Time: O(?)
|
||||
- Space: O(?)
|
||||
- Edge case traced:
|
||||
- First attempt passed: yes / no
|
||||
- Miss note:"
|
||||
```
|
||||
|
||||
Then **attach it as a sub-issue of the topic** (this is a separate API call — creating
|
||||
the issue does not link it). The sub-issues API wants the child's internal **id**, not
|
||||
its issue number, and the field is integer-typed, so use `-F` (not `-f`):
|
||||
|
||||
```sh
|
||||
child_id=$(gh api repos/prdlk/leetcode/issues/211 --jq '.id')
|
||||
gh api --method POST repos/prdlk/leetcode/issues/9/sub_issues -F sub_issue_id=$child_id
|
||||
# verify
|
||||
gh api repos/prdlk/leetcode/issues/9/sub_issues --jq '.[] | "\(.number) \(.title)"'
|
||||
```
|
||||
|
||||
## Topic issue
|
||||
|
||||
One issue per curriculum topic; owns the problem sub-issues.
|
||||
|
||||
- **Title:** `Topic NN — <Name>` (zero-padded number, em dash, e.g. `Topic 07 — Monotonic Stack`).
|
||||
- **Labels:** `topic`, `phase:N`.
|
||||
- **Milestone:** that phase's milestone (title, not number).
|
||||
- **Body:**
|
||||
|
||||
```
|
||||
**Scheduled:** <Weekday Mon DD, YYYY>
|
||||
**Phase:** <Roman> — <Phase name> (Week N)
|
||||
**Builds on:** Topic <n>
|
||||
|
||||
### Trigger
|
||||
TODO
|
||||
|
||||
### Invariant
|
||||
TODO
|
||||
|
||||
### Trap
|
||||
TODO
|
||||
|
||||
### Close-out ritual
|
||||
Before every submit, out loud:
|
||||
1. State time and space complexity.
|
||||
2. Trace one edge case.
|
||||
|
||||
### Day rules
|
||||
- 90-minute coding cap. Stop at the cap, even mid-problem.
|
||||
- Core problems first. Optional only if the core is done.
|
||||
- Finished early? Do this topic's optional set. Do not start tomorrow.
|
||||
```
|
||||
|
||||
`Scheduled` should come from the calendar of record — `apps/api/data/schedule.json`
|
||||
(`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
|
||||
|
||||
Set semantics (from the label descriptions):
|
||||
|
||||
| set | meaning | milestone |
|
||||
|---|---|---|
|
||||
| `core` | must solve on the topic day | topic's phase milestone |
|
||||
| `optional` | solve only if the clock allows | topic's phase milestone |
|
||||
| `deferred` | a Hard, parked until week 8 | `Review — Transfer & Mocks` |
|
||||
|
||||
Milestones (`--milestone` takes the **title**):
|
||||
|
||||
| # | title |
|
||||
|---|---|
|
||||
| 1 | Phase I — Linear Structures |
|
||||
| 2 | Phase II — Nodal & Grid |
|
||||
| 3 | Phase III — Hierarchical |
|
||||
| 4 | Phase IV — Relational |
|
||||
| 5 | Phase V — Decision Space |
|
||||
| 6 | Review — Transfer & Mocks |
|
||||
|
||||
Find a topic issue and confirm its phase before creating problems under it:
|
||||
|
||||
```sh
|
||||
gh issue list --repo prdlk/leetcode --label topic --state all --limit 30 \
|
||||
--json number,title,labels,milestone
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Check the LC number is not already in the curriculum BEFORE creating.** D1 keys
|
||||
`problems` on `lc_number` (`ON CONFLICT(lc_number)`), so two issues for one LC number
|
||||
silently fight over a single row — whichever the reconcile walks last wins, and the
|
||||
loser's issue number ends up orphaned in D1. A problem can legitimately move topic or
|
||||
set; that is a relabel/re-parent of the *existing* issue, never a second issue:
|
||||
|
||||
```sh
|
||||
gh issue list --repo prdlk/leetcode --label problem --state all --limit 300 \
|
||||
--search "LC 42" --json number,title,labels,state
|
||||
```
|
||||
|
||||
If a row already exists, change the existing issue instead: swap its `set:*` label,
|
||||
move the sub-issue to the new topic, and fix its milestone. Then reconcile the board
|
||||
(`project-fields` skill). If a duplicate did get created and one is deleted, the
|
||||
surviving issue number and set only reach D1 on the next catalog reconcile — SRS
|
||||
columns (`stage`, `next_review`) are preserved, since they are the real attempt history.
|
||||
|
||||
- **`-F` vs `-f`:** `sub_issue_id` must be an integer; `-f` sends a string and the API
|
||||
rejects it with `is not of type integer`.
|
||||
- **id, not number:** the sub-issue payload wants the child's `.id` (a large opaque
|
||||
integer), never its display `#number`.
|
||||
- **`--milestone` wants the title string**, not the numeric id (`'1' not found` otherwise).
|
||||
- **Reconcile, don't react:** don't edit D1 or the Project by hand — a correct issue is
|
||||
reconciled into SRS state on the nightly cron. Solutions landing in `work/` are what
|
||||
close problem issues (via `close-solved.yml`), which then roll up to close topics.
|
||||
- **Difficulty is LeetCode's**, `set` is our scheduling choice — they're independent. A
|
||||
Hard can be `core` if we deliberately schedule it (vs the usual `deferred`).
|
||||
@@ -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>/(problems)/<num>_<slug>.mdx`) has a
|
||||
hand-owned section before the script-owned one:
|
||||
|
||||
```
|
||||
frontmatter → ::::warning (maybe) → ### Example N / ### Constraints
|
||||
## Approach ← this skill (Mermaid flowchart of the code below)
|
||||
## Solution ← bun run sync owns this; never edit inside it
|
||||
```
|
||||
|
||||
`sync.ts` regenerates only `## Solution`, so an `## Approach` placed **before** it
|
||||
survives every re-sync; anything placed inside `## Solution` is overwritten on the
|
||||
next `work/` push.
|
||||
|
||||
## The command: fill every missing approach
|
||||
|
||||
1. **Find the pages** (numbered pages with no `## Approach` heading):
|
||||
|
||||
```sh
|
||||
grep -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 `hybrid-structures/(problems)/146_lru-cache.mdx`. Private helpers are named inside the
|
||||
method nodes (`"_remove_node then _add_node — move it beside the tail"`), not drawn.
|
||||
- **Annotate the why with an em dash** inside the node, sparingly (one clause, a few
|
||||
per diagram): `"nums.sort() — duplicates become adjacent"`, `"map = {0: 1} — the
|
||||
empty prefix"`. The dash explains the invariant that makes the step correct — the
|
||||
one thing the code does not say.
|
||||
- **Node ids**: one or two capitals, unique per diagram (`S I L Q C E R W A B`). Class
|
||||
diagrams prefix per method (`G1`, `P1`).
|
||||
|
||||
### Syntax rules (a diagram that breaks the page is worse than none)
|
||||
|
||||
- Wrap **every** label in double quotes — brackets, parentheses, `<`, `>` and `|` in an
|
||||
unquoted label break the parser.
|
||||
- Never put `"` inside a label; use `'` for string literals (`"s == ''?"`).
|
||||
- Never use `|` anywhere — not in node labels, not as `-->|text|` edge syntax; edge
|
||||
labels are always `-- text -->`. Write `or` for a logical or.
|
||||
- Every id an edge references must be declared with a shape the first time it appears.
|
||||
- Balanced: each `subgraph` has an `end`; each fence opens ```` ```mermaid ```` and closes ```` ``` ````.
|
||||
|
||||
### Code that does not run
|
||||
|
||||
Trace it anyway — the diagram documents what was submitted — and put a `::::warning`
|
||||
callout **before** the fence naming exactly why it fails (`sliding-window/(problems)/121_best-time-to-buy-and-sell-stock.mdx`):
|
||||
|
||||
````md
|
||||
## Approach
|
||||
|
||||
::::warning
|
||||
The diagram traces the code below literally. That code does not run: `curr` and `ans` are never initialised, so the first `curr > left` test raises `NameError`.
|
||||
::::
|
||||
|
||||
```mermaid
|
||||
…
|
||||
```
|
||||
````
|
||||
|
||||
Do not fix the code on the page: `## Solution` mirrors `work/1`, and the fix belongs
|
||||
there (then `bun run sync`).
|
||||
|
||||
## Do not
|
||||
|
||||
- Do not write prose approaches, complexity tables or alternative algorithms — the
|
||||
section is one flowchart (plus the warning callout when the code is broken).
|
||||
- Do not touch `## Solution` or anything after it, nor the frontmatter `topics:` /
|
||||
`video:` keys (`bun run sync` / `bun run videos --write` own them).
|
||||
- Do not add an approach to `reference/` pages or `index.mdx` — only numbered
|
||||
solution pages (`title: 'NN. …'`) carry one.
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
name: project-fields
|
||||
description: Reconcile the "Interview Prep" GitHub Project (user project #2) fields — Set and Difficulty projected from issue labels — and understand which fields the Worker mirrors vs which live only in D1. Use when asked to sync/fix/backfill Project fields, when Project columns look empty or stale, or when adding a field to the project.
|
||||
---
|
||||
|
||||
# Interview Prep Project fields
|
||||
|
||||
The user-level Project **"Interview Prep"** (`prdlk` project #2, id `PVT_kwHOADoNjs4BhV1a`)
|
||||
is a *board over* the issue catalog, never a source of truth. Every field is a projection
|
||||
of something owned elsewhere.
|
||||
|
||||
## Field ownership — the rule that matters
|
||||
|
||||
| Field | Owner | Written by |
|
||||
|---|---|---|
|
||||
| `Set` (Core/Optional/Deferred) | issue label `set:*` | `sync-project-fields`, last step of `close-solved.yml` |
|
||||
| `Difficulty` (Easy/Medium/Hard) | issue label `diff:*` | `sync-project-fields`, last step of `close-solved.yml` |
|
||||
| `Target Date` | D1 `next_review` | Worker mirror, per log (`apps/api/src/mirror.ts`) |
|
||||
| `Status`, `Labels`, `Milestone`, `Parent issue`, … | GitHub built-ins | GitHub |
|
||||
|
||||
**SRS stage and first-attempt result are deliberately NOT Project fields.** They live only
|
||||
in D1; the charts, digest and `/api/stats` read them from there. Duplicating them into
|
||||
Project single-selects bought nothing but drift and two extra writes per log. Do not re-add
|
||||
them to the project, and do not re-add `setStage`/`setFirstAttempt` to `mirror.ts`.
|
||||
|
||||
## Reconciling Set / Difficulty
|
||||
|
||||
`apps/cli/sync-project-fields.ts` recomputes every desired value from the issue labels and
|
||||
writes only what differs — same reconcile-don't-react shape as `close-solved`.
|
||||
|
||||
**In CI it runs itself.** It is the final step of `.github/workflows/close-solved.yml`, so
|
||||
any push under `work/` reconciles the board along with the issues and D1. The step is
|
||||
`continue-on-error: true` and skips cleanly when `PROJECT_PAT` is unset — D1 and the issues
|
||||
are the truth, so a Project failure must never fail a run that already closed issues and
|
||||
logged the solve. It honours the workflow's `dry_run` input via `DRY_RUN=true`.
|
||||
|
||||
Run it by hand when issues changed without a `work/` push (e.g. you just filed a problem
|
||||
issue, or relabelled one):
|
||||
|
||||
```sh
|
||||
# needs a token with the `project` scope — neither `gh auth token` nor GITHUB_TOKEN has it.
|
||||
export GH_TOKEN=$(grep '^GH_PAT=' apps/api/.dev.vars | cut -d= -f2-)
|
||||
|
||||
bun run sync-project-fields -- --dry-run # or DRY_RUN=1|true; prints the table, writes nothing
|
||||
bun run sync-project-fields # apply
|
||||
bun run sync-project-fields # re-run: must report 0 writes
|
||||
```
|
||||
|
||||
Invariants the script holds — preserve them in any edit:
|
||||
|
||||
- **Only `problem`-labelled items are touched.** Topic, cadence and review issues have no
|
||||
set/difficulty; they are skipped, never written and never cleared.
|
||||
- **Idempotent.** A second run reports `0 field value(s)`. That re-run *is* the verification.
|
||||
- **Labels are truth.** Never "fix" a field by hand in the UI — fix the label, then reconcile.
|
||||
A hand-edited field is silently reverted on the next run.
|
||||
- **Fields and options resolve by NAME**, so re-creating a field in the UI doesn't strand a
|
||||
dead id. A missing field or option is a hard error, not a silent skip.
|
||||
- **A problem missing a `set:`/`diff:` label** warns and is left untouched (it means the
|
||||
issue is mislabelled — fix it with the `campaign-issues` skill).
|
||||
|
||||
## Auth gotcha
|
||||
|
||||
Project v2 needs the `project` scope. The local `gh` login has only
|
||||
`gist, read:org, repo, workflow`, so **any** `gh project`/GraphQL project call fails with
|
||||
the default token. Use the Worker's `GH_PAT` from `apps/api/.dev.vars` via `GH_TOKEN`
|
||||
(never echo it). Symptom of the wrong token: `Resource not accessible by personal access
|
||||
token` or an empty `projectsV2.nodes`.
|
||||
|
||||
## Inspecting the project
|
||||
|
||||
```sh
|
||||
export GH_TOKEN=$(grep '^GH_PAT=' apps/api/.dev.vars | cut -d= -f2-)
|
||||
|
||||
# fields + single-select options
|
||||
gh api graphql -f owner=prdlk -f title="Interview Prep" -f query='
|
||||
query($owner: String!, $title: String!) {
|
||||
user(login: $owner) { projectsV2(first: 10, query: $title) { nodes { id title number
|
||||
items(first: 1) { totalCount }
|
||||
fields(first: 30) { nodes {
|
||||
... on ProjectV2FieldCommon { id name dataType }
|
||||
... on ProjectV2SingleSelectField { options { id name } } } } } } }
|
||||
}' --jq '.data.user.projectsV2.nodes[] | select(.title=="Interview Prep")
|
||||
| .fields.nodes[] | "\(.name)\t\(.dataType)\t\((.options // []) | map(.name) | join(","))"'
|
||||
```
|
||||
|
||||
Writes use `updateProjectV2ItemFieldValue` with
|
||||
`value: { singleSelectOptionId: … }` (dates use `{ date: "YYYY-MM-DD" }`); clearing uses
|
||||
`clearProjectV2ItemFieldValue`. An item id is *not* an issue number — resolve it from the
|
||||
project's `items` connection (or the issue's `projectItems`), as `mirror.ts` does.
|
||||
|
||||
## Adding a new projected field
|
||||
|
||||
1. Create the field + options in the Project UI.
|
||||
2. Add it to the `FIELDS` table in `apps/cli/sync-project-fields.ts` — one entry mapping
|
||||
label → option name. Nothing else needs to change; resolution, diffing, dry-run and
|
||||
reporting are generic over that table.
|
||||
3. Dry-run, apply, re-run to prove idempotency.
|
||||
|
||||
If the new field's truth lives in **D1** rather than a label, it belongs in the Worker
|
||||
mirror instead (`apps/api/src/mirror.ts`) — but read the ownership rule above first and
|
||||
default to *not* mirroring.
|
||||
|
||||
## Known trap
|
||||
|
||||
`resolve()` in `mirror.ts` throws if a field it asks for is absent, *before* caching
|
||||
`info` — so one missing field breaks **every** mirrored write, including `Target Date`,
|
||||
and only as a `console.warn`. If Target Date silently stops updating, check that every
|
||||
field `resolve()` looks up still exists in the project.
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Report matches without closing anything
|
||||
description: Report matches and would-be releases without closing or releasing anything
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
@@ -39,3 +39,65 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
SRS_ADMIN_KEY: ${{ secrets.SRS_ADMIN_KEY }}
|
||||
|
||||
# Project fields are a projection of the issue labels, so the same push
|
||||
# that adds a solution (and any issue filed since the last run) can leave
|
||||
# the board's Set/Difficulty empty. Reconciling here keeps the one
|
||||
# "solution landed" entry point responsible for the whole fan-out; the
|
||||
# script is idempotent, so a run with nothing to do writes nothing.
|
||||
#
|
||||
# Needs the `project` scope, which GITHUB_TOKEN does not have — hence
|
||||
# PROJECT_PAT. Unset = skip, matching SRS_ADMIN_KEY above. Best-effort by
|
||||
# design: D1 and the issues are the truth, so a Project failure must not
|
||||
# fail a run that already closed issues and logged the solve.
|
||||
- name: Reconcile Project Set/Difficulty
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PROJECT_PAT }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "PROJECT_PAT unset — skipping the Project field reconcile"
|
||||
exit 0
|
||||
fi
|
||||
bun apps/cli/sync-project-fields.ts
|
||||
|
||||
# One timever release per finished topic (every core sub-issue closed) and
|
||||
# per finished `Review <N> — <Phase>` issue (every sub-issue closed), tagged
|
||||
# at the instant the last required problem closed. It reads the same rules
|
||||
# close-topics uses (apps/cli/topics.ts) straight off the sub-issues, so the
|
||||
# push that finishes a topic or review releases it in this run — no waiting
|
||||
# for Close Topics, which chains after this workflow. A problem re-parented
|
||||
# into a Review issue still belongs to its topic (apps/api/src/reviews.ts),
|
||||
# so moving it never rewrites a topic's release. Reconciling: a run releases
|
||||
# everything finished and still missing a release, so a manual dispatch
|
||||
# backfills and a problem closed by hand in the UI is picked up by the next
|
||||
# run. The docs changelog (deploy.yml) tags each release Topic or Review.
|
||||
release:
|
||||
needs: close
|
||||
# Like close-topics: a partially failed close still closed some issues,
|
||||
# and the script only reads state, so run unless the run was cancelled.
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # push tags, create releases
|
||||
issues: read
|
||||
steps:
|
||||
# Full history: each tag targets the last main commit before its topic
|
||||
# finished, and backfilled tags reach far behind HEAD.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
# The tagger of the annotated, backdated tags.
|
||||
- run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# No install step: the script only uses Bun builtins + fetch.
|
||||
- run: bun apps/cli/release-topics.ts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
@@ -1,9 +1,14 @@
|
||||
name: Close Topics
|
||||
|
||||
# No D1 push here, unlike Close Solved: D1 stores no topic completion column.
|
||||
# Phase progress, the gate pools and the charts all derive from the problem
|
||||
# rows, which close-solved already logs — a topic is finished exactly when its
|
||||
# core problems are, so there is nothing left to reconcile.
|
||||
# Closes finished topics AND finished `Review <N> — <Phase>` issues (every
|
||||
# sub-issue closed). Problems moved from a topic into a Review issue still
|
||||
# count toward that topic (apps/cli/topics.ts).
|
||||
#
|
||||
# No D1 push here, unlike Close Solved: D1 stores no topic or review
|
||||
# completion column. Phase progress, the gate pools and the charts all derive
|
||||
# from the problem rows, which close-solved already logs — a topic or review
|
||||
# is finished exactly when its problems are, so there is nothing left to
|
||||
# reconcile.
|
||||
|
||||
on:
|
||||
# Close Solved closes problem issues with GITHUB_TOKEN, and token-driven
|
||||
@@ -19,7 +24,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Report finished topics without closing anything
|
||||
description: Report finished topics and reviews without closing anything
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
@@ -34,7 +39,7 @@ concurrency:
|
||||
jobs:
|
||||
close:
|
||||
# The `issues` trigger fires for every issue in the repo; only closing a
|
||||
# problem sub-issue can ever complete a topic's core set.
|
||||
# problem sub-issue can ever complete a topic's core set or a review.
|
||||
if: github.event_name != 'issues' || contains(github.event.issue.labels.*.name, 'problem')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -30,7 +30,13 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2 # skip if you use npm/pnpm
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
# pages/changelog.astro reads this repo's GitHub releases at build time
|
||||
# (apps/docs/lib/releases.ts). The token authenticates that fetch —
|
||||
# required if the repo is private, and it lifts the shared runner's
|
||||
# unauthenticated API rate limit. `contents: read` covers it.
|
||||
- run: bun run build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
@@ -48,8 +54,9 @@ jobs:
|
||||
|
||||
# Deploys on every main push (not path-filtered): apps/api/data/schedule.json
|
||||
# is bundled at deploy time, and re-deploying an unchanged Worker is a no-op.
|
||||
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit) and
|
||||
# CLOUDFLARE_ACCOUNT_ID.
|
||||
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit),
|
||||
# CLOUDFLARE_ACCOUNT_ID, and OPENROUTER_API_KEY (the docs assistant's
|
||||
# POST /ask; the Actions secret is the one copy, mirrored into the Worker).
|
||||
deploy-api:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -62,6 +69,13 @@ jobs:
|
||||
# DST cron guards must hold before the Worker ships.
|
||||
- run: bun run api:test
|
||||
|
||||
- run: printf %s "$OPENROUTER_API_KEY" | bunx wrangler secret put OPENROUTER_API_KEY
|
||||
working-directory: apps/api
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
- run: bun run api:deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
@@ -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,9 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
.openlogs
|
||||
tmp/
|
||||
*.log
|
||||
.crush
|
||||
|
||||
# output
|
||||
out
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues own ~161 `problem` sub-issues (`set:core|optional|deferred`, `diff:*`); milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
|
||||
LeetCode interview-prep campaign (2026-08-17 → Nov 3: eight weeks of topics, then the Review-issue phase; the end is the last date in `apps/api/data/schedule.json` — `CAMPAIGN_END`/`CAMPAIGN_DAYS` in `apps/api/src/srs.ts` are derived from it, never declared) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
|
||||
|
||||
## Architecture & Data Flow
|
||||
|
||||
@@ -13,52 +13,85 @@ flowchart LR
|
||||
work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
|
||||
work -->|close-solved.yml| PI[problem issues]
|
||||
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
|
||||
PI -->|close-topics.yml| TI[topic issues]
|
||||
PI -->|close-topics.yml| TI[topic + Review N issues]
|
||||
PI -->|close-solved.yml release job| Rel[timever release per finished topic / Review N]
|
||||
GH[GitHub issues = catalog] -->|nightly reconcile| D1
|
||||
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
|
||||
Mail -->|GET /log| D1
|
||||
PI -->|/done webhook| D1
|
||||
D1 -->|best-effort mirror| Proj[GitHub Project fields]
|
||||
D1 -->|best-effort mirror: Target Date only| Proj[GitHub Project fields]
|
||||
GH -->|close-solved.yml sync-project-fields| Proj
|
||||
D1 -->|Sat midnight ET| Review[Review — Week N issue]
|
||||
PI -->|"spaced-repetition.yml (6 AM ET cron, closed +3d/+7d)"| SR[Spaced Repetition — Month D issue]
|
||||
D1 --> Charts[SVG charts + /api/stats]
|
||||
Charts -->|bun run stats| TUI[terminal dashboard]
|
||||
```
|
||||
|
||||
- **Reconcile, don't react.** `close-solved.ts`, `close-topics.ts`, and the Worker's catalog sync recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional. `close-solved` posts its *whole* implemented set to `/admin/solved`, not just the issues it closed this run.
|
||||
- **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.
|
||||
- **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`). Project fields (`Target Date`, `SRS Stage`, `First Attempt`) are a best-effort mirror (`apps/api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written.
|
||||
- **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). `Target Date` is the *only* Project field the Worker mirrors (`apps/api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written. SRS stage and first-attempt result live **only** in D1 — do not re-add them as Project fields. The Project's `Set`/`Difficulty` single-selects are a projection of the issue labels, reconciled by `bun run sync-project-fields` — a best-effort final step of `close-solved.yml`, never the Worker.
|
||||
- **The plan names days; the samplers fill the gaps.** `apps/api/data/schedule.json` maps a date to a `ScheduledDay` (`apps/api/src/srs.ts`): `topics` (one or more topic issues — a doubled day lists two, and the digest renders each with its core list then the optional "if time" list), `problems` (a set day: fixed numbers, rendered like a topic), `gate` (Saturday's blind set, verbatim — `createReviewIssue` refuses to create the issue if any number is not a catalog row, so gate problems must exist as `set:optional` sub-issues of their topic first), or `sweep` (every problem whose last attempt failed, taps to re-log, no drills). Every number a `gate` or `problems` entry names is in `RESERVED`, and the drill/gate samplers never draw it — that is what keeps a planned gate problem unseen until its Saturday. Dates absent from the plan still get the ladder's reviews and drills; a Saturday without a `gate` entry falls back to sampling this week's optional pools.
|
||||
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
|
||||
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
|
||||
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
||||
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first, then the problems closed on the finish day / earlier, with solution links at the tag — problems still open at the finish are left out). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs `/changelog` is ONE custom page, `apps/docs/pages/changelog.astro` (a NeonUI-style timeline: date left, a card per release right, no per-release subpages, so nothing of it enters the sidebar or prev/next), linked from the header by a `navigation.tabs` entry (beside `/appendix`'s). `apps/docs/lib/releases.ts` fetches the releases at build (`GITHUB_TOKEN` in `deploy.yml`; a failed fetch fails the build), dates each by its timever tag, not `published_at` (which the API cannot backdate), tags it `Topic` or `Review` from the release name, and lists the rows of its `### Closed …` tables — so the tag format and that table shape are the page's contract with `render()`. There is no changelog RSS feed.
|
||||
- **Review issues re-parent problems; topics still own them.** The transfer phase's 20 `Review <N> — <Phase>` issues (label `review`, milestone `Review — Transfer & Mocks`; not the Worker's `Review — Week N` gate issues) hold open optional/deferred problems as sub-issues in solve order, moved there with `replace_parent`. `apps/api/src/reviews.ts` is the ONE recovery of ownership — `readReviews()` (one GraphQL request per 50 `review` issues, cheap enough for the Worker's cron subrequest budget) reads each moved problem's topic off its last `parent_issue_removed` event naming a topic, `movedFromTopics()` groups them — shared by the Worker's catalog reconcile (a moved problem's D1 row keeps syncing its labels and `topic_issue`) and `apps/cli/topics.ts` (`readProblems()` returns a topic's sub-issues plus its moved problems, so the finished rule and existing release bodies do not change when a problem moves). A Review issue finishes by `completion()` in `topics.ts` — every sub-issue closed, any set — which `close-topics.ts` closes it on and `release-topics.ts` releases it on, exactly like a topic: timever of the last close, same `<!-- release-topics: #N -->` marker (issue numbers never collide), body fields from the review's `**Scheduled:**`/`**Phase:**`/`**Load:**` lines.
|
||||
- **Review days are plan days.** `schedule.json` books each Review issue on its own date — Mon/Tue/Thu/Fri only, Oct 1 → Nov 3 — as `review: { issue, title, problems }` (solve order). The digest renders it as a linked "Today's review" section with the issue's day rules (`REVIEW_RULES` in `email.tsx`, one sentence for HTML and text), its problems join `RESERVED` so drills and sampled gates never draw them early, and `gate: []` skips a Saturday's checkpoint (Oct 3). The issue's sub-issues stay the truth: the catalog reconcile compares every booked review's title and problem list with GitHub and returns any mismatch as `reviewDrift` (also logged), so a re-arranged Review issue shows up the next sync instead of silently disagreeing with the email. Move a review by editing its `schedule.json` date AND the issue's `**Scheduled:**` line (the release body reads it).
|
||||
|
||||
## Key Directories
|
||||
|
||||
| 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/docs/` | Blume site (`blume.config.ts`, `content/`, `islands/`, `public/`). `content/(<category>)/<num>-<slug>.mdx` generated by sync |
|
||||
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (day → topic issue, human-edited, bundled at deploy), `migrations/`, `scripts/import-srs.ts` |
|
||||
| `.github/workflows/` | deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved (issues + `/admin/solved`, needs `SRS_ADMIN_KEY`), close-topics, sync-d1 (issue edits → `/admin/reconcile`) |
|
||||
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `pages/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/(problems)/<num>_<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync (a phase's once, then hand-owned): the phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment, and `(problems)` is a second `(group)` inside it, so `tree-dfs/(problems)/226_invert-binary-tree.mdx` routes to `/tree-dfs/invert-binary-tree` (Blume sorts by the `<num>_` prefix and strips it from the route). The `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order; every topic meta is `display: "page"`, so a topic is one sidebar row whose panel holds its landing page and a "Problems" section. A problem page's badge row (between the description and the body) is rendered, not written: the MDX `wrapper` (`components/ProblemBadges.astro`, registered as `mdx.wrapper` in `components.ts` — Blume's page template renders the title and description itself with no slot before the body, so the wrapper, which MDX hands no route, reads it off `Astro.url`) reads the page's `topics:` (LeetCode's topic tags, from sync) and `video:` (NeetCode YouTube id, from `bun run videos`) frontmatter — both declared via `frontmatter.extend` in `blume.config.ts`, hand-rolled Standard Schemas, no zod — and links a tag the campaign teaches to its topic landing page (filled), any other tag to its card on the Appendix glossary `/appendix#<slug>` (outlined; LeetCode's problem list if the tag has no term, gray and iconless), and the video as a red `Explanation` badge. Every badge in the row is a pill. A tag badge carries the icon its target shows (a landing's: the topic `meta.ts` icon read off `data.navigation`; an Appendix term's: its `icon` in `appendix.ts`) and the tag's own colour from `lib/tag-colors.ts` — unique per tag site-wide, assigned greedily over every tag with a landing or a term so tags that share problem pages sit furthest apart in OKLab, in a lightness band that keeps ≥ 3:1 contrast in both themes (Blume's Badge takes one hex for both). The reverse direction is the `PageFooter` slot (`components/BacklinksList.astro`): an Obsidian-style `<num> <name>` list under every topic landing page of the problems whose badges link there, plus a topic's own folder members (so a topic no tag names, `hybrid-structures`, still lists its problems), in a native `<details>` open on load whose top-right chevron collapses it (no JS); problem pages are recognised by their `<num>. <name>` title and render no list. The sidebar is Blume's default nav tree (no `Sidebar` slot override). The custom pages `/progress`, `/changelog` and `/appendix` are header `navigation.tabs` (on mobile, Blume lists them at the top of the drawer); never `navigation.featured`, which Blume renders above the nav tree. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), and where a badge points is ONE function beside it, `tagTarget()` — read by both components and the Appendix page (so badges, backlinks and term cards cannot disagree) and the map by sync. Every other tag a problem carries is ONE hand-written table, `apps/docs/appendix.ts` (`APPENDIX`: slug → `{ name, icon, category, definition }`), rendered by ONE custom page, `pages/appendix.astro` — a glossary with an A–Z letter index and a filter box (an `<appendix-filter>` custom element over static markup; no JS, the full list), one card per term (icon tinted the tag's colour, category, definition, LeetCode list link, and a `<details>` "Solved" list of the problem pages whose badge `tagTarget()` sends there, paged `PAGE_SIZE` (5) rows at a time by numbered buttons under it — a `<solved-pages>` custom element; without JS every row shows and the buttons stay hidden), each card `id`'d by slug and highlighted as `:target`. It is no content folder, so it enters neither the sidebar tree nor prev/next: its header tab is its link. Sync writes nothing for it — it warns about a carried tag with no term, a term whose tag a topic now teaches (delete it), and a term `icon` that is a `TOPIC_ICONS` value or another term's. The former per-term pages `/appendix/<slug>` are exact redirects to `/appendix#<slug>` in `blume.config.ts`, generated from `APPENDIX`'s keys. A hand-written `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is that topic's landing page: Blume ≥ 1.7.3 links the sidebar group row to it (`sliding-window/index.mdx` → `/sliding-window`) and the hidden flag drops the duplicate row. Two kinds of custom MDX component, both import-free on every page: `islands/<Name>.tsx` is hydrated React (auto-discovered; `DependencySpine` — the README topic map as data, zoom/pan, nodes link to whatever route Blume's `/api/docs/navigaLine truncated
|
||||
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | review | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
|
||||
| `.github/workflows/` | deploy (docs → Pages with `GITHUB_TOKEN` for the releases-backed changelog, Worker → Cloudflare with the `OPENROUTER_API_KEY` Actions secret put into it first, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
|
||||
| `.githooks/` | Tracked git hooks; `bun install` points `core.hooksPath` here (root `prepare` script, no husky). `pre-commit` runs `bun run sync` and stages only what sync changed (pages, `apps/docs/redirects.ts`), so a solution, its page and its `/<num>` short link land in one commit; it refuses to run while `work/1` has anything unstaged (sync reads the tree, so the page would not match the commit), and leaves pre-existing dirt under `apps/docs/content` (in-progress `## Approach` prose) unstaged. `git commit --no-verify` for a deliberate partial commit |
|
||||
|
||||
## Development Commands
|
||||
|
||||
All from the repo root (a Bun workspace over `apps/*`):
|
||||
|
||||
```sh
|
||||
bun run pick # scaffold a solution via leetcode-cli (fuzzy picker; hides problems
|
||||
# already under work/, caches index+descriptions in
|
||||
bun run pick # scaffold a solution via leetcode-cli (fuzzy picker; Tab cycles the
|
||||
# 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)
|
||||
bun run test # run ONE solution against LeetCode's judge (not a test suite)
|
||||
bun run submit # submit ONE solution to LeetCode (same picker as test)
|
||||
bun run sync # work/ → apps/docs/content/ pages
|
||||
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;
|
||||
# files git sees as modified/untracked first, so Enter runs the
|
||||
# one in progress; then most recently tested; stamped on exit 0)
|
||||
bun run submit # submit ONE solution to LeetCode (uncommitted first, then newest
|
||||
# scaffold, nothing hidden; last_submitted stamped on exit 0)
|
||||
bun run sync # work/1 → apps/docs/content/ pages + apps/docs/redirects.ts (/<num> short
|
||||
# links; re-solves are never published)
|
||||
bun run videos # docs pages with no `video:` frontmatter → NeetCode video per page
|
||||
# (channel search scraped, no API key; --write adds the
|
||||
# `video:` key; "no NeetCode match" pages stay untouched)
|
||||
bun run dev|build # Blume docs site (runs in apps/docs/)
|
||||
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
|
||||
bun run close-topics -- --dry-run
|
||||
bun run release-topics -- --dry-run # prints every would-be topic release body
|
||||
# close-solved also pushes the solved set to the Worker; point it at a local
|
||||
# one and nothing production is touched:
|
||||
SRS_API=http://localhost:8787 SRS_ADMIN_KEY=$LINK_KEY bun run close-solved -- --dry-run
|
||||
bun run api:dev # wrangler dev on :8787 (local D1); api:test = DST guard tests
|
||||
# Project Set/Difficulty from the issue labels (needs a `project`-scoped token;
|
||||
# runs as the last step of close-solved.yml in CI):
|
||||
GH_TOKEN=$PROJECT_PAT bun run sync-project-fields -- --dry-run
|
||||
# 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)
|
||||
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
||||
"localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing
|
||||
@@ -72,24 +105,26 @@ 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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
- `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/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/srs.ts` — SRS domain: the ladder (`new → +3 → +7 → retired`; fail resets to `+3`; first-ever log enters at `+3`), `WINDOWS`/`STAGES`/`TEMPERATURES`/`DIFFICULTIES`/`SETS` (the one vocabulary shared by the charts, the `work/<n>` buckets, the review issues and the stats dashboard), ET date math, `workingDay()` (no scheduled date is ever a Sunday), `temperatureOf()` (days since a concept was last exercised → `hot`/`fresh`/`fading`/`cold`, bands derived from `WINDOWS` so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
|
||||
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`, and `OPENROUTER_API_KEY`, whose one copy is the repo's Actions secret (deploy.yml puts it before every deploy — rotate it there).
|
||||
- `apps/api/src/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice.
|
||||
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/<CAMPAIGN_DAYS>) LeetCode Daily Digest` — 79 with the plan ending Nov 3; booking a later day in `schedule.json` lengthens it, and the heatmaps and `/api/stats` `heat` with it.
|
||||
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
|
||||
- `apps/cli/sync.ts` — work→docs contract: only `## Solution` onward is script-owned on existing pages; human prose is never touched; never hand-write solution pages or edit inside `## Solution`.
|
||||
- `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`).
|
||||
- Old `.github/srs/` JSON state and the `srs-*` Actions are RETIRED — do not resurrect; `apps/api/scripts/import-srs.ts` documents the migration.
|
||||
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `companies:` (`{ name, period, frequency }`: every company that asked the problem in the last six months, at the most recent of the `Thirty Days`/`Three Months`/`Six Months` windows it appears in and its frequency there, most recent first — the vocabulary is ONE module, `apps/docs/companies.ts`, shared by sync, the schema and both readers) is the exception that IS rewritten every run, since recency ages: it comes from the local DuckDB company-frequency database (`~/.local/share/duckdb/data/leetcode-company-frequency.db`, built by that project's `mise run lc:build`) via one `duckdb -readonly -json` query, and where the database or the `duckdb` CLI is missing (CI) the step is skipped and pages keep the key as it is. `ProblemBadges.astro` renders it as a TypeTable-style table under the badge row (company pill · asked within · frequency bar), each pill linking to `companyHref()` — the company's Appendix card, filed A–Z among the glossary terms (category `Company`, no definition, no hand-written table: derived from every page's `companies:`), whose Solved backlinks are the problems it asked, each with how recently. `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`, with every problem page in the topic's `(problems)/` group as `<num>_<slug>.mdx` (a page is found by its frontmatter title, wherever it sits, so a layout change is one sync of `rename`s). The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "page"`); the phase `meta.ts` is written once when missing (`Phase N: Name`, `order: N`) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing through an emptied `(problems)` and topic into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
|
||||
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
|
||||
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` / `apps/docs/lib/stats.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs `/progress` islands and `bun run stats`, so its shape is their contract: change the Worker and both readers together (additive fields a reader ignores are safe). `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
|
||||
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache). The docs `/progress` page is a custom page, `apps/docs/pages/progress.astro` (no content folder, so outside the sidebar and prev/next; its header tab is its link), mounting five LeetCode-style React cards from `components/progress/` with `client:load` — imported by the page, never used from MDX, so they are not in `islands/` — `SolvedRing` (difficulty gauge + "Attempting"), `SubmissionHeatmap` and `SubmissionChart` sharing one row (the heatmap: the last six months in month blocks, the README's colour ramp as theme.css `--heat-0..4`, its attempts / active days / max streak counted from the cells it draws; the chart: this calendar year as stacked Easy/Med./Hard bars per month or week, first solves or every attempt, via two shadcn `ToggleGroup`s), `TopicBubbles` (the campaign topics circle-packed by `d3-hierarchy`, filled by solved share), `RecentSolves` (the ten most recent, linked through `redirects.ts`) — composed from shadcn/ui (`components/ui/{card,chart,skeleton,toggle,toggle-group}.tsx`, Recharts under `chart`, Radix under the toggles) on Blume's tokens: `apps/docs/theme.css` maps shadcn's names onto `--blume-*` (never `accent`, which is Blume's brand colour — vendored components that use it, like `toggle.tsx`, are retokened to `background`/`muted`) and gives shadcn's `data-slot` parts their border colour. They share `lib/stats.ts` (`useStats()`), so the page makes ONE `/api/stats` request however many cards hydrate. shadcn sources live outside `islands/` (every `.tsx` there becomes an island) and import `lib/utils` relatively; `components.json` + `tsconfig.json` exist for the shadcn CLI and `tsc`, so after `shadcn add` rewrite `@/…` imports to relative ones (house style) — and check `package.json`: the CLI has twice installed an unrelated `cn` package for `lib/utils` and skipped real deps (`class-variance-authority`). Blume turns every tsconfig `paths` target into a Vite alias AND a dev dep-scan glob (`<target>/**/*.astro`…), so `paths` maps only `@/components`, `@/lib`, `@/hooks` — each bare and `/*`, since `components.json` names the bare forms — never `@/*` → `./*`: that glob reaches through `node_modules/blume`, where Vite's scanner reads a `<script>` inside a `RootLayout.astro` comment as a tag and `blume dev` skips dependency pre-bundling. Blume's `.prose :where(a, p, ol)` rules ignore `not-prose`, so island markup overrides them with `!` utilities, as Blume's own components do.
|
||||
- `apps/docs/blume.config.ts` — site base `/leetcode`; imports `apps/docs/redirects.ts`, sync-generated (`/<num>` → `/<topic>/<slug>`, one row per page, exact root-relative paths — Blume adds the base and, for the static Pages build, emits a meta-refresh page per rule plus `blume-redirects.json`); `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`, `PHASE_ALIASES`).
|
||||
- Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
|
||||
|
||||
## Runtime/Tooling Preferences
|
||||
|
||||
- **Bun only**: run scripts with `bun apps/cli/<name>.ts` (or the root `bun run` aliases), install with `bun install --frozen-lockfile`. One workspace (`apps/*`), one root `bun.lock`. Node 22 appears only in `deploy.yml` because Blume requires it.
|
||||
- **No root tsconfig, no ESLint, no Prettier** — match surrounding style by hand. `apps/api/` has a strict `tsconfig.json`; `Env` types are generated (`bunx wrangler types`), never hand-written.
|
||||
- **Near-zero runtime npm dependencies** — SVG, PNG, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. The **one** exception is the digest email: `apps/api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. Do not extend this exception to any other module.
|
||||
- **Near-zero runtime npm dependencies** — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. Two exceptions, each confined to one surface. The digest email: `apps/api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. And the docs `/progress` islands: shadcn/ui (`recharts`, `radix-ui`, `class-variance-authority`, `clsx`, `tailwind-merge`) plus `d3-hierarchy` for the bubble packing, client-side in `apps/docs` only. Do not extend either exception to any other module.
|
||||
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
|
||||
|
||||
## Testing & QA
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/basedprad"><img alt="follow" src="https://shieldcn.dev/x/follow/basedprad.svg" /></a>
|
||||
<a href="https://lc.prad.nu"><img alt="badge" src="https://shieldcn.dev/badge/Read-Documentation.svg?logo=lu%3ABook" /></a>
|
||||
<a href="https://lc.prad.nu/progress"><img alt="gate pass rate" src="https://srs-api.prdlk.workers.dev/badge/gate.svg" /></a>
|
||||
<a href="https://leetcode.com/progress"><img alt="Custom badge" src="https://shieldcn.dev/badge/view-@prdlk.svg?logo=leetcode" /></a>
|
||||
<a href="https://lc.prd.lk"><img alt="badge" src="https://shieldcn.dev/badge/Read-Documentation.svg?logo=lu%3ABook" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>8-week interview-prep fight camp. Each phase is a milestone; each topic an issue; each problem a sub-issue.<br>
|
||||
<sub>Interview-prep fight camp: eight weeks of topics, then a review phase to Nov 3. Each phase is a milestone; each topic an issue; each problem a sub-issue.<br>
|
||||
New topic Mon–Fri, gate on Saturday, Sunday off — every solve comes back at <strong>+3</strong> and <strong>+7</strong> days,
|
||||
the two windows that keep the rest day clear (a Thursday solve's +3 slides to Monday).<br>
|
||||
Charts are live from the SRS Worker, never committed: pushing a solution or tapping the daily digest moves them within ~5 min.</sub>
|
||||
</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>
|
||||
</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>
|
||||
</tr>
|
||||
</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>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
GH_PAT=github_pat_or_gho_token_with_issues_and_projects_rw
|
||||
WEBHOOK_SECRET=random_hex_matching_the_repo_webhook
|
||||
LINK_KEY=random_hex_signing_one_tap_links
|
||||
OPENROUTER_API_KEY=sk-or-v1_key_for_the_docs_assistant
|
||||
+62
-25
@@ -14,23 +14,50 @@ Live at `https://srs-api.prdlk.workers.dev`.
|
||||
labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via
|
||||
`/admin/reconcile`); it recomputes from scratch, never invents rows, and
|
||||
never overwrites SRS-owned columns (`stage`, `next_review`).
|
||||
- **The repo owns the schedule** — `data/schedule.json`, human-edited,
|
||||
bundled at deploy. Git history is its audit log.
|
||||
- The GitHub Project mirror (`Target Date`, `SRS Stage`, `First Attempt`)
|
||||
is best-effort: failures log warnings, never block a D1 write. Topic rows
|
||||
are never written.
|
||||
- **The repo owns the plan** — `data/schedule.json`, human-edited,
|
||||
bundled at deploy. Git history is its audit log. Each date is a
|
||||
`ScheduledDay`: `topics` (one or more topic issues), `problems` (a set day,
|
||||
fixed numbers), `review` (a `Review <N> — <Phase>` issue with its problems
|
||||
in solve order; Mon/Tue/Thu/Fri only), `gate` (Saturday's blind set,
|
||||
verbatim; `[]` skips that week's checkpoint), or `sweep` (redo every miss,
|
||||
nothing new). Numbers a `gate`, `problems` or `review` entry names are
|
||||
`RESERVED`: the drill and gate samplers never draw them. The catalog
|
||||
reconcile reports any drift between a `review` entry and its issue.
|
||||
- The GitHub Project mirror writes `Target Date` and nothing else: it is
|
||||
best-effort, so failures log warnings and never block a D1 write, and topic
|
||||
rows are never written. SRS stage and first-attempt result stay in D1 only —
|
||||
the charts, digest and `/api/stats` read them from there, so mirroring them
|
||||
into Project single-selects bought only drift. Do not re-add them.
|
||||
`Set`/`Difficulty` are a projection of the issue labels, reconciled by
|
||||
`bun run sync-project-fields` at the repo root, not by this Worker.
|
||||
|
||||
## The ladder
|
||||
|
||||
`new → +2 → +5 → +10 → retired`; pass advances, fail resets to `+2`.
|
||||
A problem's first-ever log enters at `+2` regardless of result. Stage names
|
||||
the NEXT review's interval. All dates are ET calendar strings anchored at
|
||||
noon UTC (`src/srs.ts`) — never raw `Date` math.
|
||||
`new → +3 → +7 → retired`; pass advances, fail resets to `+3`. A problem's
|
||||
first-ever log enters at `+3` regardless of result. Stage names the NEXT
|
||||
review's interval, and the two rungs are the same numbers as the `work/3` and
|
||||
`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
|
||||
earlier week (the current week's optional pool is reserved for Saturday's
|
||||
gate) and with at least one core problem on the ladder — a skipped learning
|
||||
day never feeds drills just because its calendar week lapsed.
|
||||
day never feeds drills just because its calendar week lapsed. A Saturday
|
||||
whose plan fixes the `gate` set uses that list as-is; every number must be a
|
||||
catalog row (a `set:optional` sub-issue of its topic), or the review issue is
|
||||
not created — a `/done` on an uncatalogued number could never be logged.
|
||||
|
||||
## Routes
|
||||
|
||||
@@ -40,7 +67,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 |
|
||||
| `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` |
|
||||
| `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=` |
|
||||
|
||||
## Crons (DST-proof)
|
||||
@@ -60,15 +88,23 @@ routes). Bindings in `wrangler.jsonc`: `DB` (D1 `srs`), `EMAIL`
|
||||
(`send_email`, restricted to the verified destination). Sender domain
|
||||
`prdlk.com` is onboarded to Email Sending.
|
||||
|
||||
Two Actions workflows push into D1 with the `SRS_ADMIN_KEY` repo secret:
|
||||
`sync-d1.yml` sends curriculum issue edits to `POST /admin/reconcile`
|
||||
(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`.
|
||||
That second call is what keeps a committed solution from being invisible
|
||||
here: closing its issue is not a state change the Worker can see — the
|
||||
catalog reconcile ignores issue state, and only `logAttempt()` moves the
|
||||
ladder. `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.
|
||||
Three Actions workflows talk to this Worker with the `SRS_ADMIN_KEY` repo
|
||||
secret:
|
||||
|
||||
- `sync-d1.yml` sends curriculum issue edits to `POST /admin/reconcile` (the
|
||||
morning cron is the backstop).
|
||||
- `close-solved.yml` sends the whole implemented `work/` set — each entry
|
||||
tagged with its bucket — to `POST /admin/solved` on every push to `main`.
|
||||
That call is what keeps a committed solution from being invisible here:
|
||||
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
|
||||
|
||||
@@ -79,13 +115,14 @@ bunx wrangler d1 migrations apply srs --local
|
||||
bun run dev # wrangler dev on :8787, local D1
|
||||
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
||||
"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
|
||||
migration from the retired `.github/srs/srs.json` lives at
|
||||
`scripts/import-srs.ts` (`--remote` for production D1); it is re-runnable —
|
||||
import-sourced attempts are wiped and re-inserted.
|
||||
`?date=` on admin routes is the `SRS_TODAY` equivalent. Migrations are
|
||||
append-only and applied in order; `0002_ladder_3_7.sql` is the +2/+5/+10 →
|
||||
`+3`/`+7` rebuild, which also lifts every date that was sitting on a Sunday.
|
||||
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
|
||||
|
||||
|
||||
+44
-24
@@ -1,26 +1,46 @@
|
||||
{
|
||||
"2026-08-17": 3,
|
||||
"2026-08-18": 4,
|
||||
"2026-08-24": 5,
|
||||
"2026-08-25": 6,
|
||||
"2026-08-26": 7,
|
||||
"2026-08-27": 8,
|
||||
"2026-08-28": 9,
|
||||
"2026-08-31": 10,
|
||||
"2026-09-01": 11,
|
||||
"2026-09-02": 12,
|
||||
"2026-09-03": 13,
|
||||
"2026-09-04": 14,
|
||||
"2026-09-07": 15,
|
||||
"2026-09-08": 16,
|
||||
"2026-09-09": 17,
|
||||
"2026-09-10": 18,
|
||||
"2026-09-11": 19,
|
||||
"2026-09-14": 20,
|
||||
"2026-09-15": 21,
|
||||
"2026-09-16": 22,
|
||||
"2026-09-17": 23,
|
||||
"2026-09-18": 24,
|
||||
"2026-09-21": 25,
|
||||
"2026-09-22": 26
|
||||
"2026-08-17": { "topics": [3] },
|
||||
"2026-08-18": { "topics": [4] },
|
||||
"2026-08-24": { "topics": [5] },
|
||||
"2026-08-25": { "topics": [6] },
|
||||
"2026-08-26": { "topics": [7] },
|
||||
"2026-08-27": { "topics": [8] },
|
||||
"2026-08-28": { "topics": [9] },
|
||||
"2026-08-31": { "topics": [10] },
|
||||
"2026-09-01": { "topics": [11] },
|
||||
"2026-09-02": { "topics": [12] },
|
||||
"2026-09-03": { "topics": [13] },
|
||||
"2026-09-04": { "topics": [14] },
|
||||
"2026-09-07": { "topics": [15] },
|
||||
"2026-09-08": { "topics": [16] },
|
||||
"2026-09-21": { "topics": [17, 18] },
|
||||
"2026-09-22": { "topics": [19, 20] },
|
||||
"2026-09-23": { "topics": [21] },
|
||||
"2026-09-24": { "topics": [22] },
|
||||
"2026-09-25": { "topics": [23] },
|
||||
"2026-09-26": { "gate": [648, 1091, 802, 547, 90] },
|
||||
"2026-09-28": { "topics": [24] },
|
||||
"2026-09-29": { "topics": [25] },
|
||||
"2026-09-30": { "topics": [26] },
|
||||
"2026-10-01": { "review": { "issue": 225, "title": "Review 1 — Linear Structures", "problems": [205, 974, 209, 34, 42] } },
|
||||
"2026-10-02": { "review": { "issue": 226, "title": "Review 2 — Linear Structures", "problems": [290, 525, 189, 1004, 84] }, "sweep": true },
|
||||
"2026-10-03": { "gate": [] },
|
||||
"2026-10-05": { "review": { "issue": 227, "title": "Review 3 — Linear Structures", "problems": [643, 128, 162, 71, 4] } },
|
||||
"2026-10-06": { "review": { "issue": 228, "title": "Review 4 — Linear Structures", "problems": [496, 80, 901, 76, 224] } },
|
||||
"2026-10-08": { "review": { "issue": 229, "title": "Review 5 — Nodal & Grid", "problems": [876, 19, 36] } },
|
||||
"2026-10-09": { "review": { "issue": 230, "title": "Review 6 — Nodal & Grid", "problems": [92, 82, 380] } },
|
||||
"2026-10-12": { "review": { "issue": 231, "title": "Review 7 — Nodal & Grid", "problems": [143, 86, 289] } },
|
||||
"2026-10-13": { "review": { "issue": 232, "title": "Review 8 — Nodal & Grid", "problems": [61, 138] } },
|
||||
"2026-10-15": { "review": { "issue": 233, "title": "Review 9 — Hierarchical", "problems": [100, 112, 530, 1046, 103, 124] } },
|
||||
"2026-10-16": { "review": { "issue": 234, "title": "Review 10 — Hierarchical", "problems": [572, 543, 703, 105, 295] } },
|
||||
"2026-10-19": { "review": { "issue": 235, "title": "Review 11 — Hierarchical", "problems": [101, 637, 173, 621, 502] } },
|
||||
"2026-10-20": { "review": { "issue": 236, "title": "Review 12 — Hierarchical", "problems": [108, 129, 373, 648, 212] } },
|
||||
"2026-10-22": { "review": { "issue": 237, "title": "Review 13 — Relational", "problems": [1971, 695, 547, 433] } },
|
||||
"2026-10-23": { "review": { "issue": 238, "title": "Review 14 — Relational", "problems": [130, 261, 909, 1462] } },
|
||||
"2026-10-26": { "review": { "issue": 239, "title": "Review 15 — Relational", "problems": [286, 1254, 721, 399] } },
|
||||
"2026-10-27": { "review": { "issue": 240, "title": "Review 16 — Relational", "problems": [1091, 802, 269] } },
|
||||
"2026-10-29": { "review": { "issue": 241, "title": "Review 17 — Decision Space", "problems": [746, 228, 17, 77, 213, 63, 122] } },
|
||||
"2026-10-30": { "review": { "issue": 242, "title": "Review 18 — Decision Space", "problems": [22, 90, 139, 120, 45, 253, 72] } },
|
||||
"2026-11-02": { "review": { "issue": 243, "title": "Review 19 — Decision Space", "problems": [79, 131, 91, 494, 763, 986, 920] } },
|
||||
"2026-11-03": { "review": { "issue": 244, "title": "Review 20 — Decision Space", "problems": [300, 647, 97, 221, 918, 452, 52] } }
|
||||
}
|
||||
@@ -0,0 +1,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));
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* POST /ask — the docs assistant's backend.
|
||||
*
|
||||
* The docs are a static GitHub Pages site, and Blume's own assistant route is
|
||||
* server-rendered (it reads the provider key per request), so the site points
|
||||
* `ai.assistant.endpoint` here instead. This speaks the Blume island's wire
|
||||
* contract (`blume/components/islands/hooks.ts`):
|
||||
*
|
||||
* - request: `{ messages: [{ role, content }], page: { path } }` as JSON,
|
||||
* `path` the base-stripped route the reader is on;
|
||||
* - response: the answer as a plain UTF-8 text stream; any non-2xx shows the
|
||||
* island's error notice, and a 503 whose body starts "The assistant is not
|
||||
* configured" shows that body verbatim.
|
||||
*
|
||||
* Validation matches Blume's generated route: 1–40 user/assistant messages of
|
||||
* string content, ≤ 24 000 chars serialized, a 64 KB body, and only role and
|
||||
* content ever reach the model, so the endpoint cannot be repurposed as an
|
||||
* open LLM proxy with a caller's own system prompt.
|
||||
*
|
||||
* Grounding reads what the site already publishes: `llms-full.txt`, every
|
||||
* page's Markdown under `# Title` / `Source: <url>`. The page the reader is on
|
||||
* goes in first, then the best keyword matches, within Blume's default budget
|
||||
* (6 pages, 2 000 chars each, 10 000 total), under Blume's grounded system
|
||||
* prompt — answer only from the excerpts, cite pages as `[Title](/route)`.
|
||||
*
|
||||
* The model call is OpenRouter's OpenAI-compatible chat endpoint over plain
|
||||
* `fetch` (no SDK — see the repo's near-zero-dependency rule), streamed, with
|
||||
* `reasoning.effort: "none"`, for the model the `ASK_MODEL` var names (a
|
||||
* wrangler.jsonc change swaps it); its SSE deltas are re-emitted as bare text. The
|
||||
* reader closing the panel aborts the request, which aborts the model call.
|
||||
*/
|
||||
|
||||
const BODY_LIMIT = 64 * 1024;
|
||||
const MAX_MESSAGES = 40;
|
||||
const MAX_CHARS = 24_000;
|
||||
|
||||
const MAX_RESULTS = 6;
|
||||
const EXCERPT_CHARS = 2000;
|
||||
const CONTEXT_BUDGET = 10_000;
|
||||
/** Lead-in kept before the matched region of a long page. */
|
||||
const EXCERPT_LEAD = 160;
|
||||
/** How long one isolate reuses the parsed corpus (the edge caches the fetch as long). */
|
||||
const CORPUS_TTL_MS = 5 * 60_000;
|
||||
|
||||
const SYSTEM_PROMPT =
|
||||
"You are a helpful documentation assistant for this project, a LeetCode interview-prep campaign: solved problems grouped by topic, with guides to the techniques behind them. Answer the user's question using ONLY the documentation excerpts below. Each excerpt is headed by its page as `## Page Title (/route)`. If the answer is not covered by the excerpts, say you don't know and suggest where in the docs to look — do not invent details. Always cite the pages you drew from, and write every citation as a Markdown link to that page using its route, e.g. [Page Title](/route).";
|
||||
|
||||
/** Words that match everything and rank nothing. */
|
||||
const STOPWORDS = new Set(
|
||||
"a an and are as at be but by can do does for from how i if in is it its me my of on or so that the this to was what when where which who why with you your".split(
|
||||
" ",
|
||||
),
|
||||
);
|
||||
|
||||
interface AskMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
title: string;
|
||||
route: string;
|
||||
body: string;
|
||||
/** Lower-cased title + body, searched per query term. */
|
||||
text: string;
|
||||
titleText: string;
|
||||
}
|
||||
|
||||
// ── corpus ───────────────────────────────────────────────────────
|
||||
|
||||
let corpus: { at: number; sections: Section[] } | undefined;
|
||||
|
||||
/** `llms-full.txt` split into pages, cached per isolate for `CORPUS_TTL_MS`. */
|
||||
async function sections(docsUrl: string): Promise<Section[]> {
|
||||
if (corpus && Date.now() - corpus.at < CORPUS_TTL_MS) return corpus.sections;
|
||||
const res = await fetch(`${docsUrl}/llms-full.txt`, {
|
||||
cf: { cacheTtl: CORPUS_TTL_MS / 1000, cacheEverything: true },
|
||||
});
|
||||
if (!res.ok) throw new Error(`GET llms-full.txt -> ${res.status}`);
|
||||
const full = await res.text();
|
||||
const heads = [...full.matchAll(/^# (.+)\nSource: (\S+)$/gm)];
|
||||
const out = heads.map((head, i): Section => {
|
||||
const start = head.index + head[0].length;
|
||||
const end = heads[i + 1]?.index ?? full.length;
|
||||
const body = full.slice(start, end).replace(/\n-{3,}\s*$/, "").trim();
|
||||
const title = head[1]!.trim();
|
||||
return {
|
||||
title,
|
||||
route: new URL(head[2]!).pathname.replace(/(.)\/+$/, "$1"),
|
||||
body,
|
||||
text: `${title}\n${body}`.toLowerCase(),
|
||||
titleText: title.toLowerCase(),
|
||||
};
|
||||
});
|
||||
corpus = { at: Date.now(), sections: out };
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── retrieval ────────────────────────────────────────────────────
|
||||
|
||||
function terms(text: string): string[] {
|
||||
return [...new Set(text.toLowerCase().match(/[a-z0-9][a-z0-9+#-]*/g) ?? [])].filter(
|
||||
(t) => !STOPWORDS.has(t) && (t.length > 1 || /\d/.test(t)),
|
||||
);
|
||||
}
|
||||
|
||||
function occurrences(haystack: string, needle: string): number {
|
||||
let count = 0;
|
||||
for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + needle.length)) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pages to inject, best first: term frequency damped by log and weighted
|
||||
* by rarity across pages (idf), a title hit counting triple — so "217" or
|
||||
* "sliding window" lands on the page named that rather than every page that
|
||||
* mentions it in passing.
|
||||
*/
|
||||
function rank(all: Section[], query: string[]): Section[] {
|
||||
const idf = new Map(
|
||||
query.map((t) => [t, Math.log(1 + all.length / (1 + all.filter((s) => s.text.includes(t)).length))]),
|
||||
);
|
||||
return all
|
||||
.map((section) => {
|
||||
let score = 0;
|
||||
for (const t of query) {
|
||||
const tf = occurrences(section.text, t);
|
||||
if (tf) score += idf.get(t)! * (Math.log(1 + tf) + (section.titleText.includes(t) ? 3 : 0));
|
||||
}
|
||||
return { section, score };
|
||||
})
|
||||
.filter((r) => r.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, MAX_RESULTS)
|
||||
.map((r) => r.section);
|
||||
}
|
||||
|
||||
/** A page whole when it fits, else `chars` from just before its first query hit. */
|
||||
function excerpt(section: Section, query: string[], chars: number): string {
|
||||
if (section.body.length <= chars) return section.body;
|
||||
const lower = section.body.toLowerCase();
|
||||
const hits = query.map((t) => lower.indexOf(t)).filter((i) => i !== -1);
|
||||
const start = hits.length ? Math.max(0, Math.min(...hits) - EXCERPT_LEAD) : 0;
|
||||
return `${start > 0 ? "…" : ""}${section.body.slice(start, start + chars)}…`;
|
||||
}
|
||||
|
||||
/** The system prompt: the current page, then the ranked pages, within the budget. */
|
||||
async function instructions(env: Env, messages: AskMessage[], path: string | undefined): Promise<string> {
|
||||
const all = await sections(env.DOCS_URL);
|
||||
// A follow-up ("and in Python?") leans on the question before it.
|
||||
const asked = messages.filter((m) => m.role === "user").slice(-2).map((m) => m.content);
|
||||
const query = terms(asked.join(" "));
|
||||
const current = path ? all.find((s) => s.route === path) : undefined;
|
||||
const picked = [...(current ? [current] : []), ...rank(all, query).filter((s) => s !== current)];
|
||||
const parts: string[] = [];
|
||||
let budget = CONTEXT_BUDGET;
|
||||
for (const section of picked) {
|
||||
if (budget < 200) break;
|
||||
const text = excerpt(section, query, Math.min(EXCERPT_CHARS, budget));
|
||||
parts.push(`## ${section.title} (${section.route})\n\n${text}`);
|
||||
budget -= text.length;
|
||||
}
|
||||
return parts.length ? `${SYSTEM_PROMPT}\n\n# Documentation excerpts\n\n${parts.join("\n\n")}` : SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
// ── request handling ─────────────────────────────────────────────
|
||||
|
||||
/** The validated conversation, or a reason it was turned away. */
|
||||
function readMessages(body: unknown): AskMessage[] | undefined {
|
||||
if (!body || typeof body !== "object" || !("messages" in body) || !Array.isArray(body.messages)) return;
|
||||
const raw: unknown[] = body.messages;
|
||||
if (raw.length === 0 || raw.length > MAX_MESSAGES || JSON.stringify(raw).length > MAX_CHARS) return;
|
||||
const messages: AskMessage[] = [];
|
||||
for (const m of raw) {
|
||||
if (!m || typeof m !== "object" || !("role" in m) || !("content" in m)) return;
|
||||
if ((m.role !== "user" && m.role !== "assistant") || typeof m.content !== "string") return;
|
||||
messages.push({ role: m.role, content: m.content });
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
function readPath(body: unknown): string | undefined {
|
||||
if (!body || typeof body !== "object" || !("page" in body)) return;
|
||||
const page = body.page;
|
||||
if (!page || typeof page !== "object" || !("path" in page) || typeof page.path !== "string") return;
|
||||
return page.path.replace(/(.)\/+$/, "$1");
|
||||
}
|
||||
|
||||
/** OpenRouter's SSE (`data: {…choices[0].delta.content…}`) → the answer's bare text. */
|
||||
function textStream(sse: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let buffer = "";
|
||||
return sse.pipeThrough(
|
||||
new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue; // `: OPENROUTER PROCESSING` keep-alives
|
||||
const data = line.slice(6).trim();
|
||||
if (data === "[DONE]") continue;
|
||||
let event: unknown;
|
||||
try {
|
||||
event = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (event && typeof event === "object" && "error" in event) {
|
||||
// After the 200: the stream just ends, and the island shows its
|
||||
// error notice for an empty answer.
|
||||
console.error(`ask: provider error mid-stream: ${JSON.stringify(event.error)}`);
|
||||
continue;
|
||||
}
|
||||
if (!event || typeof event !== "object" || !("choices" in event) || !Array.isArray(event.choices)) continue;
|
||||
const choice: unknown = event.choices[0];
|
||||
if (!choice || typeof choice !== "object" || !("delta" in choice)) continue;
|
||||
const delta = choice.delta;
|
||||
if (!delta || typeof delta !== "object" || !("content" in delta)) continue;
|
||||
const content = delta.content;
|
||||
if (typeof content === "string" && content) controller.enqueue(encoder.encode(content));
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleAsk(request: Request, env: Env): Promise<Response> {
|
||||
// The docs serve from the custom domain and the Pages origin; reflect the
|
||||
// requesting origin only when it is on the allowlist (as /api/stats does).
|
||||
const allowed = env.DOCS_ORIGIN.split(",").map((o) => o.trim());
|
||||
const origin = request.headers.get("origin");
|
||||
const cors = {
|
||||
"access-control-allow-origin": origin && allowed.includes(origin) ? origin : allowed[0]!,
|
||||
"access-control-allow-methods": "POST",
|
||||
"access-control-allow-headers": "content-type",
|
||||
"access-control-max-age": "86400",
|
||||
vary: "Origin",
|
||||
};
|
||||
const text = (body: string, status: number) =>
|
||||
new Response(body, { status, headers: { ...cors, "content-type": "text/plain; charset=utf-8" } });
|
||||
|
||||
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
|
||||
if (request.method !== "POST") return text("POST only.", 405);
|
||||
|
||||
const raw = await request.arrayBuffer();
|
||||
if (raw.byteLength > BODY_LIMIT) return text("Request too large: the body must be at most 64 KB.", 413);
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(new TextDecoder().decode(raw));
|
||||
} catch {
|
||||
body = undefined;
|
||||
}
|
||||
const messages = readMessages(body);
|
||||
if (!messages) return text("Invalid request: send 1-40 user/assistant messages with string content.", 400);
|
||||
if (!env.OPENROUTER_API_KEY) return text("The assistant is not configured: set OPENROUTER_API_KEY.", 503);
|
||||
|
||||
let system: string;
|
||||
try {
|
||||
system = await instructions(env, messages, readPath(body));
|
||||
} catch (err) {
|
||||
// Ungrounded beats unanswered: the prompt still forbids inventing details.
|
||||
console.warn(`ask: grounding unavailable: ${err}`);
|
||||
system = SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${env.OPENROUTER_API_KEY}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: env.ASK_MODEL,
|
||||
stream: true,
|
||||
reasoning: { effort: "none" },
|
||||
messages: [{ role: "system", content: system }, ...messages],
|
||||
}),
|
||||
signal: request.signal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
console.error(`ask: POST /chat/completions -> ${res.status} ${await res.text().catch(() => "")}`);
|
||||
return text("Failed to generate a response.", res.status === 429 ? 429 : 502);
|
||||
}
|
||||
return new Response(textStream(res.body), {
|
||||
headers: { ...cors, "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
|
||||
});
|
||||
}
|
||||
+85
-11
@@ -4,18 +4,73 @@
|
||||
* rows from scratch on every run — re-runs are no-ops. SRS-owned columns
|
||||
* (stage, next_review) are NEVER overwritten; defer_until is set once, on
|
||||
* first sight of a deferred problem. The Worker never invents catalog rows.
|
||||
*
|
||||
* Difficulty and set come from the `diff:*` / `set:*` LABELS, never from the
|
||||
* issue title — the title carries only the LC number and the problem's name,
|
||||
* so relabelling a problem is enough to move it and titles stay readable.
|
||||
* The `problem` label is what marks a sub-issue as curriculum.
|
||||
*
|
||||
* A topic's problems are its sub-issues PLUS those re-parented into a
|
||||
* `Review <N> — <Phase>` issue (reviews.ts recovers which topic each came
|
||||
* from). Walking topic sub-issues alone would silently freeze a moved
|
||||
* problem's row: upserts only, so nothing breaks — its labels just stop
|
||||
* syncing.
|
||||
*/
|
||||
import { addDays } from "./srs.ts";
|
||||
import { DIFFICULTIES, SCHEDULE, SETS, workingDay } from "./srs.ts";
|
||||
import type { GitHub } from "./github.ts";
|
||||
import { movedFromTopics, readReviews, type Review } from "./reviews.ts";
|
||||
|
||||
const TITLE_RE = /^LC (\d+) · (.+) · (Easy|Medium|Hard) · (core|optional|deferred)$/;
|
||||
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
||||
|
||||
/** Deferred Hards enter the queue from this date, two per day. */
|
||||
/** Deferred Hards enter the queue from this date, two per working day. */
|
||||
const DEFER_FROM = "2026-09-28";
|
||||
|
||||
export interface ReconcileReport {
|
||||
topics: number;
|
||||
problems: number;
|
||||
/** Where schedule.json's review days disagree with the Review issues; empty when in step. */
|
||||
reviewDrift: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* schedule.json copies each review day's issue, title and problems so the
|
||||
* digest and RESERVED need no GitHub call; the issues stay the truth. Compared
|
||||
* here because this is the one place that already holds both.
|
||||
*/
|
||||
function reviewDrift(reviews: Review[]): string[] {
|
||||
const drift: string[] = [];
|
||||
const booked = new Map<number, string>();
|
||||
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||
if (!day.review) continue;
|
||||
const { issue, title, problems } = day.review;
|
||||
booked.set(issue, date);
|
||||
const review = reviews.find((r) => r.number === issue);
|
||||
if (!review) {
|
||||
drift.push(`${date}: #${issue} is not a "Review <N> — <Phase>" issue`);
|
||||
continue;
|
||||
}
|
||||
if (review.title !== title) drift.push(`${date}: #${issue} is titled "${review.title}", schedule says "${title}"`);
|
||||
const actual = review.problems.map((p) => Number(p.title.match(TITLE_RE)?.[1]));
|
||||
if (actual.join(",") !== problems.join(",")) {
|
||||
drift.push(`${date}: #${issue} holds LC ${actual.join(", ")}, schedule says LC ${problems.join(", ")}`);
|
||||
}
|
||||
}
|
||||
for (const review of reviews) {
|
||||
if (!booked.has(review.number)) drift.push(`#${review.number} "${review.title}" has no day in schedule.json`);
|
||||
}
|
||||
return drift;
|
||||
}
|
||||
|
||||
/** Label names on one issue payload, narrowed with guards rather than cast. */
|
||||
function labelNames(value: object): string[] {
|
||||
if (!("labels" in value) || !Array.isArray(value.labels)) return [];
|
||||
const names: string[] = [];
|
||||
for (const label of value.labels) {
|
||||
if (label && typeof label === "object" && "name" in label && typeof label.name === "string") {
|
||||
names.push(label.name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<ReconcileReport> {
|
||||
@@ -55,20 +110,39 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
||||
);
|
||||
}
|
||||
|
||||
const reviews = await readReviews(gh);
|
||||
const drift = reviewDrift(reviews);
|
||||
for (const line of drift) console.warn(`catalog: review drift — ${line}`);
|
||||
const moved = movedFromTopics(reviews);
|
||||
let problems = 0;
|
||||
let deferredSeen = 0;
|
||||
for (const t of topics) {
|
||||
const members: { number: number; title: string; labels: string[] }[] = [];
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||
const m = raw.title.match(TITLE_RE);
|
||||
if (!m) continue; // non-curriculum sub-issue
|
||||
members.push({ number: raw.number, title: raw.title, labels: labelNames(raw) });
|
||||
}
|
||||
members.push(...(moved.get(t.number) ?? []));
|
||||
for (const { number, title, labels } of members) {
|
||||
if (!labels.includes("problem")) continue; // non-curriculum sub-issue
|
||||
const m = title.match(TITLE_RE);
|
||||
if (!m) {
|
||||
console.warn(`catalog: #${number} is a problem but not titled "LC <n> · <name>"`);
|
||||
continue;
|
||||
}
|
||||
const difficulty = DIFFICULTIES.find((d) => labels.includes(`diff:${d}`));
|
||||
const set = SETS.find((s) => labels.includes(`set:${s}`));
|
||||
if (!difficulty || !set) {
|
||||
console.warn(`catalog: #${number} is missing a diff:/set: label — skipped`);
|
||||
continue;
|
||||
}
|
||||
const lc = Number(m[1]);
|
||||
const set = m[4]!;
|
||||
// Two deferred Hards per day from DEFER_FROM, in catalog walk order —
|
||||
// applied only when the row is first created (SRS owns it afterwards).
|
||||
const defer = set === "deferred" ? addDays(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
|
||||
// Two deferred Hards per WORKING day from DEFER_FROM, in catalog walk
|
||||
// order — applied only when the row is first created (SRS owns it
|
||||
// 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++;
|
||||
statements.push(
|
||||
db
|
||||
@@ -78,7 +152,7 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
||||
ON CONFLICT(lc_number) DO UPDATE SET
|
||||
issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`,
|
||||
)
|
||||
.bind(lc, raw.number, t.number, m[2]!, m[3]!.toLowerCase(), set, defer),
|
||||
.bind(lc, number, t.number, m[2]!, difficulty, set, defer),
|
||||
);
|
||||
problems++;
|
||||
}
|
||||
@@ -88,5 +162,5 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
||||
for (let i = 0; i < statements.length; i += 50) {
|
||||
await db.batch(statements.slice(i, i + 50));
|
||||
}
|
||||
return { topics: topics.length, problems };
|
||||
return { topics: topics.length, problems, reviewDrift: drift };
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
* badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode
|
||||
* green ramp. Every function renders on a zero-row DB.
|
||||
*/
|
||||
import { STAGES } from "./srs.ts";
|
||||
import { Canvas, textWidth } from "./png.ts";
|
||||
|
||||
// D1Database comes from the generated worker-configuration.d.ts runtime types.
|
||||
@@ -43,8 +44,6 @@ const PHASES = [
|
||||
{ milestone: 5, name: "V — Decision Space" },
|
||||
];
|
||||
|
||||
const STAGES = ["new", "+2", "+5", "+10", "retired"];
|
||||
|
||||
// ── svg helpers ──────────────────────────────────────────────────
|
||||
|
||||
/** 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 ────────────────────────────────────────
|
||||
|
||||
// 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> {
|
||||
const { results } = await db
|
||||
.prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`)
|
||||
|
||||
+82
-23
@@ -5,14 +5,17 @@
|
||||
* the HTML and the plain-text alternative from that single tree (React Email
|
||||
* `render`), so the two can never drift.
|
||||
*
|
||||
* Retrieval rules: review and drill rows carry number + difficulty only —
|
||||
* never the topic, never a solution link. `DigestRow` has no field for
|
||||
* either, so the rule holds by construction. The learning day's core list is
|
||||
* the only labeled section. At most REVIEW_CAP (3) reviews per day; drills
|
||||
* fill toward DAILY_CAP (6) only when the due queue is light. Overflow is
|
||||
* levelled, not stacked: sendDigest gives every review past the cap a
|
||||
* concrete future date, ≤ REVIEW_CAP per day, oldest first. Sunday is a
|
||||
* two-line rest note.
|
||||
* Retrieval rules: review, drill and sweep rows carry number + difficulty
|
||||
* only — never the topic, never a solution link. `DigestRow` has no field for
|
||||
* either, so the rule holds by construction. The plan's own sections — a
|
||||
* learning day's topics (core, then the optional "if time" list), a set
|
||||
* day's problems, and a review day's `Review <N>` issue — are the only
|
||||
* labeled, linked ones. At most REVIEW_CAP (3)
|
||||
* reviews per day; drills fill toward DAILY_CAP (6) only when the due queue
|
||||
* is light, and never on a sweep day, which assigns every open miss and
|
||||
* nothing new. Overflow is levelled, not stacked: sendDigest gives every
|
||||
* review past the cap a concrete future date, ≤ REVIEW_CAP per day, oldest
|
||||
* first. Sunday is a two-line rest note.
|
||||
*
|
||||
* Idempotency: email_log keys sends by ET date — a same-day re-send is a
|
||||
* no-op unless forced; the body itself is deterministic (drill picks are
|
||||
@@ -21,21 +24,25 @@
|
||||
*/
|
||||
import {
|
||||
CAMPAIGN_DAYS,
|
||||
MISS_WHERE,
|
||||
type ProblemRow,
|
||||
RESERVED,
|
||||
REVIEW_CAP,
|
||||
SCHEDULE,
|
||||
type ScheduledDay,
|
||||
addDays,
|
||||
campaignDay,
|
||||
campaignWeek,
|
||||
dueReviews,
|
||||
isoWeek,
|
||||
levelReviews,
|
||||
lookupProblems,
|
||||
pickDrills,
|
||||
prettyDate,
|
||||
streak,
|
||||
weekdayOf,
|
||||
} from "./srs.ts";
|
||||
import { type DigestData, type DigestRow, renderDigest } from "./email.tsx";
|
||||
import { type DigestData, type DigestRow, type TopicRow, renderDigest } from "./email.tsx";
|
||||
import { signLink } from "./links.ts";
|
||||
|
||||
/** Total daily ceiling: drills only fill what the whole due queue leaves. */
|
||||
@@ -63,23 +70,49 @@ async function tapRow(env: Env, p: ProblemRow, date: string, staged: boolean): P
|
||||
};
|
||||
}
|
||||
|
||||
/** A plan-named problem: linked to its issue, ticked once it has entered the ladder. */
|
||||
function topicRow(env: Env, p: ProblemRow): TopicRow {
|
||||
return {
|
||||
lc: p.lc_number,
|
||||
difficulty: p.difficulty,
|
||||
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
|
||||
solved: p.stage !== "new",
|
||||
};
|
||||
}
|
||||
|
||||
/** Problems whose most recent attempt failed — the miss log a sweep day works through. */
|
||||
async function openMisses(db: D1Database): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT p.* FROM problems p
|
||||
JOIN attempts a ON a.lc_number = p.lc_number
|
||||
WHERE ${MISS_WHERE}
|
||||
ORDER BY a.date, p.lc_number`,
|
||||
)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Everything the email needs, read straight out of D1 and the schedule. */
|
||||
export async function collectDigest(env: Env, date: string): Promise<DigestData> {
|
||||
const db = env.DB;
|
||||
const rest = weekdayOf(date) === 0;
|
||||
const plan: ScheduledDay = rest ? {} : (SCHEDULE[date] ?? {});
|
||||
|
||||
// Reviews surface at most REVIEW_CAP; drills fill toward DAILY_CAP only
|
||||
// when the WHOLE due queue is light — a heavy backlog must never trade
|
||||
// review slots for brand-new drill problems.
|
||||
// review slots for brand-new drill problems — and never on a sweep day.
|
||||
const due = rest ? [] : await dueReviews(db, date);
|
||||
const capped = due.slice(0, REVIEW_CAP);
|
||||
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
|
||||
const drills =
|
||||
rest || plan.sweep ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
|
||||
|
||||
const data: DigestData = {
|
||||
day: prettyDate(date),
|
||||
progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`,
|
||||
streak: await streak(db, date),
|
||||
rest,
|
||||
topics: [],
|
||||
reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))),
|
||||
carried: due.length - capped.length,
|
||||
drills: await Promise.all(drills.map((p) => tapRow(env, p, date, false))),
|
||||
@@ -88,30 +121,56 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
|
||||
progressUrl: `${env.DOCS_URL}/progress`,
|
||||
};
|
||||
|
||||
// New topic — the only section allowed to name problems and link them.
|
||||
const topicIssue = rest ? undefined : SCHEDULE[date];
|
||||
if (topicIssue !== undefined) {
|
||||
// New topics — the plan's sections, the only ones allowed to name problems
|
||||
// and link them. Core first; the optional set is "if time", minus anything
|
||||
// the plan has reserved for a gate or a set day.
|
||||
for (const topicIssue of plan.topics ?? []) {
|
||||
const topic = await db
|
||||
.prepare("SELECT name FROM topics WHERE issue = ?")
|
||||
.bind(topicIssue)
|
||||
.first<{ name: string }>();
|
||||
const { results: core } = await db
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
"SELECT * FROM problems WHERE topic_issue = ? AND set_label = 'core' ORDER BY lc_number",
|
||||
"SELECT * FROM problems WHERE topic_issue = ? AND set_label IN ('core', 'optional') ORDER BY lc_number",
|
||||
)
|
||||
.bind(topicIssue)
|
||||
.all<ProblemRow>();
|
||||
data.topic = {
|
||||
data.topics.push({
|
||||
name: topic?.name ?? `#${topicIssue}`,
|
||||
core: core.map((p) => ({
|
||||
lc: p.lc_number,
|
||||
difficulty: p.difficulty,
|
||||
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
|
||||
solved: p.stage !== "new",
|
||||
})),
|
||||
core: results.filter((p) => p.set_label === "core").map((p) => topicRow(env, p)),
|
||||
ifTime: results
|
||||
.filter((p) => p.set_label === "optional" && !RESERVED.has(p.lc_number))
|
||||
.map((p) => topicRow(env, p)),
|
||||
});
|
||||
}
|
||||
|
||||
// A set day assigns problems by number rather than by topic.
|
||||
if (plan.problems) {
|
||||
const { rows, missing } = await lookupProblems(db, plan.problems);
|
||||
if (missing.length) console.warn(`digest ${date}: planned problems not in the catalog: ${missing.join(", ")}`);
|
||||
data.assigned = rows.map((p) => topicRow(env, p));
|
||||
}
|
||||
|
||||
// A review day: the `Review <N> — <Phase>` issue's problems, in its solve
|
||||
// order, linked like a set day's and headed by the issue itself.
|
||||
if (plan.review) {
|
||||
const { rows, missing } = await lookupProblems(db, plan.review.problems);
|
||||
if (missing.length) console.warn(`digest ${date}: review problems not in the catalog: ${missing.join(", ")}`);
|
||||
data.review = {
|
||||
title: plan.review.title,
|
||||
url: `https://github.com/${env.REPO}/issues/${plan.review.issue}`,
|
||||
rows: rows.map((p) => topicRow(env, p)),
|
||||
};
|
||||
}
|
||||
|
||||
// A sweep day works the miss log: every problem still standing on a
|
||||
// failure, minus the ones today's review list already carries.
|
||||
if (plan.sweep) {
|
||||
const reviewed = new Set(capped.map((p) => p.lc_number));
|
||||
const misses = (await openMisses(db)).filter((p) => !reviewed.has(p.lc_number));
|
||||
data.sweep = await Promise.all(misses.map((p) => tapRow(env, p, date, true)));
|
||||
}
|
||||
|
||||
// Saturday: the review issue already exists (created at midnight ET).
|
||||
if (weekdayOf(date) === 6) {
|
||||
const gate = await db
|
||||
|
||||
+127
-26
@@ -62,15 +62,22 @@ const DIFFICULTY: Record<string, { label: string; color: string }> = {
|
||||
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> = {
|
||||
new: "first look",
|
||||
"+2": "2 days ago",
|
||||
"+5": "5 days ago",
|
||||
"+10": "10 days ago",
|
||||
"+3": "3 days ago",
|
||||
"+7": "7 days ago",
|
||||
retired: "retired",
|
||||
};
|
||||
|
||||
// A review day's rules, the same ones its GitHub issue carries — one sentence
|
||||
// here so the HTML and the plain-text alternative cannot word them apart.
|
||||
const REVIEW_RULES =
|
||||
"90 minutes, top to bottom — easy to hard; skip what is already solved. " +
|
||||
"Before each submit, say the time and space complexity out loud and trace one edge case. " +
|
||||
"Whatever is unfinished stays open.";
|
||||
|
||||
// ── data model ───────────────────────────────────────────────────
|
||||
|
||||
/** A review or drill line: number + difficulty + one-tap links, nothing else. */
|
||||
@@ -83,7 +90,7 @@ export interface DigestRow {
|
||||
failUrl: string;
|
||||
}
|
||||
|
||||
/** A learning-day core problem — the only rows allowed a link. */
|
||||
/** A plan-named problem — the only rows allowed a link. */
|
||||
export interface TopicRow {
|
||||
lc: number;
|
||||
difficulty: string;
|
||||
@@ -91,18 +98,32 @@ export interface TopicRow {
|
||||
solved: boolean;
|
||||
}
|
||||
|
||||
/** One learning-day topic: the core list, then the optional "if time" list. */
|
||||
export interface TopicSection {
|
||||
name: string;
|
||||
core: TopicRow[];
|
||||
ifTime: TopicRow[];
|
||||
}
|
||||
|
||||
export interface DigestData {
|
||||
/** "Tuesday, August 25" */
|
||||
day: string;
|
||||
/** "Day 9 of 56 · Week 2" */
|
||||
/** "Day 9 of 79 · Week 2" — the length is CAMPAIGN_DAYS, derived from the plan. */
|
||||
progress: string;
|
||||
streak: number;
|
||||
rest: boolean;
|
||||
topic?: { name: string; core: TopicRow[] };
|
||||
/** Topics taught today, in plan order — empty on every other day. */
|
||||
topics: TopicSection[];
|
||||
/** A set day's problems, assigned by number rather than by topic. */
|
||||
assigned?: TopicRow[];
|
||||
/** A review day: the `Review <N> — <Phase>` issue and its problems in solve order. */
|
||||
review?: { title: string; url: string; rows: TopicRow[] };
|
||||
reviews: DigestRow[];
|
||||
/** Reviews past the daily cap; levelled onto the coming days. */
|
||||
carried: number;
|
||||
drills: DigestRow[];
|
||||
/** A sweep day's miss log: present (even empty) only on a sweep day. */
|
||||
sweep?: DigestRow[];
|
||||
gate?: { week: number; url: string };
|
||||
yesterday: { total: number; failed: number[] };
|
||||
gateRate?: number;
|
||||
@@ -278,10 +299,17 @@ function Block({
|
||||
// ── email ────────────────────────────────────────────────────────
|
||||
|
||||
function DigestEmail({ data }: { data: DigestData }) {
|
||||
const load = data.reviews.length + data.drills.length;
|
||||
const preview = data.rest
|
||||
? "Rest day — nothing to do but rest."
|
||||
: `${load} to work through today${data.topic ? `, starting with ${data.topic.name}` : ""}.`;
|
||||
const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
|
||||
const lead = data.topics[0]
|
||||
? `, starting with ${data.topics[0].name}`
|
||||
: data.review
|
||||
? `, starting with ${data.review.title}`
|
||||
: data.assigned
|
||||
? ", starting with today's set"
|
||||
: data.sweep
|
||||
? " — sweep day, nothing new"
|
||||
: "";
|
||||
const preview = data.rest ? "Rest day — nothing to do but rest." : `${load} to work through today${lead}.`;
|
||||
|
||||
const passed = data.yesterday.total - data.yesterday.failed.length;
|
||||
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
|
||||
@@ -302,7 +330,7 @@ function DigestEmail({ data }: { data: DigestData }) {
|
||||
<Text style={{ color: MUTED, fontSize: "13px", margin: "8px 0 0" }}>
|
||||
{data.progress}
|
||||
{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
|
||||
? " · 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.`}
|
||||
@@ -310,15 +338,60 @@ function DigestEmail({ data }: { data: DigestData }) {
|
||||
|
||||
{data.rest ? null : (
|
||||
<>
|
||||
{data.topic ? (
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Something new today</Text>
|
||||
{data.topics.map((topic, i) => (
|
||||
<Section key={topic.name} style={{ marginTop: i === 0 ? "28px" : "20px" }}>
|
||||
{i === 0 ? (
|
||||
<Text style={label}>
|
||||
{data.topics.length === 1 ? "Something new today" : "Two new things today"}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
||||
{data.topic.name}
|
||||
{topic.name}
|
||||
</Text>
|
||||
<Text style={hint}>Work through these from the top — a tick means it is already solved.</Text>
|
||||
<TopicTable rows={data.topic.core} />
|
||||
<TopicTable rows={topic.core} />
|
||||
{topic.ifTime.length > 0 ? (
|
||||
<>
|
||||
<Text style={{ ...hint, margin: "12px 0 8px" }}>
|
||||
If time — only once the core is done inside the clock.
|
||||
</Text>
|
||||
<TopicTable rows={topic.ifTime} />
|
||||
</>
|
||||
) : null}
|
||||
</Section>
|
||||
))}
|
||||
|
||||
{data.review ? (
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Today's review</Text>
|
||||
<Text style={{ fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
||||
<Link href={data.review.url} style={{ color: INK, textDecoration: "underline" }}>
|
||||
{data.review.title}
|
||||
</Link>
|
||||
</Text>
|
||||
<Text style={hint}>{REVIEW_RULES}</Text>
|
||||
<TopicTable rows={data.review.rows} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{data.assigned ? (
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Today's set</Text>
|
||||
<Text style={hint}>
|
||||
Picked by number, not by topic — all of them, in order, inside the clock.
|
||||
</Text>
|
||||
<TopicTable rows={data.assigned} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{data.sweep ? (
|
||||
<Block
|
||||
title="Sweep — redo every red"
|
||||
note="Every problem still standing on a miss. Nothing new today; clear these first."
|
||||
rows={data.sweep}
|
||||
staged
|
||||
empty="The miss log is clear — nothing is standing on a fail."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Block
|
||||
@@ -358,14 +431,14 @@ function DigestEmail({ data }: { data: DigestData }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Eight weeks at a glance — the one thing here that is pure
|
||||
{/* The whole campaign at a glance — the one thing here that is pure
|
||||
encouragement rather than instruction. */}
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Every day you showed up</Text>
|
||||
<Link href={data.progressUrl}>
|
||||
<Img
|
||||
src={data.heatmapUrl}
|
||||
alt="Attempt heatmap across the eight-week campaign"
|
||||
alt="Attempt heatmap across the whole campaign"
|
||||
width="552"
|
||||
style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }}
|
||||
/>
|
||||
@@ -403,22 +476,42 @@ function plainDigest(data: DigestData): string {
|
||||
data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`,
|
||||
data.progress +
|
||||
(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
|
||||
? " · 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.`),
|
||||
];
|
||||
|
||||
const linked = (p: TopicRow) =>
|
||||
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`;
|
||||
|
||||
if (!data.rest) {
|
||||
if (data.topic) {
|
||||
for (const topic of data.topics) {
|
||||
out.push(
|
||||
"",
|
||||
`SOMETHING NEW TODAY — ${data.topic.name}`,
|
||||
`SOMETHING NEW TODAY — ${topic.name}`,
|
||||
"Work through these from the top; the ones marked done you have already solved.",
|
||||
...data.topic.core.map(
|
||||
(p) =>
|
||||
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`,
|
||||
),
|
||||
...topic.core.map(linked),
|
||||
);
|
||||
if (topic.ifTime.length > 0) {
|
||||
out.push(" If time — only once the core is done inside the clock:", ...topic.ifTime.map(linked));
|
||||
}
|
||||
}
|
||||
if (data.review) {
|
||||
out.push(
|
||||
"",
|
||||
`TODAY'S REVIEW — ${data.review.title}`,
|
||||
` ${data.review.url}`,
|
||||
REVIEW_RULES,
|
||||
...data.review.rows.map(linked),
|
||||
);
|
||||
}
|
||||
if (data.assigned) {
|
||||
out.push(
|
||||
"",
|
||||
"TODAY'S SET",
|
||||
"Picked by number, not by topic — all of them, in order, inside the clock.",
|
||||
...data.assigned.map(linked),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -436,6 +529,14 @@ function plainDigest(data: DigestData): string {
|
||||
"No cold starts today.",
|
||||
],
|
||||
];
|
||||
if (data.sweep) {
|
||||
lists.unshift([
|
||||
"SWEEP — REDO EVERY RED",
|
||||
"Every problem still standing on a miss. Nothing new today; clear these first.",
|
||||
data.sweep,
|
||||
"The miss log is clear — nothing is standing on a fail.",
|
||||
]);
|
||||
}
|
||||
for (const [title, note, rows, empty] of lists) {
|
||||
out.push("", title);
|
||||
if (rows.length === 0) {
|
||||
|
||||
+33
-13
@@ -2,21 +2,28 @@
|
||||
* Saturday review issue: created at midnight ET so the 8 AM digest can link
|
||||
* to it; scored when the issue closes (webhook).
|
||||
*
|
||||
* The gate half is a topic-blind quiz — one unsolved optional problem per
|
||||
* topic scheduled this week (which is why daily drills only draw from
|
||||
* earlier weeks). The recap half is generated data, deliberately NOT a task
|
||||
* list: the week's solved problems are already scheduled by the +2 ladder,
|
||||
* and re-assigning them on Saturday would be massed practice.
|
||||
* The gate half is a topic-blind quiz. When the plan fixes the set
|
||||
* (`gate` in data/schedule.json) that list IS the gate, verbatim — every
|
||||
* number must be a catalog problem, or the issue is not created, because a
|
||||
* `/done` on an uncatalogued number can never be logged; an empty list skips
|
||||
* the week's checkpoint outright. Otherwise the set
|
||||
* is sampled: one unsolved optional problem per topic scheduled this week
|
||||
* (which is why daily drills only draw from earlier weeks), never one the
|
||||
* plan has reserved. The recap half is generated data, deliberately NOT a
|
||||
* task list: the week's solved problems are already scheduled by the +3/+7
|
||||
* ladder, and re-assigning them on Saturday would be massed practice.
|
||||
*
|
||||
* Label is `review` (the retired Actions system owned `gate`). Creation is
|
||||
* idempotent by week; deferred Hards never appear.
|
||||
* idempotent by week; deferred Hards never appear in a sampled set.
|
||||
*/
|
||||
import {
|
||||
type ProblemRow,
|
||||
RESERVED,
|
||||
type Result,
|
||||
SCHEDULE,
|
||||
campaignWeek,
|
||||
isoWeek,
|
||||
lookupProblems,
|
||||
rng,
|
||||
sample,
|
||||
streak,
|
||||
@@ -32,7 +39,7 @@ function capitalized(difficulty: string): string {
|
||||
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
|
||||
}
|
||||
|
||||
/** Unsolved problems of a topic in a set — never attempted, never drilled. */
|
||||
/** Unsolved problems of a topic in a set — never attempted, drilled, or reserved. */
|
||||
async function freshPool(db: D1Database, topic: number, set: string): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
@@ -44,7 +51,7 @@ async function freshPool(db: D1Database, topic: number, set: string): Promise<Pr
|
||||
)
|
||||
.bind(topic, set)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
return results.filter((p) => !RESERVED.has(p.lc_number));
|
||||
}
|
||||
|
||||
export interface CreateReport {
|
||||
@@ -67,12 +74,25 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
||||
return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` };
|
||||
}
|
||||
|
||||
// Topics scheduled this week; review-only weeks (Sep 23 onward) sample
|
||||
// across every topic that still has unsolved optional problems.
|
||||
// The plan's fixed set wins; otherwise sample this week's topics, and in a
|
||||
// week without topics, across every topic that still has an unsolved pool.
|
||||
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
||||
const planned = SCHEDULE[date]?.gate;
|
||||
if (planned?.length === 0) return { created: false, reason: `the plan skips the week ${week} checkpoint` };
|
||||
if (planned) {
|
||||
const { rows, missing } = await lookupProblems(db, planned);
|
||||
if (missing.length) {
|
||||
return {
|
||||
created: false,
|
||||
reason: `planned gate names problems outside the catalog: LC ${missing.join(", LC ")}`,
|
||||
};
|
||||
}
|
||||
picks.push(...rows.map((p) => ({ p, fallback: false })));
|
||||
} else {
|
||||
let topics = Object.entries(SCHEDULE)
|
||||
.filter(([d]) => campaignWeek(d) === week)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([, t]) => t);
|
||||
.flatMap(([, day]) => day.topics ?? []);
|
||||
if (topics.length === 0) {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
@@ -89,7 +109,6 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
||||
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
|
||||
|
||||
const random = rng(`gate-${iso}`);
|
||||
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
||||
for (const topic of topics) {
|
||||
const optional = await freshPool(db, topic, "optional");
|
||||
if (optional.length > 0) {
|
||||
@@ -101,6 +120,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
||||
// Both pools exhausted: the topic is fully solved; nothing to quiz.
|
||||
}
|
||||
if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` };
|
||||
}
|
||||
|
||||
// Recap: everything attempted this week, plus streak — data, not tasks.
|
||||
const monday = weekMonday(date);
|
||||
@@ -154,7 +174,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
||||
|
||||
const milestone = await db
|
||||
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
|
||||
.bind(topics[0])
|
||||
.bind(picks[0]!.p.topic_issue)
|
||||
.first<{ milestone: number }>();
|
||||
|
||||
const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
|
||||
|
||||
+47
-28
@@ -9,6 +9,7 @@
|
||||
* Writes are owner-only (webhook author check, HMAC one-tap links, bearer
|
||||
* admin routes); everything else is read-only public.
|
||||
*/
|
||||
import { handleAsk } from "./ask.ts";
|
||||
import { reconcileCatalog } from "./catalog.ts";
|
||||
import { gateBadge, heatmapChart, heatmapPng, ladderChart, progressChart } from "./charts.ts";
|
||||
import { sendDigest } from "./digest.ts";
|
||||
@@ -17,9 +18,12 @@ import { type GitHub, github } from "./github.ts";
|
||||
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
|
||||
import { type Mirror, projectMirror } from "./mirror.ts";
|
||||
import {
|
||||
CAMPAIGN_DAYS,
|
||||
CAMPAIGN_START,
|
||||
type LogOutcome,
|
||||
type Stage,
|
||||
daysBetween,
|
||||
dueToday,
|
||||
etDate,
|
||||
etHour,
|
||||
getProblem,
|
||||
@@ -30,27 +34,21 @@ import { buildStats } from "./stats.ts";
|
||||
|
||||
// ── shared side effects after a state write ──────────────────────
|
||||
|
||||
/** Project-field half of a mirror; shared by every write path. */
|
||||
async function mirrorFields(mirror: Mirror, outcome: LogOutcome): Promise<void> {
|
||||
await mirror.setStage(outcome.issue, outcome.stage);
|
||||
await mirror.setTargetDate(outcome.issue, outcome.next_review);
|
||||
if (outcome.first) await mirror.setFirstAttempt(outcome.issue, outcome.result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror one logged attempt into GitHub: Project fields always; on a
|
||||
* Mirror one logged attempt into GitHub: the Project's Target Date always; on a
|
||||
* first-ever pass also close the problem's sub-issue (comment first — repo
|
||||
* convention). Runs inside ctx.waitUntil; failures are warnings.
|
||||
*/
|
||||
async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Promise<void> {
|
||||
if (outcome.error || outcome.duplicate) return;
|
||||
const gh = github(env.GH_PAT, env.REPO);
|
||||
await mirrorFields(projectMirror(gh, env.REPO.split("/")[0]!), outcome);
|
||||
const mirror = projectMirror(gh, env.REPO.split("/")[0]!);
|
||||
await mirror.setTargetDate(outcome.issue, outcome.next_review);
|
||||
if (outcome.first && outcome.result === "pass") {
|
||||
try {
|
||||
await gh.closeIssue(
|
||||
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) {
|
||||
console.warn(`close #${outcome.issue}: ${err}`);
|
||||
@@ -102,7 +100,7 @@ async function handleTap(request: Request, env: Env, ctx: ExecutionContext): Pro
|
||||
}
|
||||
ctx.waitUntil(mirrorOutcome(env, outcome, "email"));
|
||||
return page(
|
||||
result === "pass" ? "Logged ✅" : "Logged — back to +2",
|
||||
result === "pass" ? "Logged ✅" : "Logged — back to +3",
|
||||
outcomeLine(outcome),
|
||||
);
|
||||
}
|
||||
@@ -208,47 +206,60 @@ async function adminAuthorized(request: Request, env: Env): Promise<boolean> {
|
||||
/**
|
||||
* 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
|
||||
* implemented set here on every push to main: logAttempt ignores anything
|
||||
* already on the ladder, so this is a total recompute like the reconcilers
|
||||
* that call it — a re-run changes nothing.
|
||||
* implemented set here on every push to main, each entry tagged with the
|
||||
* BUCKET the file sits in — 1 for the first solve, 3 and 7 for the blind
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
const RUNG_OF_BUCKET: Record<number, Stage> = { 1: "new", 3: "+3", 7: "+7" };
|
||||
|
||||
async function handleSolved(request: Request, env: Env, date: string): Promise<Response> {
|
||||
const body: unknown = await request.json().catch(() => null);
|
||||
const raw: unknown = body && typeof body === "object" && "lc" in body ? body.lc : null;
|
||||
if (!Array.isArray(raw)) return new Response('expected {"lc": [<number>, ...]}', { status: 400 });
|
||||
const lcs: number[] = [];
|
||||
const raw: unknown = body && typeof body === "object" && "solved" in body ? body.solved : null;
|
||||
const shape = 'expected {"solved": [{"lc": <number>, "bucket": 1|3|7}, ...]}';
|
||||
if (!Array.isArray(raw)) return new Response(shape, { status: 400 });
|
||||
const entries: { lc: number; rung: Stage; bucket: number }[] = [];
|
||||
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)) {
|
||||
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 logged: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
let mirror: Mirror | undefined;
|
||||
for (const lc of lcs) {
|
||||
for (const { lc, rung, bucket } of entries) {
|
||||
if (dry) {
|
||||
const p = await getProblem(env.DB, lc);
|
||||
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 logged.push(`LC ${lc}: would enter the ladder at +2`);
|
||||
else if (p.stage !== rung) skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${p.stage}`);
|
||||
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;
|
||||
}
|
||||
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);
|
||||
else if (outcome.duplicate) skipped.push(`LC ${lc}: already at stage ${outcome.stage}`);
|
||||
else {
|
||||
else if (outcome.duplicate) {
|
||||
skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${outcome.stage}`);
|
||||
} else {
|
||||
logged.push(outcomeLine(outcome));
|
||||
// One mirror for the batch: field IDs resolve once, not per problem.
|
||||
mirror ??= projectMirror(github(env.GH_PAT, env.REPO), env.REPO.split("/")[0]!);
|
||||
await mirrorFields(mirror, outcome);
|
||||
await mirror.setTargetDate(outcome.issue, outcome.next_review);
|
||||
}
|
||||
}
|
||||
return Response.json({ date, logged, skipped, warnings: mirror?.warnings ?? [] });
|
||||
@@ -277,6 +288,12 @@ async function handleAdmin(request: Request, env: Env, path: string): Promise<Re
|
||||
if (path === "/admin/review") {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -307,6 +324,8 @@ export default {
|
||||
if (path.startsWith("/admin/") && request.method === "POST") {
|
||||
return await handleAdmin(request, env, path);
|
||||
}
|
||||
// The docs assistant (the static Pages site's `ai.assistant.endpoint`).
|
||||
if (path === "/ask") return await handleAsk(request, env);
|
||||
|
||||
if (path === "/chart/progress.svg") {
|
||||
return new Response(await progressChart(env.DB), { headers: SVG_HEADERS });
|
||||
@@ -315,10 +334,10 @@ export default {
|
||||
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
|
||||
}
|
||||
if (path === "/chart/heatmap.svg") {
|
||||
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, 56), { headers: SVG_HEADERS });
|
||||
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, CAMPAIGN_DAYS), { headers: SVG_HEADERS });
|
||||
}
|
||||
if (path === "/chart/heatmap.png") {
|
||||
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, 56), { headers: PNG_HEADERS });
|
||||
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, CAMPAIGN_DAYS), { headers: PNG_HEADERS });
|
||||
}
|
||||
if (path === "/badge/gate.svg") {
|
||||
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
|
||||
|
||||
+10
-39
@@ -4,30 +4,26 @@
|
||||
* console warning and never blocks a state write. Only problem issues are
|
||||
* ever passed in, so topic rows' Target Date is never written.
|
||||
*
|
||||
* Field/option IDs are resolved by NAME per invocation (a Worker isolate is
|
||||
* short-lived; the two extra queries per mirror burst are irrelevant at this
|
||||
* volume and survive field re-creation).
|
||||
* Target Date is the ONLY mirrored column. SRS stage and first-attempt result
|
||||
* live exclusively in D1 — they are read from there (charts, digest, /api/stats)
|
||||
* and duplicating them into Project single-selects bought nothing but drift and
|
||||
* two extra writes per log. Do not re-add them here.
|
||||
*
|
||||
* Field IDs are resolved by NAME per invocation (a Worker isolate is
|
||||
* short-lived; the extra query per mirror burst is irrelevant at this
|
||||
* volume and survives field re-creation).
|
||||
*/
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
const PROJECT_TITLE = "Interview Prep";
|
||||
|
||||
interface SelectField {
|
||||
id: string;
|
||||
options: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ProjectInfo {
|
||||
id: string;
|
||||
targetDate: string;
|
||||
srsStage: SelectField;
|
||||
firstAttempt: SelectField;
|
||||
}
|
||||
|
||||
export interface Mirror {
|
||||
setTargetDate(issue: number, date: string | null): Promise<void>;
|
||||
setStage(issue: number, stage: string): Promise<void>;
|
||||
setFirstAttempt(issue: number, result: string): Promise<void>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
@@ -47,7 +43,6 @@ export function projectMirror(gh: GitHub, owner: string): Mirror {
|
||||
fields(first: 30) {
|
||||
nodes {
|
||||
... on ProjectV2FieldCommon { id name dataType }
|
||||
... on ProjectV2SingleSelectField { id name options { id name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,7 +57,7 @@ export function projectMirror(gh: GitHub, owner: string): Mirror {
|
||||
id: string;
|
||||
title: string;
|
||||
fields: {
|
||||
nodes: { id: string; name: string; options?: { id: string; name: string }[] }[];
|
||||
nodes: { id: string; name: string }[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
@@ -70,19 +65,9 @@ export function projectMirror(gh: GitHub, owner: string): Mirror {
|
||||
};
|
||||
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
|
||||
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
|
||||
const select = (name: string): SelectField => {
|
||||
const f = node.fields.nodes.find((n) => n.name === name);
|
||||
if (!f?.options) throw new Error(`single-select field "${name}" missing`);
|
||||
return { id: f.id, options: Object.fromEntries(f.options.map((o) => [o.name, o.id])) };
|
||||
};
|
||||
const date = node.fields.nodes.find((n) => n.name === "Target Date");
|
||||
if (!date) throw new Error(`date field "Target Date" missing`);
|
||||
info = {
|
||||
id: node.id,
|
||||
targetDate: date.id,
|
||||
srsStage: select("SRS Stage"),
|
||||
firstAttempt: select("First Attempt"),
|
||||
};
|
||||
info = { id: node.id, targetDate: date.id };
|
||||
return info;
|
||||
}
|
||||
|
||||
@@ -154,19 +139,5 @@ export function projectMirror(gh: GitHub, owner: string): Mirror {
|
||||
await setField(issue, project.targetDate, { date });
|
||||
}
|
||||
}),
|
||||
setStage: (issue, stage) =>
|
||||
attempt(`SRS Stage #${issue}`, async () => {
|
||||
const project = await resolve();
|
||||
const option = project.srsStage.options[stage];
|
||||
if (!option) throw new Error(`no option "${stage}"`);
|
||||
await setField(issue, project.srsStage.id, { singleSelectOptionId: option });
|
||||
}),
|
||||
setFirstAttempt: (issue, result) =>
|
||||
attempt(`First Attempt #${issue}`, async () => {
|
||||
const project = await resolve();
|
||||
const option = project.firstAttempt.options[result];
|
||||
if (!option) throw new Error(`no option "${result}"`);
|
||||
await setField(issue, project.firstAttempt.id, { singleSelectOptionId: option });
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* `Review <N> — <Phase>` issues: the transfer phase's day blocks. Problems were
|
||||
* re-parented into them from their topics (`replace_parent` — a GitHub issue
|
||||
* has exactly one parent), so a topic's sub-issue list no longer names every
|
||||
* problem it owns. This module is the ONE recovery of that ownership: each
|
||||
* moved problem's topic is its last `parent_issue_removed` event naming a
|
||||
* topic, which GitHub records and no human edits.
|
||||
*
|
||||
* Two consumers must agree on it: the Worker's catalog reconcile
|
||||
* (catalog.ts — else a moved problem's D1 row stops syncing its labels) and
|
||||
* the CLI's topic rules (apps/cli/topics.ts — else close-topics and the
|
||||
* release bodies change when a problem moves). Hence it lives here, the lower
|
||||
* layer, the way apps/cli already imports srs.ts.
|
||||
*
|
||||
* Library: side-effect-free on import; the caller passes its GitHub client —
|
||||
* the Worker's or the CLI's, which share the two members used here.
|
||||
*/
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
/** `Review 7 — Nodal & Grid`, never the Worker's weekly `Review — Week N` gate. */
|
||||
const REVIEW_TITLE = /^Review \d+ — /;
|
||||
const TOPIC_TITLE = /^Topic \d+ — /;
|
||||
|
||||
/** One problem issue as a topic or review lists it. */
|
||||
export interface Problem {
|
||||
number: number;
|
||||
/** `LC <num> · <Name>`. */
|
||||
title: string;
|
||||
/** ISO instant the issue closed; null while it is open. */
|
||||
closedAt: string | null;
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
export interface Review {
|
||||
number: number;
|
||||
title: string;
|
||||
open: boolean;
|
||||
/** Markdown body: `**Scheduled:**` / `**Phase:**` / `**Load:**` lines the release reads. */
|
||||
body: string;
|
||||
/** Sub-issues in the order GitHub lists them (the solve order). */
|
||||
problems: (Problem & { fromTopic: number | null })[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `Review <N> — <Phase>` issue, open and closed, with its sub-issues.
|
||||
* One GraphQL request per 50 `review`-labelled issues (the Worker's weekly
|
||||
* gate issues share the label and are dropped by title): the sub-issues and
|
||||
* the timeline filter ride along, so recovering each problem's source topic
|
||||
* costs no extra request. That matters in the Worker, whose nightly cron
|
||||
* spends its subrequest budget on the catalog walk and the digest.
|
||||
*/
|
||||
export async function readReviews(gh: Pick<GitHub, "repo" | "graphql">): Promise<Review[]> {
|
||||
const [owner, name] = gh.repo.split("/");
|
||||
const reviews: Review[] = [];
|
||||
for (let after: string | null = null; ; ) {
|
||||
// Shape fixed by the query below; GraphQL errors throw in gh.graphql().
|
||||
const data = (await gh.graphql(
|
||||
`query($owner: String!, $name: String!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issues(labels: ["review"], first: 50, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
number title state body
|
||||
subIssues(first: 50) {
|
||||
pageInfo { hasNextPage }
|
||||
nodes {
|
||||
number title state closedAt
|
||||
labels(first: 20) { nodes { name } }
|
||||
timelineItems(itemTypes: [PARENT_ISSUE_REMOVED_EVENT], last: 10) {
|
||||
nodes { ... on ParentIssueRemovedEvent { parent { number title } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner, name, after },
|
||||
)) as {
|
||||
repository: {
|
||||
issues: {
|
||||
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
||||
nodes: {
|
||||
number: number;
|
||||
title: string;
|
||||
state: "OPEN" | "CLOSED";
|
||||
body: string;
|
||||
subIssues: {
|
||||
pageInfo: { hasNextPage: boolean };
|
||||
nodes: {
|
||||
number: number;
|
||||
title: string;
|
||||
state: "OPEN" | "CLOSED";
|
||||
closedAt: string | null;
|
||||
labels: { nodes: { name: string }[] };
|
||||
timelineItems: { nodes: { parent: { number: number; title: string } | null }[] };
|
||||
}[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
const page = data.repository.issues;
|
||||
for (const issue of page.nodes) {
|
||||
if (!REVIEW_TITLE.test(issue.title)) continue;
|
||||
// A review is one day's handful; more than a page means a mis-linked issue.
|
||||
if (issue.subIssues.pageInfo.hasNextPage) throw new Error(`review #${issue.number} has over 50 sub-issues`);
|
||||
reviews.push({
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
open: issue.state === "OPEN",
|
||||
body: issue.body,
|
||||
problems: issue.subIssues.nodes.map((p) => ({
|
||||
number: p.number,
|
||||
title: p.title,
|
||||
closedAt: p.state === "CLOSED" ? p.closedAt : null,
|
||||
labels: p.labels.nodes.map((l) => l.name),
|
||||
// Oldest first, so the last topic it left is its owner; a later
|
||||
// review-to-review move names a review and is skipped.
|
||||
fromTopic:
|
||||
p.timelineItems.nodes.findLast((e) => e.parent && TOPIC_TITLE.test(e.parent.title))?.parent?.number ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (!page.pageInfo.hasNextPage) break;
|
||||
after = page.pageInfo.endCursor;
|
||||
}
|
||||
return reviews.sort((a, b) => a.number - b.number);
|
||||
}
|
||||
|
||||
/** Topic -> the problems moved out of it into Review issues, in review order. */
|
||||
export function movedFromTopics(reviews: Review[]): Map<number, Problem[]> {
|
||||
const moved = new Map<number, Problem[]>();
|
||||
for (const review of reviews) {
|
||||
for (const { fromTopic, ...problem } of review.problems) {
|
||||
if (fromTopic !== null) moved.set(fromTopic, [...(moved.get(fromTopic) ?? []), problem]);
|
||||
}
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
+164
-4
@@ -1,11 +1,35 @@
|
||||
/**
|
||||
* DST-guard and date-math checks with fixed instants — the acceptance proof
|
||||
* that the double crons fire exactly once at 8 AM ET (digest) and midnight
|
||||
* Saturday ET (review issue) on BOTH UTC offsets.
|
||||
* Acceptance proof for the two rules that cannot be eyeballed in production:
|
||||
* the double crons fire exactly once at 8 AM ET (digest) and midnight Saturday
|
||||
* 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 { 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", () => {
|
||||
test("digest fires only at ET hour 8 — EDT (UTC-4)", () => {
|
||||
@@ -50,6 +74,142 @@ 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);
|
||||
// Reviews run Mon/Tue and Thu/Fri; Wednesday and the weekend stay free.
|
||||
if (day.review) expect([1, 2, 4, 5]).toContain(weekdayOf(date));
|
||||
}
|
||||
});
|
||||
|
||||
test("a topic is taught once; a doubled day answers for both", () => {
|
||||
const seen = new Set<number>();
|
||||
for (const day of Object.values(SCHEDULE)) {
|
||||
for (const topic of day.topics ?? []) {
|
||||
expect(seen.has(topic)).toBe(false);
|
||||
seen.add(topic);
|
||||
}
|
||||
}
|
||||
expect(topicDate(17)).toBe("2026-09-21");
|
||||
expect(topicDate(18)).toBe("2026-09-21");
|
||||
expect(topicDate(999)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a review is booked once, and no problem sits in two reviews", () => {
|
||||
const issues = new Set<number>();
|
||||
const problems = new Set<number>();
|
||||
for (const day of Object.values(SCHEDULE)) {
|
||||
if (!day.review) continue;
|
||||
expect(issues.has(day.review.issue)).toBe(false);
|
||||
issues.add(day.review.issue);
|
||||
for (const lc of day.review.problems) {
|
||||
expect(problems.has(lc)).toBe(false);
|
||||
problems.add(lc);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("every gate and set problem is reserved from the samplers", () => {
|
||||
for (const day of Object.values(SCHEDULE)) {
|
||||
for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) {
|
||||
expect(RESERVED.has(lc)).toBe(true);
|
||||
}
|
||||
}
|
||||
expect(RESERVED.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ladder", () => {
|
||||
test("two rungs, named after the windows", () => {
|
||||
expect(STAGES).toEqual(["new", "+3", "+7", "retired"]);
|
||||
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", () => {
|
||||
test("same seed, same picks", () => {
|
||||
const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
|
||||
+301
-40
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* SRS domain: ET dates, the interval ladder, deterministic sampling, and the
|
||||
* one write path for attempts — ported from scripts/srs.ts, re-homed on D1.
|
||||
* SRS domain: ET dates, the work week, the interval ladder, concept
|
||||
* temperature, deterministic sampling, and the one write path for attempts.
|
||||
*
|
||||
* D1 is the single source of truth. The stage names the review a problem must
|
||||
* pass NEXT (`+2` = due 2 days after last clean solve). Passing advances
|
||||
* new → +2 → +5 → +10 → retired; any failure resets to +2. A problem's
|
||||
* FIRST-ever log enters the ladder at +2 regardless of result: a pass earns
|
||||
* a +2 review, a fail must be re-solved just as soon.
|
||||
* pass NEXT (`+3` = due 3 working days after the last clean solve). Passing
|
||||
* advances new → +3 → +7 → retired; any failure resets to +3. A problem's
|
||||
* FIRST-ever log enters the ladder at +3 regardless of result: a pass earns a
|
||||
* 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
|
||||
* anchored at noon UTC so DST edges cannot shift a date. Never raw Date math.
|
||||
@@ -15,8 +22,8 @@ import scheduleJson from "../data/schedule.json";
|
||||
|
||||
// ── dates (America/New_York) ─────────────────────────────────────
|
||||
|
||||
/** Day 1. The last day is not a constant: see CAMPAIGN_END below the schedule. */
|
||||
export const CAMPAIGN_START = "2026-08-17";
|
||||
export const CAMPAIGN_DAYS = 56;
|
||||
|
||||
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "America/New_York",
|
||||
@@ -56,6 +63,37 @@ export function weekdayOf(date: string): number {
|
||||
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 {
|
||||
return daysBetween(CAMPAIGN_START, date) + 1;
|
||||
}
|
||||
@@ -88,16 +126,74 @@ export function prettyDate(date: string): string {
|
||||
|
||||
// ── the schedule (bundled; humans edit apps/api/data/schedule.json) ───
|
||||
|
||||
export const SCHEDULE: Record<string, number> = scheduleJson;
|
||||
/**
|
||||
* One calendar day of the plan. Most days teach topics (one, or two when the
|
||||
* plan doubles up); the rest are named by what they assign instead: a fixed
|
||||
* gate set for Saturday's review issue, a fixed problem set (a "B-set" day),
|
||||
* a transfer-phase review day, or a sweep — redo every miss, nothing new.
|
||||
* Absent dates are unplanned: the ladder still runs, the digest just has
|
||||
* nothing new to say.
|
||||
*/
|
||||
export interface ScheduledDay {
|
||||
/** Topic issues taught that day, in the order the digest presents them. */
|
||||
topics?: number[];
|
||||
/** Problems assigned by number instead of by topic. */
|
||||
problems?: number[];
|
||||
/**
|
||||
* A `Review <N> — <Phase>` day (Mon/Tue/Thu/Fri only): the issue, its title
|
||||
* and its problems in solve order. The issue's sub-issues are the truth; this
|
||||
* copy is what the digest and RESERVED read without calling GitHub, and the
|
||||
* catalog reconcile reports any drift between the two.
|
||||
*/
|
||||
review?: { issue: number; title: string; problems: number[] };
|
||||
/**
|
||||
* Saturday: the blind set, fixed by the plan rather than sampled. An empty
|
||||
* list skips that week's checkpoint — no issue is created.
|
||||
*/
|
||||
gate?: number[];
|
||||
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
|
||||
sweep?: boolean;
|
||||
}
|
||||
|
||||
/** Week in which a topic was (or will be) taught. */
|
||||
export function topicWeek(topic: number): number | undefined {
|
||||
for (const [date, t] of Object.entries(SCHEDULE)) {
|
||||
if (t === topic) return campaignWeek(date);
|
||||
export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
|
||||
|
||||
/**
|
||||
* The last day the plan books anything — the campaign ends there. Derived,
|
||||
* not declared: booking a later day (the review phase ran the plan past its
|
||||
* original eight weeks) moves the digest's "Day N/M", the stats' `heat` and
|
||||
* the heatmap charts with it, and nothing can disagree about when it ends.
|
||||
*/
|
||||
export const CAMPAIGN_END = Object.keys(SCHEDULE).sort().at(-1)!;
|
||||
export const CAMPAIGN_DAYS = daysBetween(CAMPAIGN_START, CAMPAIGN_END) + 1;
|
||||
|
||||
/** Date the schedule teaches a topic on, if it teaches it at all. */
|
||||
export function topicDate(topic: number): string | undefined {
|
||||
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||
if (day.topics?.includes(topic)) return date;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Week in which a topic was (or will be) taught. */
|
||||
export function topicWeek(topic: number): number | undefined {
|
||||
const date = topicDate(topic);
|
||||
return date === undefined ? undefined : campaignWeek(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every problem the plan names outright — a gate's blind set, a set day's
|
||||
* list, or a review day's. The plan owns these: the samplers (daily drills,
|
||||
* a sampled gate) never draw them, so a gate problem stays unseen until its
|
||||
* Saturday and a set or review day's problems wait for their day.
|
||||
*/
|
||||
export const RESERVED: ReadonlySet<number> = new Set(
|
||||
Object.values(SCHEDULE).flatMap((day) => [
|
||||
...(day.gate ?? []),
|
||||
...(day.problems ?? []),
|
||||
...(day.review?.problems ?? []),
|
||||
]),
|
||||
);
|
||||
|
||||
// ── deterministic sampling ───────────────────────────────────────
|
||||
|
||||
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
|
||||
@@ -126,10 +222,63 @@ export function sample<T>(pool: T[], n: number, random: () => number): T[] {
|
||||
|
||||
// ── 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 Kind = "first" | "review" | "drill" | "gate";
|
||||
|
||||
/**
|
||||
* Catalog vocabulary, carried by the `diff:*` / `set:*` issue labels and
|
||||
* stored verbatim in `problems`. Listed low to high / most to least required,
|
||||
* which is the order every chart, table and digest section prints them in.
|
||||
*/
|
||||
export const DIFFICULTIES = ["easy", "medium", "hard"] as const;
|
||||
export type Difficulty = (typeof DIFFICULTIES)[number];
|
||||
export const SETS = ["core", "optional", "deferred"] as const;
|
||||
export type SetLabel = (typeof SETS)[number];
|
||||
|
||||
/**
|
||||
* How warm a concept is: the age of a topic's most recent attempt, bucketed by
|
||||
* the ladder's own windows so the two cannot drift. Touched inside the first
|
||||
* window it is `hot`, inside the second `fresh`, inside one more full second
|
||||
* window `fading`; older than that — or never attempted — it is `cold`.
|
||||
*
|
||||
* A read-side label only: nothing is scheduled off it. It answers the question
|
||||
* the ladder cannot, because the ladder tracks problems and a topic can go
|
||||
* quiet for a fortnight while not one of its problems comes due.
|
||||
*/
|
||||
export const TEMPERATURES = ["hot", "fresh", "fading", "cold"] as const;
|
||||
export type Temperature = (typeof TEMPERATURES)[number];
|
||||
|
||||
/** Warmest band first, with the age it tolerates. Past the last one is cold. */
|
||||
const TEMPERATURE_MAX_AGE: [Temperature, number][] = [
|
||||
["hot", WINDOWS[0]],
|
||||
["fresh", WINDOWS[1]],
|
||||
["fading", WINDOWS[1] * 2],
|
||||
];
|
||||
|
||||
/** Days since a topic's last attempt → its band; null (never) is cold. */
|
||||
export function temperatureOf(age: number | null): Temperature {
|
||||
if (age === null) return "cold";
|
||||
for (const [temperature, max] of TEMPERATURE_MAX_AGE) {
|
||||
if (age <= max) return temperature;
|
||||
}
|
||||
return "cold";
|
||||
}
|
||||
|
||||
export interface ProblemRow {
|
||||
lc_number: number;
|
||||
issue: number;
|
||||
@@ -142,8 +291,8 @@ export interface ProblemRow {
|
||||
defer_until: string | null;
|
||||
}
|
||||
|
||||
export const INTERVAL: Record<string, number> = { "+2": 2, "+5": 5, "+10": 10 };
|
||||
const NEXT_STAGE: Record<string, Stage> = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" };
|
||||
export const INTERVAL: Record<string, number> = { "+3": 3, "+7": 7 };
|
||||
const NEXT_STAGE: Record<string, Stage> = { new: "+3", "+3": "+7", "+7": "retired" };
|
||||
|
||||
// ── queries ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -151,31 +300,131 @@ export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow
|
||||
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog rows for a list of numbers the plan names, in the plan's order,
|
||||
* plus the numbers the catalog does not know — a planned problem without an
|
||||
* issue can never be logged, so callers decide whether that is fatal.
|
||||
*/
|
||||
export async function lookupProblems(
|
||||
db: D1Database,
|
||||
lcs: number[],
|
||||
): Promise<{ rows: ProblemRow[]; missing: number[] }> {
|
||||
if (lcs.length === 0) return { rows: [], missing: [] };
|
||||
const { results } = await db
|
||||
.prepare(`SELECT * FROM problems WHERE lc_number IN (${lcs.map(() => "?").join(",")})`)
|
||||
.bind(...lcs)
|
||||
.all<ProblemRow>();
|
||||
const byNumber = new Map(results.map((p) => [p.lc_number, p]));
|
||||
const rows: ProblemRow[] = [];
|
||||
const missing: number[] = [];
|
||||
for (const lc of lcs) {
|
||||
const row = byNumber.get(lc);
|
||||
if (row) rows.push(row);
|
||||
else missing.push(lc);
|
||||
}
|
||||
return { rows, missing };
|
||||
}
|
||||
|
||||
/**
|
||||
* What "due" means, in one place: not retired, scheduled on/before ?1, and
|
||||
* past its deferral if it has one. Every caller that asks "what is open on
|
||||
* 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)`;
|
||||
|
||||
/**
|
||||
* What an open miss means, in one place: a problem `p` whose most recent
|
||||
* attempt (highest id) failed and that has not retired. The sweep day works
|
||||
* these and the stats' "attempting" count is how many there are, so the two
|
||||
* cannot disagree. Expects `problems p` joined to `attempts a` on lc_number.
|
||||
*/
|
||||
export const MISS_WHERE = `a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number)
|
||||
AND a.result = 'fail' AND p.stage != 'retired'`;
|
||||
|
||||
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
||||
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`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`,
|
||||
)
|
||||
.prepare(`SELECT * FROM problems WHERE ${DUE_WHERE} ORDER BY next_review, lc_number`)
|
||||
.bind(date)
|
||||
.all<ProblemRow>();
|
||||
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. */
|
||||
export const REVIEW_CAP = 3;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* day, oldest first — instead of piling up as "due today". Runs every digest
|
||||
* morning, so a future day that grows past the cap (spill plus newly
|
||||
* REVIEW_CAP is pushed to a concrete future WORKING day — at most REVIEW_CAP
|
||||
* per day, oldest first — instead of piling up as "due today". Runs every
|
||||
* 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
|
||||
* 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> {
|
||||
const overflow = (await dueReviews(db, date)).slice(REVIEW_CAP);
|
||||
@@ -184,7 +433,7 @@ export async function levelReviews(db: D1Database, date: string): Promise<number
|
||||
overflow.map((p, i) =>
|
||||
db
|
||||
.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;
|
||||
@@ -195,8 +444,9 @@ export async function levelReviews(db: D1Database, date: string): Promise<number
|
||||
* scheduled in an earlier week (the current week's optional pool is reserved
|
||||
* for Saturday's gate) AND showing learning evidence: at least one of the
|
||||
* topic's core problems has entered the ladder. A skipped learning day never
|
||||
* feeds drills just because its calendar week lapsed. Never repeated,
|
||||
* seeded by date. Boosted topics contribute up to 2 extra.
|
||||
* feeds drills just because its calendar week lapsed. Problems the plan names
|
||||
* outright (RESERVED) are never drawn. Never repeated, seeded by date.
|
||||
* Boosted topics contribute up to 2 extra.
|
||||
*/
|
||||
export async function pickDrills(
|
||||
db: D1Database,
|
||||
@@ -219,6 +469,7 @@ export async function pickDrills(
|
||||
)
|
||||
.all<ProblemRow & { boost: number }>();
|
||||
const pool = results.filter((p) => {
|
||||
if (RESERVED.has(p.lc_number)) return false;
|
||||
const w = topicWeek(p.topic_issue);
|
||||
return w !== undefined && w < week;
|
||||
});
|
||||
@@ -273,9 +524,14 @@ export interface LogOutcome {
|
||||
* Email idempotency comes from the partial unique index on
|
||||
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
||||
* nothing and must not touch the ladder. Webhook corrections (pass then fail
|
||||
* on the same day) remain legal — every webhook attempt appends. Commit
|
||||
* idempotency needs no index: a commit records a FIRST solve only, so the
|
||||
* stage check below turns every re-push into a no-op.
|
||||
* on the same day) remain legal — every webhook attempt appends.
|
||||
*
|
||||
* 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(
|
||||
db: D1Database,
|
||||
@@ -284,6 +540,8 @@ export async function logAttempt(
|
||||
date: string;
|
||||
result: Result;
|
||||
source: "email" | "webhook" | "commit";
|
||||
/** Commit only: the stage this file settles. Default `new` = first solve. */
|
||||
rung?: Stage;
|
||||
gate?: boolean;
|
||||
},
|
||||
): Promise<LogOutcome> {
|
||||
@@ -300,11 +558,12 @@ export async function logAttempt(
|
||||
duplicate: false,
|
||||
};
|
||||
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
|
||||
// reconciler re-sending the whole solved set — must never move the ladder,
|
||||
// and must never wake a retired problem. Reviews come from the digest tap,
|
||||
// a /done comment, or a gate.
|
||||
if (opts.source === "commit" && p.stage !== "new") {
|
||||
// A commit reports a file, not an event: it may only settle the rung that
|
||||
// file's bucket IS. Any other stage means the push is old news (the ladder
|
||||
// already moved past it) or premature — no write either way, and a retired
|
||||
// problem can never be woken. Fresh reviews otherwise come from the digest
|
||||
// tap, a /done comment, or a gate.
|
||||
if (opts.source === "commit" && p.stage !== (opts.rung ?? "new")) {
|
||||
return {
|
||||
...nothing,
|
||||
title: p.title,
|
||||
@@ -336,16 +595,18 @@ export async function logAttempt(
|
||||
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;
|
||||
if (first) {
|
||||
stage = "+2";
|
||||
stage = "+3";
|
||||
} else if (opts.result === "pass") {
|
||||
stage = NEXT_STAGE[p.stage] ?? "retired";
|
||||
} 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 = [
|
||||
db.prepare(
|
||||
|
||||
+239
-29
@@ -1,16 +1,34 @@
|
||||
/**
|
||||
* GET /api/stats — the one JSON document behind the docs progress page.
|
||||
* Read-only aggregation over D1; shape is the page's contract, change both
|
||||
* together. A problem counts as "done" once it entered the ladder
|
||||
* (stage != 'new').
|
||||
* GET /api/stats — the one JSON document behind the docs progress page and the
|
||||
* `bun run stats` terminal dashboard. Read-only aggregation over D1; the shape
|
||||
* is both readers' contract, so change all three together. A problem counts as
|
||||
* "done" once it entered the ladder (stage != 'new').
|
||||
*
|
||||
* The temperature block is the one figure here that is not a count of rows:
|
||||
* srs.ts turns a topic's days-since-last-attempt into a band, so "which
|
||||
* concepts have gone cold" has exactly one definition and the dashboard is
|
||||
* free to colour it however it likes.
|
||||
*
|
||||
* `recent`, `attempting` and `year` feed the docs page's LeetCode-style cards
|
||||
* (recently solved, the solved ring, the heatmap and the submissions chart).
|
||||
* They are additive: the terminal dashboard ignores them.
|
||||
*/
|
||||
import {
|
||||
CAMPAIGN_DAYS,
|
||||
CAMPAIGN_START,
|
||||
DIFFICULTIES,
|
||||
DUE_WHERE,
|
||||
MISS_WHERE,
|
||||
addDays,
|
||||
campaignDay,
|
||||
campaignWeek,
|
||||
daysBetween,
|
||||
isoWeek,
|
||||
STAGES,
|
||||
streak,
|
||||
temperatureOf,
|
||||
topicDate,
|
||||
type Temperature,
|
||||
} from "./srs.ts";
|
||||
|
||||
const PHASE_NAMES: Record<number, string> = {
|
||||
@@ -21,6 +39,97 @@ const PHASE_NAMES: Record<number, string> = {
|
||||
5: "V — Decision Space",
|
||||
};
|
||||
|
||||
/** One concept: catalog coverage plus how long since it was last exercised. */
|
||||
interface TopicStat {
|
||||
issue: number;
|
||||
name: string;
|
||||
milestone: number | null;
|
||||
/** Campaign week the schedule teaches it in; null when unscheduled. */
|
||||
week: number | null;
|
||||
/** False while the schedule has not reached it — temperature is moot. */
|
||||
taught: boolean;
|
||||
total: number;
|
||||
done: number;
|
||||
retired: number;
|
||||
due: number;
|
||||
/** ET date of the most recent attempt on any of its problems. */
|
||||
last: string | null;
|
||||
/** Days since `last`; null when never attempted. */
|
||||
age: number | null;
|
||||
temperature: Temperature;
|
||||
}
|
||||
|
||||
/** Days in `year`: the 365 ending today, which always covers the current calendar year to date. */
|
||||
const YEAR_DAYS = 365;
|
||||
|
||||
/** How many problems `recent` lists. */
|
||||
const RECENT_LIMIT = 10;
|
||||
|
||||
async function buildTopics(db: D1Database, today: string): Promise<TopicStat[]> {
|
||||
// Three shapes, three statements: batch() carries one row type, and casting
|
||||
// its results apart would cost more than the round trips it saves.
|
||||
const { results: coverage } = await db
|
||||
.prepare(
|
||||
`SELECT t.issue AS issue, t.name AS name, t.milestone AS milestone,
|
||||
COUNT(p.lc_number) AS total,
|
||||
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done,
|
||||
SUM(CASE WHEN p.stage = 'retired' THEN 1 ELSE 0 END) AS retired
|
||||
FROM topics t LEFT JOIN problems p ON p.topic_issue = t.issue
|
||||
GROUP BY t.issue
|
||||
ORDER BY t.issue`,
|
||||
)
|
||||
.all<{
|
||||
issue: number;
|
||||
name: string;
|
||||
milestone: number | null;
|
||||
total: number;
|
||||
done: number | null;
|
||||
retired: number | null;
|
||||
}>();
|
||||
|
||||
const { results: activity } = await db
|
||||
.prepare(
|
||||
`SELECT p.topic_issue AS issue, MAX(a.date) AS last
|
||||
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
|
||||
GROUP BY p.topic_issue`,
|
||||
)
|
||||
.all<{ issue: number; last: string | null }>();
|
||||
const lastByTopic = new Map<number, string>();
|
||||
for (const row of activity) {
|
||||
if (row.last) lastByTopic.set(row.issue, row.last);
|
||||
}
|
||||
|
||||
const { results: dueRows } = await db
|
||||
.prepare(
|
||||
`SELECT topic_issue AS issue, COUNT(*) AS n
|
||||
FROM problems WHERE ${DUE_WHERE} GROUP BY topic_issue`,
|
||||
)
|
||||
.bind(today)
|
||||
.all<{ issue: number; n: number }>();
|
||||
const dueByTopic = new Map<number, number>();
|
||||
for (const row of dueRows) dueByTopic.set(row.issue, row.n);
|
||||
|
||||
return coverage.map((row) => {
|
||||
const taughtOn = topicDate(row.issue);
|
||||
const last = lastByTopic.get(row.issue) ?? null;
|
||||
const age = last === null ? null : daysBetween(last, today);
|
||||
return {
|
||||
issue: row.issue,
|
||||
name: row.name,
|
||||
milestone: row.milestone,
|
||||
week: taughtOn === undefined ? null : campaignWeek(taughtOn),
|
||||
taught: taughtOn !== undefined && taughtOn <= today,
|
||||
total: row.total,
|
||||
done: row.done ?? 0,
|
||||
retired: row.retired ?? 0,
|
||||
due: dueByTopic.get(row.issue) ?? 0,
|
||||
last,
|
||||
age,
|
||||
temperature: temperatureOf(age),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildStats(db: D1Database, today: string): Promise<object> {
|
||||
const { results: phaseRows } = await db
|
||||
.prepare(
|
||||
@@ -58,40 +167,136 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
||||
const { results: ladderRows } = await db
|
||||
.prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage")
|
||||
.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;
|
||||
|
||||
const { results: gates } = await db
|
||||
// Same shape as `ladder`: every band present, zeros included, in DIFFICULTIES
|
||||
// order so a reader can index it without sorting.
|
||||
const { results: diffRows } = await db
|
||||
.prepare(
|
||||
`SELECT difficulty,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN stage != 'new' THEN 1 ELSE 0 END) AS done
|
||||
FROM problems GROUP BY difficulty`,
|
||||
)
|
||||
.all<{ difficulty: string; total: number; done: number }>();
|
||||
const difficulty = DIFFICULTIES.map((name) => {
|
||||
const row = diffRows.find((r) => r.difficulty === name);
|
||||
return { difficulty: name, total: row?.total ?? 0, done: row?.done ?? 0 };
|
||||
});
|
||||
|
||||
// gates rows are keyed by ISO week (that's the digest/gate contract), but
|
||||
// the page speaks campaign weeks. The campaign never crosses a year
|
||||
// boundary, so a plain offset converts safely.
|
||||
const isoWeekOffset = isoWeek(CAMPAIGN_START) - 1;
|
||||
const { results: gateRows } = await db
|
||||
.prepare("SELECT week, issue, pass_rate, closed_on FROM gates ORDER BY week")
|
||||
.all<{ week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }>();
|
||||
const gates = gateRows.map((g) => ({ ...g, week: g.week - isoWeekOffset }));
|
||||
|
||||
// Review-queue depth for the next 14 days: everything due by that day.
|
||||
const queue: { date: string; due: number }[] = [];
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const date = addDays(today, i);
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM problems
|
||||
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||
AND (defer_until IS NULL OR defer_until <= ?1)`,
|
||||
)
|
||||
.bind(date)
|
||||
.first<{ n: number }>();
|
||||
queue.push({ date, due: row?.n ?? 0 });
|
||||
}
|
||||
// Review-queue depth for the next 14 days: everything due by that day. One
|
||||
// statement per day, but DUE_WHERE stays the only definition of "due" — the
|
||||
// batch is a single round trip, not fourteen.
|
||||
const dates = Array.from({ length: 14 }, (_, i) => addDays(today, i));
|
||||
const queueRows = await db.batch<{ n: number }>(
|
||||
dates.map((date) =>
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM problems WHERE ${DUE_WHERE}`).bind(date),
|
||||
),
|
||||
);
|
||||
const queue = dates.map((date, i) => ({ date, due: queueRows[i]?.results[0]?.n ?? 0 }));
|
||||
|
||||
const recent: { date: string; attempts: number; passes: number }[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = addDays(today, -i);
|
||||
const row = await db
|
||||
// Attempts per campaign day, every day present. This is the README heatmap's
|
||||
// data model — the terminal dashboard lays it out by campaign week and reads
|
||||
// its tail for "last 14 days" rather than asking a second question.
|
||||
const end = addDays(CAMPAIGN_START, CAMPAIGN_DAYS - 1);
|
||||
const { results: heatRows } = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS attempts,
|
||||
`SELECT date,
|
||||
COUNT(*) AS attempts,
|
||||
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes
|
||||
FROM attempts WHERE date = ?`,
|
||||
FROM attempts WHERE date BETWEEN ?1 AND ?2 GROUP BY date`,
|
||||
)
|
||||
.bind(date)
|
||||
.first<{ attempts: number; passes: number | null }>();
|
||||
recent.push({ date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 });
|
||||
.bind(CAMPAIGN_START, end)
|
||||
.all<{ date: string; attempts: number; passes: number | null }>();
|
||||
const byDate = new Map(heatRows.map((r) => [r.date, r]));
|
||||
const heat = Array.from({ length: CAMPAIGN_DAYS }, (_, i) => {
|
||||
const date = addDays(CAMPAIGN_START, i);
|
||||
const row = byDate.get(date);
|
||||
return { date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 };
|
||||
});
|
||||
|
||||
// The ten problems most recently solved, dated by each one's FIRST passing
|
||||
// attempt — when it was completed, not when it was last re-solved (LeetCode's
|
||||
// "Recent AC"). Same-day ties fall back to log order.
|
||||
const { results: recentRows } = await db
|
||||
.prepare(
|
||||
`SELECT p.lc_number AS lc, p.title AS title, p.difficulty AS difficulty,
|
||||
MIN(a.date) AS date, MIN(a.id) AS seq
|
||||
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
|
||||
WHERE a.result = 'pass'
|
||||
GROUP BY p.lc_number
|
||||
ORDER BY date DESC, seq DESC
|
||||
LIMIT ?1`,
|
||||
)
|
||||
.bind(RECENT_LIMIT)
|
||||
.all<{ lc: number; title: string; difficulty: string; date: string; seq: number }>();
|
||||
const recent = recentRows.map(({ lc, title, difficulty, date }) => ({ lc, title, difficulty, date }));
|
||||
|
||||
// Problems still standing on a failure — the sweep day's miss log, counted.
|
||||
const missRow = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM problems p
|
||||
JOIN attempts a ON a.lc_number = p.lc_number
|
||||
WHERE ${MISS_WHERE}`,
|
||||
)
|
||||
.first<{ n: number }>();
|
||||
|
||||
// The past year, one entry per day (zeros included), split by difficulty two
|
||||
// ways: every logged attempt, and every first solve — the same first-pass
|
||||
// date `recent` uses. `heat` stays campaign-scoped (the terminal dashboard
|
||||
// lays it out by campaign week); this is the calendar-scoped view the docs
|
||||
// heatmap and submissions chart window and bucket for themselves.
|
||||
const yearStart = addDays(today, -(YEAR_DAYS - 1));
|
||||
const { results: attemptRows } = await db
|
||||
.prepare(
|
||||
`SELECT a.date AS date, p.difficulty AS difficulty,
|
||||
COUNT(*) AS n,
|
||||
SUM(CASE WHEN a.result = 'pass' THEN 1 ELSE 0 END) AS passes
|
||||
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
|
||||
WHERE a.date BETWEEN ?1 AND ?2
|
||||
GROUP BY a.date, p.difficulty`,
|
||||
)
|
||||
.bind(yearStart, today)
|
||||
.all<{ date: string; difficulty: string; n: number; passes: number | null }>();
|
||||
const { results: solvedRows } = await db
|
||||
.prepare(
|
||||
`SELECT date, difficulty, COUNT(*) AS n
|
||||
FROM (SELECT MIN(a.date) AS date, p.difficulty AS difficulty
|
||||
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
|
||||
WHERE a.result = 'pass'
|
||||
GROUP BY p.lc_number)
|
||||
WHERE date BETWEEN ?1 AND ?2
|
||||
GROUP BY date, difficulty`,
|
||||
)
|
||||
.bind(yearStart, today)
|
||||
.all<{ date: string; difficulty: string; n: number }>();
|
||||
const year = Array.from({ length: YEAR_DAYS }, (_, i) => ({
|
||||
date: addDays(yearStart, i),
|
||||
passes: 0,
|
||||
attempts: Object.fromEntries(DIFFICULTIES.map((d) => [d, 0])),
|
||||
solved: Object.fromEntries(DIFFICULTIES.map((d) => [d, 0])),
|
||||
}));
|
||||
const yearIndex = new Map(year.map((d, i) => [d.date, i]));
|
||||
for (const row of attemptRows) {
|
||||
const day = year[yearIndex.get(row.date) ?? -1];
|
||||
if (!day || !(row.difficulty in day.attempts)) continue;
|
||||
day.attempts[row.difficulty] = row.n;
|
||||
day.passes += row.passes ?? 0;
|
||||
}
|
||||
for (const row of solvedRows) {
|
||||
const day = year[yearIndex.get(row.date) ?? -1];
|
||||
if (day && row.difficulty in day.solved) day.solved[row.difficulty] = row.n;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -99,9 +304,14 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
||||
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
|
||||
phases: [...phases.values()],
|
||||
ladder,
|
||||
difficulty,
|
||||
attempting: missRow?.n ?? 0,
|
||||
topics: await buildTopics(db, today),
|
||||
gates,
|
||||
streak: await streak(db, today),
|
||||
queue,
|
||||
heat,
|
||||
recent,
|
||||
year,
|
||||
};
|
||||
}
|
||||
@@ -32,10 +32,15 @@
|
||||
"REPO": "prdlk/leetcode",
|
||||
"FROM_EMAIL": "srs@prdlk.com",
|
||||
"TO_EMAIL": "prnk28@gmail.com",
|
||||
"DOCS_URL": "https://prdlk.github.io/leetcode",
|
||||
"DOCS_ORIGIN": "https://prdlk.github.io,https://lc.prad.nu",
|
||||
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev"
|
||||
"DOCS_URL": "https://lc.prd.lk",
|
||||
"DOCS_ORIGIN": "https://lc.prd.lk,https://prdlk.github.io",
|
||||
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev",
|
||||
// The docs assistant's OpenRouter model (POST /ask). `:free` variants share
|
||||
// an upstream pool and answer 429 when it is saturated.
|
||||
"ASK_MODEL": "qwen/qwen3.8-27b:free"
|
||||
}
|
||||
|
||||
// Secrets (wrangler secret put): GH_PAT, WEBHOOK_SECRET, LINK_KEY.
|
||||
// OPENROUTER_API_KEY (the docs assistant, POST /ask) is the repo's Actions
|
||||
// secret of that name, put by deploy.yml before every deploy.
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* The SRS Worker, from the CLI side: one base URL and one read-side payload.
|
||||
*
|
||||
* `SRS_API` points a script at a `wrangler dev` instance instead of production
|
||||
* — the same switch close-solved.ts uses for its `/admin/solved` push, which is
|
||||
* why the default lives here rather than in either script.
|
||||
*
|
||||
* The payload is narrowed with a guard rather than cast: D1 is the only source
|
||||
* of SRS truth, so if the Worker answers something else the caller must say so
|
||||
* out loud, not render half a dashboard off undefined fields.
|
||||
*/
|
||||
import { STAGES, TEMPERATURES, type Stage, type Temperature } from "../api/src/srs.ts";
|
||||
|
||||
export const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
||||
|
||||
// The rungs and the bands come straight from the Worker's domain module — the
|
||||
// same import work.ts makes for WINDOWS — so a change to either vocabulary
|
||||
// reaches this dashboard without a second list to remember.
|
||||
export { STAGES, TEMPERATURES, type Stage, type Temperature };
|
||||
|
||||
export interface Phase {
|
||||
milestone: number;
|
||||
name: string;
|
||||
core_done: number;
|
||||
core_total: number;
|
||||
optional_done: number;
|
||||
optional_total: number;
|
||||
deferred_done: number;
|
||||
deferred_total: number;
|
||||
}
|
||||
|
||||
export interface Topic {
|
||||
issue: number;
|
||||
name: string;
|
||||
milestone: number | null;
|
||||
week: number | null;
|
||||
taught: boolean;
|
||||
total: number;
|
||||
done: number;
|
||||
retired: number;
|
||||
due: number;
|
||||
last: string | null;
|
||||
age: number | null;
|
||||
temperature: Temperature;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
generated: string;
|
||||
campaign: { day: number; week: number; start: string; days: number };
|
||||
phases: Phase[];
|
||||
ladder: Record<Stage, number>;
|
||||
difficulty: { difficulty: string; total: number; done: number }[];
|
||||
topics: Topic[];
|
||||
/** week is the 1-based campaign week — the Worker converts from ISO. */
|
||||
gates: { week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }[];
|
||||
streak: number;
|
||||
queue: { date: string; due: number }[];
|
||||
/** Every campaign day, zeros and future days included. */
|
||||
heat: { date: string; attempts: number; passes: number }[];
|
||||
}
|
||||
|
||||
function isStats(value: unknown): value is Stats {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof v.campaign === "object" &&
|
||||
v.campaign !== null &&
|
||||
typeof v.ladder === "object" &&
|
||||
v.ladder !== null &&
|
||||
typeof v.streak === "number" &&
|
||||
Array.isArray(v.phases) &&
|
||||
Array.isArray(v.difficulty) &&
|
||||
Array.isArray(v.topics) &&
|
||||
Array.isArray(v.gates) &&
|
||||
Array.isArray(v.queue) &&
|
||||
Array.isArray(v.heat)
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/stats. Throws with the reason; there is no local substitute. */
|
||||
export async function fetchStats(signal?: AbortSignal): Promise<Stats> {
|
||||
const res = await fetch(`${SRS_API}/api/stats`, { signal });
|
||||
if (!res.ok) throw new Error(`GET /api/stats -> ${res.status} ${await res.text()}`);
|
||||
const body: unknown = await res.json();
|
||||
if (!isStats(body)) throw new Error("GET /api/stats -> unrecognised payload shape");
|
||||
return body;
|
||||
}
|
||||
+29
-74
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* `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
|
||||
* isImplemented().
|
||||
* isImplemented() in source.ts.
|
||||
*
|
||||
* bun apps/cli/close-solved.ts # close matches, log them in D1
|
||||
* 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
|
||||
* 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 { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..", "..");
|
||||
const WORK = join(ROOT, "work");
|
||||
import { isImplemented } from "./source.ts";
|
||||
import { WORK, parseWorkPath, type Bucket } from "./work.ts";
|
||||
|
||||
const DRY =
|
||||
process.argv.includes("--dry-run") ||
|
||||
process.env.DRY_RUN === "1" ||
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
const gh = await github();
|
||||
@@ -89,27 +40,27 @@ const gh = await github();
|
||||
|
||||
interface WorkEntry {
|
||||
files: string[];
|
||||
/** Any bucket implemented — this is what closes the problem issue. */
|
||||
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>();
|
||||
for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
||||
const m = basename(rel).match(/^(\d+)\.(.+)\.(?:js|py)$/);
|
||||
if (!m) {
|
||||
console.warn(`skip (unrecognized name): work/${rel}`);
|
||||
const file = parseWorkPath(rel);
|
||||
if (!file) {
|
||||
console.warn(`skip (off-layout): work/${rel}`);
|
||||
continue;
|
||||
}
|
||||
const num = Number(m[1]);
|
||||
const src = await Bun.file(join(WORK, rel)).text();
|
||||
const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js");
|
||||
const entry = work.get(num);
|
||||
if (entry) {
|
||||
const entry = work.get(file.lc) ?? { files: [], implemented: false, solved: new Set<Bucket>() };
|
||||
entry.files.push(`work/${rel}`);
|
||||
entry.implemented ||= implemented;
|
||||
} else {
|
||||
work.set(num, { files: [`work/${rel}`], implemented });
|
||||
}
|
||||
if (implemented) entry.solved.add(file.bucket);
|
||||
work.set(file.lc, entry);
|
||||
}
|
||||
|
||||
// ── 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
|
||||
// pools all keep treating it as untouched.
|
||||
//
|
||||
// The whole implemented set goes over, not just this run's closes: the
|
||||
// Worker drops anything already on the ladder, so the call is a total
|
||||
// recompute and backfills whatever earlier runs missed.
|
||||
const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
||||
// Each entry carries the BUCKET the file sits in, which is the rung it
|
||||
// settles: work/1 is the first solve, work/3 the 3-day review, work/7 the
|
||||
// 7-day one. That is what makes a pushed re-solve advance the ladder instead
|
||||
// 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()]
|
||||
.filter(([, entry]) => entry.implemented)
|
||||
.map(([lc]) => lc)
|
||||
.sort((a, b) => a - b);
|
||||
.flatMap(([lc, entry]) => [...entry.solved].sort().map((bucket) => ({ lc, bucket })))
|
||||
.sort((a, b) => a.lc - b.lc || a.bucket - b.bucket);
|
||||
|
||||
let syncNote: string;
|
||||
let syncFailed = false;
|
||||
@@ -228,7 +183,7 @@ if (!process.env.SRS_ADMIN_KEY) {
|
||||
authorization: `Bearer ${process.env.SRS_ADMIN_KEY}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ lc: solved }),
|
||||
body: JSON.stringify({ solved }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
||||
// 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");
|
||||
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 · ` +
|
||||
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}` +
|
||||
`\nd1: ${syncNote}`,
|
||||
@@ -265,7 +220,7 @@ console.log(
|
||||
|
||||
await writeStepSummary(
|
||||
`### 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
|
||||
? `${markdownTable([rows[0]!, ...actionable])}\n`
|
||||
|
||||
+60
-47
@@ -1,24 +1,34 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Close the GitHub issue for every topic whose required (core) problems are done.
|
||||
* Close the GitHub issue for every topic whose required (core) problems are
|
||||
* done, and for every `Review <N> — <Phase>` issue whose problems all are.
|
||||
*
|
||||
* Each `topic`-labelled issue owns its problems as GitHub sub-issues, and each
|
||||
* problem carries exactly one `set:` label — `set:core` is required, while
|
||||
* `set:optional` and `set:deferred` are extra credit. A topic is finished when
|
||||
* every one of its core sub-issues is closed; optional/deferred state is
|
||||
* ignored, matching the day rule in each topic body ("Core problems first").
|
||||
* Problems re-parented into Review issues still count for their topic. The
|
||||
* rules live in topics.ts, shared with release-topics.ts.
|
||||
*
|
||||
* A Review issue is a fixed day's assignment, so it closes when every one of
|
||||
* its sub-issues is closed, whatever their set. Closing it fires the Worker's
|
||||
* `review`-label webhook, which scores only issues with a gate record — these
|
||||
* have none, so that is a no-op.
|
||||
*
|
||||
* Like close-solved.ts this reconciles state instead of reacting to an event
|
||||
* payload: re-runs are no-ops, backfilling needs no special casing, and only
|
||||
* open topics are touched, so reopening a problem never reopens its topic.
|
||||
* open topics/reviews are touched, so reopening a problem never reopens either.
|
||||
*
|
||||
* bun apps/cli/close-topics.ts # close finished topics
|
||||
* bun apps/cli/close-topics.ts # close finished topics and reviews
|
||||
* bun apps/cli/close-topics.ts --dry-run # report only, touch nothing
|
||||
*
|
||||
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
||||
*/
|
||||
import { movedFromTopics, readReviews } from "../api/src/reviews.ts";
|
||||
import { github } from "./github.ts";
|
||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
import { completion, coreSet, readProblems } from "./topics.ts";
|
||||
|
||||
const DRY =
|
||||
process.argv.includes("--dry-run") ||
|
||||
@@ -53,44 +63,8 @@ for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=ope
|
||||
}
|
||||
topics.sort((a, b) => a.number - b.number);
|
||||
|
||||
// ── core sub-issue state per topic ───────────────────────────────
|
||||
|
||||
interface Core {
|
||||
/** Core sub-issues, closed and open alike, lowest number first. */
|
||||
all: number[];
|
||||
/** The core sub-issues still open — non-empty means the topic stays open. */
|
||||
pending: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a topic's core sub-issue state. The sub_issues payload carries the full
|
||||
* issue objects (state + labels), so no per-problem follow-up request is needed.
|
||||
*/
|
||||
async function readCore(topic: number): Promise<Core> {
|
||||
const all: number[] = [];
|
||||
const pending: number[] = [];
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||
if (!("state" in raw) || typeof raw.state !== "string") continue;
|
||||
if (!("labels" in raw) || !Array.isArray(raw.labels)) continue;
|
||||
|
||||
const core = raw.labels.some(
|
||||
(label) =>
|
||||
label &&
|
||||
typeof label === "object" &&
|
||||
"name" in label &&
|
||||
label.name === "set:core",
|
||||
);
|
||||
if (!core) continue;
|
||||
|
||||
all.push(raw.number);
|
||||
if (raw.state === "open") pending.push(raw.number);
|
||||
}
|
||||
all.sort((a, b) => a - b);
|
||||
pending.sort((a, b) => a - b);
|
||||
return { all, pending };
|
||||
}
|
||||
const reviews = await readReviews(gh);
|
||||
const moved = movedFromTopics(reviews);
|
||||
|
||||
// ── reconcile ────────────────────────────────────────────────────
|
||||
|
||||
@@ -98,7 +72,7 @@ const report: string[][] = [];
|
||||
let closed = 0;
|
||||
|
||||
for (const topic of topics) {
|
||||
const core = await readCore(topic.number);
|
||||
const core = coreSet(await readProblems(gh, topic.number, moved));
|
||||
const progress = `${core.all.length - core.pending.length}/${core.all.length}`;
|
||||
|
||||
// A topic with no core sub-issues has nothing to complete: never close it,
|
||||
@@ -132,19 +106,58 @@ for (const topic of topics) {
|
||||
closed++;
|
||||
}
|
||||
|
||||
// ── reviews ──────────────────────────────────────────────────────
|
||||
|
||||
const reviewReport: string[][] = [];
|
||||
let reviewsClosed = 0;
|
||||
|
||||
for (const review of reviews.filter((r) => r.open)) {
|
||||
const set = completion(review.problems);
|
||||
const progress = `${set.all.length - set.pending.length}/${set.all.length}`;
|
||||
// An empty review would read as done; leave it for a human, like a topic
|
||||
// with no core set.
|
||||
if (set.all.length === 0) {
|
||||
reviewReport.push([`#${review.number}`, review.title, progress, "no sub-issues"]);
|
||||
continue;
|
||||
}
|
||||
if (set.pending.length > 0) {
|
||||
reviewReport.push([`#${review.number}`, review.title, progress, `open: ${set.pending.map((n) => `#${n}`).join(" ")}`]);
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
reviewReport.push([`#${review.number}`, review.title, progress, "would close"]);
|
||||
continue;
|
||||
}
|
||||
await gh.closeIssue(
|
||||
review.number,
|
||||
`Review complete — all ${set.all.length} problems closed ` +
|
||||
`(${set.all.map((n) => `#${n}`).join(", ")}).\n\n` +
|
||||
"Closed automatically by `close-topics`.",
|
||||
);
|
||||
reviewReport.push([`#${review.number}`, review.title, progress, "closed"]);
|
||||
reviewsClosed++;
|
||||
}
|
||||
|
||||
// ── report ───────────────────────────────────────────────────────
|
||||
|
||||
const rows = [["topic", "title", "core", "status"], ...report];
|
||||
printTable(rows);
|
||||
const reviewRows = [["review", "title", "closed", "status"], ...reviewReport];
|
||||
console.log();
|
||||
printTable(reviewRows);
|
||||
|
||||
const finished = report.filter((r) => r[3] === "closed" || r[3] === "would close");
|
||||
const finished = new Set(["closed", "would close"]);
|
||||
const topicsDone = report.filter((r) => finished.has(r[3]!)).length;
|
||||
const reviewsDone = reviewReport.filter((r) => finished.has(r[3]!)).length;
|
||||
console.log(
|
||||
`\n${topics.length} open topics · ` +
|
||||
`${DRY ? `would close ${finished.length}` : `closed ${closed}`}`,
|
||||
`\n${topics.length} open topics · ${DRY ? `would close ${topicsDone}` : `closed ${closed}`}` +
|
||||
`\n${reviewReport.length} open reviews · ${DRY ? `would close ${reviewsDone}` : `closed ${reviewsClosed}`}`,
|
||||
);
|
||||
|
||||
await writeStepSummary(
|
||||
`### close-topics${DRY ? " (dry run)" : ""}\n\n` +
|
||||
`${topics.length} open topic issues, ${finished.length} with a complete core set.\n\n` +
|
||||
(report.length ? `${markdownTable(rows)}\n` : "No open topics.\n"),
|
||||
`${topics.length} open topic issues, ${topicsDone} with a complete core set.\n\n` +
|
||||
(report.length ? `${markdownTable(rows)}\n` : "No open topics.\n") +
|
||||
`\n${reviewReport.length} open review issues, ${reviewsDone} with every problem closed.\n\n` +
|
||||
(reviewReport.length ? `${markdownTable(reviewRows)}\n` : "No open reviews.\n"),
|
||||
);
|
||||
@@ -10,6 +10,12 @@
|
||||
* are ever fetched, and paid-only problems are skipped because GraphQL
|
||||
* returns null content for them.
|
||||
*
|
||||
* A `solutions` table tracks the lifecycle of each work/ file by LC number:
|
||||
* `created_at` (backfilled from the file's birthtime on every picker run),
|
||||
* `last_tested` and `last_submitted` (stamped on exit 0 of leetcode-cli).
|
||||
* The test picker sorts by last_tested, the submit picker by created_at, and
|
||||
* submit hides anything with a last_submitted.
|
||||
*
|
||||
* Library: side-effect-free on import — nothing is opened or fetched until
|
||||
* problemDb() is called. Index upserts never touch the description column, so
|
||||
* a daily refresh cannot evict cached statements.
|
||||
@@ -27,6 +33,13 @@ export interface Problem {
|
||||
paidOnly: boolean;
|
||||
}
|
||||
|
||||
/** Lifecycle of one work/ solution, keyed by LC number. ISO timestamps. */
|
||||
export interface SolutionActivity {
|
||||
createdAt: string;
|
||||
lastTested: string | null;
|
||||
lastSubmitted: string | null;
|
||||
}
|
||||
|
||||
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
|
||||
const INDEX_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -109,6 +122,13 @@ export function problemDb(path = DB_PATH) {
|
||||
paid_only INTEGER NOT NULL,
|
||||
description TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS solutions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
last_tested TEXT,
|
||||
last_submitted TEXT
|
||||
);
|
||||
DROP TABLE IF EXISTS submissions;
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
@@ -177,6 +197,37 @@ export function problemDb(path = DB_PATH) {
|
||||
db.query("UPDATE problems SET description = ?2 WHERE slug = ?1").run(slug, html);
|
||||
},
|
||||
|
||||
/** Per-solution lifecycle rows keyed by LC number. */
|
||||
solutionActivity(): Map<number, SolutionActivity> {
|
||||
const rows = db
|
||||
.query<{ id: number; created_at: string; last_tested: string | null; last_submitted: string | null }, []>(
|
||||
"SELECT id, created_at, last_tested, last_submitted FROM solutions",
|
||||
)
|
||||
.all();
|
||||
return new Map(
|
||||
rows.map((r) => [r.id, { createdAt: r.created_at, lastTested: r.last_tested, lastSubmitted: r.last_submitted }]),
|
||||
);
|
||||
},
|
||||
|
||||
/** Register a solution's creation time; existing rows are left alone. */
|
||||
ensureSolution(id: number, createdAt: string): void {
|
||||
db.query("INSERT INTO solutions (id, created_at) VALUES (?1, ?2) ON CONFLICT(id) DO NOTHING").run(id, createdAt);
|
||||
},
|
||||
|
||||
markTested(id: number): void {
|
||||
db.query(
|
||||
`INSERT INTO solutions (id, created_at, last_tested) VALUES (?1, ?2, ?2)
|
||||
ON CONFLICT(id) DO UPDATE SET last_tested = excluded.last_tested`,
|
||||
).run(id, new Date().toISOString());
|
||||
},
|
||||
|
||||
markSubmitted(id: number): void {
|
||||
db.query(
|
||||
`INSERT INTO solutions (id, created_at, last_submitted) VALUES (?1, ?2, ?2)
|
||||
ON CONFLICT(id) DO UPDATE SET last_submitted = excluded.last_submitted`,
|
||||
).run(id, new Date().toISOString());
|
||||
},
|
||||
|
||||
close(): void {
|
||||
db.close();
|
||||
},
|
||||
|
||||
+23
-3
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* GitHub REST plumbing shared by the issue reconcilers (close-solved,
|
||||
* close-topics).
|
||||
* GitHub REST + GraphQL plumbing shared by the issue reconcilers (close-solved,
|
||||
* close-topics) and the Project field reconciler (sync-project-fields).
|
||||
*
|
||||
* Importing this module is side-effect free — nothing resolves credentials or
|
||||
* touches the network until github() is awaited — so a script can import it
|
||||
@@ -22,6 +22,8 @@ export interface GitHub {
|
||||
repo: string;
|
||||
/** One authenticated request against api.github.com; throws on non-2xx. */
|
||||
api(path: string, init?: RequestInit): Promise<unknown>;
|
||||
/** One GraphQL call (Projects v2 lives here); throws on transport or query errors. */
|
||||
graphql(query: string, variables?: Record<string, unknown>): Promise<unknown>;
|
||||
/** Every page of a list endpoint, flattened into one stream of elements. */
|
||||
list(path: string): AsyncGenerator<unknown, void, void>;
|
||||
/** Comment on an issue, then close it as completed. */
|
||||
@@ -74,6 +76,24 @@ export async function github(): Promise<GitHub> {
|
||||
}
|
||||
}
|
||||
|
||||
async function graphql(
|
||||
query: string,
|
||||
variables: Record<string, unknown> = {},
|
||||
): Promise<unknown> {
|
||||
const res = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
|
||||
const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
|
||||
if (payload.errors?.length) throw new Error(payload.errors.map((e) => e.message).join("; "));
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment before closing: if the PATCH fails, the issue still carries a
|
||||
* visible note of what the automation decided, instead of failing silently.
|
||||
@@ -89,5 +109,5 @@ export async function github(): Promise<GitHub> {
|
||||
});
|
||||
}
|
||||
|
||||
return { repo, api, list, closeIssue };
|
||||
return { repo, api, graphql, list, closeIssue };
|
||||
}
|
||||
@@ -4,15 +4,18 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"pick": "bun ./pick.ts",
|
||||
"stats": "bun ./stats.ts",
|
||||
"test": "bun ./test.ts",
|
||||
"submit": "bun ./submit.ts",
|
||||
"sync": "bun ./sync.ts",
|
||||
"videos": "bun ./videos.ts",
|
||||
"close-solved": "bun ./close-solved.ts",
|
||||
"close-topics": "bun ./close-topics.ts"
|
||||
"close-topics": "bun ./close-topics.ts",
|
||||
"release-topics": "bun ./release-topics.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^1.7.0",
|
||||
"@night-slayer18/leetcode-cli": "^3.5.0",
|
||||
"@night-slayer18/leetcode-cli": "^3.5.2",
|
||||
"@types/bun": "^1.3.14"
|
||||
}
|
||||
}
|
||||
+181
-25
@@ -1,19 +1,24 @@
|
||||
#!/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
|
||||
* most once per 24h; campaign problem descriptions from README.md are
|
||||
* backfilled into the same db on the way. Problems that already have a
|
||||
* numbered file under work/ are excluded from the picker — re-picking them
|
||||
* would only clobber the existing scaffold.
|
||||
* backfilled into the same db on the way. The new-problem section hides
|
||||
* anything that already has a numbered file under work/1 — re-picking it would
|
||||
* 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 { basename, join } from "node:path";
|
||||
import { fetchDescription, problemDb, readmeSlugs, type Problem } from "./db";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..", "..");
|
||||
const WORK_DIR = join(ROOT, "work");
|
||||
import { autocomplete, cancel, isCancel, log, spinner } from "@clack/prompts";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { styleText } from "node:util";
|
||||
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();
|
||||
|
||||
@@ -57,26 +62,110 @@ if (missing.length > 0) {
|
||||
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>();
|
||||
for (const rel of new Bun.Glob("**/*.*").scanSync({ cwd: WORK_DIR })) {
|
||||
const m = basename(rel).match(/^(\d+)\./);
|
||||
if (m) done.add(Number(m[1]));
|
||||
/** What Enter scaffolds: this problem, into this bucket. */
|
||||
interface Choice {
|
||||
bucket: Bucket;
|
||||
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();
|
||||
|
||||
const picked = await autocomplete<Problem>({
|
||||
message: "Pick a problem",
|
||||
placeholder: "Type to search by number or title...",
|
||||
maxItems: 12,
|
||||
options: problems.map((p) => ({
|
||||
value: p,
|
||||
const queues = reviewQueues(etDate(new Date()));
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
label: "new",
|
||||
options: problems
|
||||
.filter((p) => !first.has(p.id))
|
||||
.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) => {
|
||||
const haystack = option.label!.toLowerCase();
|
||||
return search
|
||||
@@ -90,10 +179,77 @@ if (isCancel(picked)) {
|
||||
cancel("Nothing picked.");
|
||||
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 ──────────────────────────────
|
||||
|
||||
// The active workspace names the config file; everything else about it belongs
|
||||
// to leetcode-cli.
|
||||
const registry = join(homedir(), ".leetcode", "workspaces.json");
|
||||
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"] },
|
||||
);
|
||||
process.exit(await proc.exited);
|
||||
);
|
||||
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
+377
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Publish one GitHub release per finished topic and per finished
|
||||
* `Review <N> — <Phase>` issue, versioned by timever
|
||||
* (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — https://gist.github.com/twolfson/de1b004dd22536b8e668).
|
||||
*
|
||||
* "Finished" is topics.ts's rule — a topic when every core problem is closed,
|
||||
* a review when every one of its sub-issues is — the same one close-topics.ts
|
||||
* closes the issue on, and the version is the instant the LAST required
|
||||
* problem closed. Keying off the sub-issues rather than the topic/review issue
|
||||
* lets this run as the tail of Close Solved: the push that closes the final
|
||||
* required problem releases it in the same run, without waiting for
|
||||
* close-topics to chain after it. The issue itself closes seconds later, on
|
||||
* the same day.
|
||||
*
|
||||
* Each release is a changelog snapshot at that instant: the issue's problems
|
||||
* that had closed by then (a topic's sub-issues plus those since re-parented
|
||||
* into Review issues, or a review's sub-issues in solve order — see
|
||||
* topics.ts), grouped as closed on the finish day (ET) and closed earlier,
|
||||
* each with its set, difficulty, and the solution files as they stood in the
|
||||
* tagged commit — the last main commit before the finish. Problems still open
|
||||
* at the finish are left out: the release records what finished, and an open
|
||||
* optional problem is tracked by its issue (or its Review day), not here.
|
||||
* apps/docs/lib/releases.ts reads these `### Closed …` tables back for the
|
||||
* docs `/changelog` timeline, so their row shape is its contract.
|
||||
*
|
||||
* The tag is annotated and backdated (tagger date = finish), so
|
||||
* `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 (apps/docs/lib/releases.ts)
|
||||
* — the tag format is that contract.
|
||||
*
|
||||
* Reconciles instead of reacting: every finished topic or review without a
|
||||
* release gets one, so a first run backfills the whole campaign; an existing
|
||||
* release keeps its tag and commit and only has its body rewritten when
|
||||
* render() would say something else. Re-runs with nothing new are no-ops.
|
||||
* A release is found by the marker comment in its body, never by tag name, so
|
||||
* reopening and re-closing a core problem can never mint a second release.
|
||||
* A tag that exists without its release (a run that died between the two) is
|
||||
* reused, not re-created.
|
||||
*
|
||||
* bun apps/cli/release-topics.ts # tag, push, release, fix bodies
|
||||
* bun apps/cli/release-topics.ts --dry-run # print the would-be releases
|
||||
*
|
||||
* Auth: GH_TOKEN / GITHUB_TOKEN, else `gh auth token` (needs contents: write).
|
||||
* Git: a clone with full history, an `origin` it may push tags to, and a
|
||||
* committer identity (the tagger).
|
||||
*/
|
||||
import { etDate } from "../api/src/srs.ts";
|
||||
import { movedFromTopics, readReviews, type Problem } from "../api/src/reviews.ts";
|
||||
import { github } from "./github.ts";
|
||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
import { completion, coreSet, labelValue, readProblems } from "./topics.ts";
|
||||
import { ROOT, parseWorkPath } from "./work.ts";
|
||||
|
||||
const DRY =
|
||||
process.argv.includes("--dry-run") ||
|
||||
process.env.DRY_RUN === "1" ||
|
||||
process.env.DRY_RUN === "true";
|
||||
|
||||
/** The docs site (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();
|
||||
}
|
||||
|
||||
// ── finished topics and reviews ──────────────────────────────────
|
||||
|
||||
/** A topic or Review issue, as far as its release reads it. */
|
||||
interface Issue {
|
||||
number: number;
|
||||
title: string;
|
||||
milestone: string | null;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Narrow one /issues element; the `topic` label filter cannot exclude PRs. */
|
||||
function readTopic(value: unknown): Issue | undefined {
|
||||
if (!value || typeof value !== "object") return;
|
||||
if ("pull_request" in value) return;
|
||||
if (!("number" in value) || typeof value.number !== "number") return;
|
||||
if (!("title" in value) || typeof value.title !== "string") return;
|
||||
const milestone =
|
||||
"milestone" in value &&
|
||||
value.milestone &&
|
||||
typeof value.milestone === "object" &&
|
||||
"title" in value.milestone &&
|
||||
typeof value.milestone.title === "string"
|
||||
? value.milestone.title
|
||||
: null;
|
||||
const body = "body" in value && typeof value.body === "string" ? value.body : "";
|
||||
return { number: value.number, title: value.title, milestone, body };
|
||||
}
|
||||
|
||||
interface Finished {
|
||||
kind: "topic" | "review";
|
||||
issue: Issue;
|
||||
/** Topic: every problem it owns, by number. Review: its sub-issues, in solve order. */
|
||||
problems: Problem[];
|
||||
/** The set whose last close finished it: a topic's core, a review's every problem. */
|
||||
required: number[];
|
||||
finishedAt: string;
|
||||
}
|
||||
|
||||
// Closed and open issues alike: the rules read the sub-issues, so a topic or
|
||||
// review finished moments ago (close-topics not yet run) releases now.
|
||||
const finished: Finished[] = [];
|
||||
const reviews = await readReviews(gh);
|
||||
const moved = movedFromTopics(reviews);
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
||||
const issue = readTopic(raw);
|
||||
if (!issue) continue;
|
||||
const problems = await readProblems(gh, issue.number, moved);
|
||||
const core = coreSet(problems);
|
||||
if (core.finishedAt) finished.push({ kind: "topic", issue, problems, required: core.all, finishedAt: core.finishedAt });
|
||||
}
|
||||
for (const review of reviews) {
|
||||
const set = completion(review.problems);
|
||||
if (!set.finishedAt) continue;
|
||||
finished.push({
|
||||
kind: "review",
|
||||
// The review's phase is its body's `**Phase:**` line; its milestone is the
|
||||
// shared "Review — Transfer & Mocks" and says nothing per issue.
|
||||
issue: { number: review.number, title: review.title, milestone: null, body: review.body },
|
||||
problems: review.problems,
|
||||
required: set.all,
|
||||
finishedAt: set.finishedAt,
|
||||
});
|
||||
}
|
||||
// Oldest first, so a backfill creates releases in campaign order.
|
||||
finished.sort((a, b) => a.finishedAt.localeCompare(b.finishedAt) || a.issue.number - b.issue.number);
|
||||
|
||||
// ── existing releases ────────────────────────────────────────────
|
||||
|
||||
/** Topic or review issue -> its release. Name and body are kept in sync with the render on every run. */
|
||||
const released = new Map<number, { id: number; tag: string; name: string; body: string }>();
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/releases`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if (!("id" in raw) || typeof raw.id !== "number") continue;
|
||||
if (!("tag_name" in raw) || typeof raw.tag_name !== "string") continue;
|
||||
if (!("body" in raw) || typeof raw.body !== "string") continue;
|
||||
const name = "name" in raw && typeof raw.name === "string" ? raw.name : "";
|
||||
const m = raw.body.match(/<!-- release-topics: #(\d+) -->/);
|
||||
if (m) released.set(Number(m[1]), { id: raw.id, tag: raw.tag_name, name, body: raw.body });
|
||||
}
|
||||
|
||||
// ── timever ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Timever of an instant. The API reports whole seconds, so SUBTIME is the
|
||||
* tiebreak: two topics or reviews finishing in the same second (one push
|
||||
* closing both last problems) get consecutive nanoseconds, in (instant,
|
||||
* issue) order.
|
||||
*/
|
||||
const tags = new Map<number, string>(); // issue -> tag
|
||||
{
|
||||
let prev = "";
|
||||
let ns = 0;
|
||||
for (const f of finished) {
|
||||
const iso = new Date(f.finishedAt).toISOString(); // 2026-09-24T18:36:38.000Z
|
||||
const base = `${iso.slice(0, 10).replaceAll("-", "")}.${iso.slice(11, 19).replaceAll(":", "")}`;
|
||||
ns = base === prev ? ns + 1 : Number(iso.slice(20, 23)) * 1_000_000;
|
||||
prev = base;
|
||||
tags.set(f.issue.number, `${base}.${String(ns).padStart(9, "0")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── release body ─────────────────────────────────────────────────
|
||||
|
||||
const ET_WHEN = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "America/New_York",
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const ET_DAY = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "America/New_York",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
/** Topic body sections worth carrying into the changelog (skipped while still `TODO`). */
|
||||
const NOTES = ["Trigger", "Invariant", "Trap"] as const;
|
||||
|
||||
function bodyField(body: string, field: string): string | undefined {
|
||||
return body.match(new RegExp(`^\\*\\*${field}:\\*\\*\\s*(.+)$`, "m"))?.[1]?.trim();
|
||||
}
|
||||
|
||||
function bodySection(body: string, heading: string): string | undefined {
|
||||
const m = body.match(new RegExp(`^### ${heading}\\n([\\s\\S]*?)(?=^### |(?![\\s\\S]))`, "m"));
|
||||
const text = m?.[1]?.trim();
|
||||
return text && text !== "TODO" ? text : undefined;
|
||||
}
|
||||
|
||||
function render(f: Finished, tag: string, sha: string, files: Map<number, string[]>): string {
|
||||
const { issue, problems } = f;
|
||||
const finishDay = etDate(new Date(f.finishedAt));
|
||||
// Snapshot at the finish instant: a problem closed later was open then, and
|
||||
// open problems are not part of the release.
|
||||
const closedBy = (p: Problem) => p.closedAt !== null && p.closedAt <= f.finishedAt;
|
||||
const onDay = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) === finishDay);
|
||||
const earlier = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) !== finishDay);
|
||||
|
||||
const row = (p: Problem): string[] => {
|
||||
const lc = Number(p.title.match(/^LC (\d+)/)?.[1]);
|
||||
const code = closedBy(p)
|
||||
? (files.get(lc) ?? []).map(
|
||||
(path) => `[${path.slice(path.lastIndexOf(".") + 1)}](https://github.com/${gh.repo}/blob/${tag}/${encodeURI(path)})`,
|
||||
)
|
||||
: [];
|
||||
return [
|
||||
`#${p.number}`,
|
||||
p.title,
|
||||
labelValue(p, "set:") ?? "—",
|
||||
labelValue(p, "diff:") ?? "—",
|
||||
closedBy(p) ? ET_DAY.format(new Date(p.closedAt!)) : "—",
|
||||
code.length ? [...code, `[docs](${DOCS_SITE}/${lc})`].join(" · ") : "—",
|
||||
];
|
||||
};
|
||||
const table = (list: Problem[]) =>
|
||||
markdownTable([["Issue", "Problem", "Set", "Difficulty", "Closed (ET)", "Solution"], ...list.map(row)]);
|
||||
|
||||
// Blume's changelog derives each page's meta description from the opening
|
||||
// prose, stripping markdown and line breaks without adding a space — so lead
|
||||
// with one self-contained sentence and keep the fields on one `·` line.
|
||||
const when = ET_WHEN.format(new Date(f.finishedAt));
|
||||
const summary =
|
||||
`Finished ${when} ET: all ${f.required.length} ${f.kind === "topic" ? "core " : ""}problems closed, ` +
|
||||
`${onDay.length} ${onDay.length === 1 ? "problem" : "problems"} closed that day.`;
|
||||
const fields: string[] = [];
|
||||
const scheduled = bodyField(issue.body, "Scheduled");
|
||||
if (scheduled) fields.push(`**Scheduled:** ${scheduled}`);
|
||||
const phase = f.kind === "topic" ? issue.milestone : bodyField(issue.body, "Phase");
|
||||
if (phase) fields.push(`**Phase:** ${phase}`);
|
||||
fields.push(`**${f.kind === "topic" ? "Topic" : "Review"}:** #${issue.number}`);
|
||||
const buildsOn = bodyField(issue.body, "Builds on");
|
||||
if (buildsOn) fields.push(`**Builds on:** ${buildsOn}`);
|
||||
const load = bodyField(issue.body, "Load");
|
||||
if (load) fields.push(`**Load:** ${load}`);
|
||||
// The marker is how a later run finds this release; see the file header.
|
||||
// One marker for both kinds: issue numbers never collide.
|
||||
const sections = [`<!-- release-topics: #${issue.number} -->\n${summary}`, fields.join(" · ")];
|
||||
sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the ${f.kind} finished\n\n${table(onDay)}`);
|
||||
if (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`);
|
||||
const notes = NOTES.flatMap((h) => {
|
||||
const text = bodySection(issue.body, h);
|
||||
return text ? [`**${h}.** ${text}`] : [];
|
||||
});
|
||||
if (notes.length) sections.push(`### Notes\n\n${notes.join("\n\n")}`);
|
||||
sections.push(
|
||||
`---\n\n\`${tag}\` is [timever](${TIMEVER}) for the UTC instant the ` +
|
||||
`${f.kind === "topic" ? "core set completed" : "last problem closed"}; ` +
|
||||
`it tags ${sha.slice(0, 7)}, the last main commit before it.`,
|
||||
);
|
||||
return `${sections.join("\n\n")}\n`;
|
||||
}
|
||||
|
||||
// ── reconcile ────────────────────────────────────────────────────
|
||||
|
||||
const latest = finished.at(-1)?.issue.number;
|
||||
const report: string[][] = [];
|
||||
const previews: string[] = [];
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const f of finished) {
|
||||
const { issue } = f;
|
||||
const finishedEt = etDate(new Date(f.finishedAt));
|
||||
const existing = released.get(issue.number);
|
||||
// An existing release keeps its tag and commit; only its name and body are
|
||||
// reconciled. Its tag may postdate this clone (released by another run), so
|
||||
// fetch it when missing — read-only, so a dry run does it too.
|
||||
const tag = existing?.tag ?? tags.get(issue.number)!;
|
||||
if (existing && Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT }).exitCode !== 0) {
|
||||
git("fetch", "-q", "origin", `refs/tags/${tag}:refs/tags/${tag}`);
|
||||
}
|
||||
const sha = existing
|
||||
? git("rev-parse", `${tag}^{commit}`)
|
||||
: git("rev-list", "-1", "--first-parent", `--before=${f.finishedAt}`, "HEAD");
|
||||
if (!sha) {
|
||||
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "ERROR: no commit before finish"]);
|
||||
continue;
|
||||
}
|
||||
// LC number -> its first-solve files in the tagged tree. Keyed by file name,
|
||||
// like firstSolved(): tags before the bucket split see `work/<Difficulty>/…`,
|
||||
// and the work/3 + work/7 re-solves are not the solve that closed the issue.
|
||||
const files = new Map<number, string[]>();
|
||||
for (const path of git("ls-tree", "-r", "--name-only", sha, "--", "work").split("\n")) {
|
||||
const bucket = parseWorkPath(path.slice("work/".length))?.bucket;
|
||||
const lc = path.match(/\/(\d+)\.[^/]+\.(?:js|py)$/)?.[1];
|
||||
if (!lc || (bucket && bucket !== 1)) continue;
|
||||
files.set(Number(lc), [...(files.get(Number(lc)) ?? []), path]);
|
||||
}
|
||||
const body = render(f, tag, sha, files);
|
||||
const name = issue.title;
|
||||
|
||||
if (existing) {
|
||||
if (existing.body === body && existing.name === name) {
|
||||
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "released"]);
|
||||
} else if (DRY) {
|
||||
previews.push(`## ${name} (${tag})\n\n${body}`);
|
||||
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "would update"]);
|
||||
} else {
|
||||
await gh.api(`/repos/${gh.repo}/releases/${existing.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name, body }),
|
||||
});
|
||||
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "updated"]);
|
||||
updated++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DRY) {
|
||||
previews.push(`## ${name} (${tag})\n\n${body}`);
|
||||
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "would release"]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const remote = git("ls-remote", "--tags", "origin", `refs/tags/${tag}`);
|
||||
if (!remote) {
|
||||
const local = Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT });
|
||||
if (local.exitCode !== 0) {
|
||||
const tagged = Bun.spawnSync(
|
||||
[
|
||||
"git", "tag", "-a", tag, sha, "-m",
|
||||
`${issue.title} (#${issue.number})\n\n` +
|
||||
`${f.kind === "topic" ? "Core set complete" : "All problems closed"}: ${f.required.map((n) => `#${n}`).join(", ")}`,
|
||||
],
|
||||
{ cwd: ROOT, env: { ...process.env, GIT_COMMITTER_DATE: f.finishedAt } },
|
||||
);
|
||||
if (tagged.exitCode !== 0) throw new Error(`git tag ${tag}: ${tagged.stderr.toString().trim()}`);
|
||||
}
|
||||
git("push", "origin", `refs/tags/${tag}`);
|
||||
}
|
||||
await gh.api(`/repos/${gh.repo}/releases`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
tag_name: tag,
|
||||
name,
|
||||
body,
|
||||
make_latest: issue.number === latest ? "true" : "false",
|
||||
}),
|
||||
});
|
||||
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "released now"]);
|
||||
created++;
|
||||
}
|
||||
|
||||
// ── report ───────────────────────────────────────────────────────
|
||||
|
||||
if (previews.length) console.log(`${previews.join("\n")}\n`);
|
||||
const rows = [["issue", "title", "finished (ET)", "tag", "status"], ...report];
|
||||
printTable(rows);
|
||||
const would = (status: string) => report.filter((r) => r[4] === status).length;
|
||||
console.log(
|
||||
`\n${finished.length} finished topics and reviews · ` +
|
||||
(DRY
|
||||
? `would release ${would("would release")}, update ${would("would update")}`
|
||||
: `released ${created}, updated ${updated}`),
|
||||
);
|
||||
|
||||
await writeStepSummary(
|
||||
`### release-topics${DRY ? " (dry run)" : ""}\n\n` +
|
||||
`${finished.length} finished topics and reviews, ${released.size} already released.\n\n` +
|
||||
(report.length ? `${markdownTable(rows)}\n` : "Nothing finished.\n") +
|
||||
(previews.length ? `\n${previews.join("\n")}` : ""),
|
||||
);
|
||||
+117
-14
@@ -1,21 +1,114 @@
|
||||
/**
|
||||
* Shared work/ solution picker for the test and submit entries: fuzzy-select
|
||||
* a solution file, then hand it to leetcode-cli. Library: side-effect-free on
|
||||
* import — nothing scans the filesystem until runOnSolution() is called.
|
||||
* 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
|
||||
* file's birthtime, mtime when the filesystem has none) so ordering works for
|
||||
* files that predate the db. Files git reports as modified, added or
|
||||
* untracked come first, newest edit first — the problem being worked on is
|
||||
* the focused option, so Enter runs it without a search. The rest are ordered
|
||||
* per command — test lists the most recently tested first (never-tested
|
||||
* after, by name); submit lists the most recently created first. Nothing is
|
||||
* ever hidden. Exit 0 of leetcode-cli stamps last_tested / last_submitted.
|
||||
*
|
||||
* Library: side-effect-free on import — nothing scans the filesystem until
|
||||
* runOnSolution() is called.
|
||||
*/
|
||||
import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
|
||||
import { join, relative } from "node:path";
|
||||
import { statSync } from "node:fs";
|
||||
import { basename, join, relative } from "node:path";
|
||||
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. */
|
||||
function lcNumber(rel: string): number | undefined {
|
||||
const m = basename(rel).match(/^(\d+)\./);
|
||||
return m ? Number(m[1]) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* work/-relative paths of solutions git sees as modified, added or untracked,
|
||||
* mapped to their mtime. Empty outside a git checkout.
|
||||
*/
|
||||
function dirtySolutions(scanned: Set<string>): Map<string, number> {
|
||||
const git = Bun.spawnSync(
|
||||
["git", "status", "--porcelain", "-z", "--untracked-files=all", "--", WORK_DIR],
|
||||
{ cwd: ROOT },
|
||||
);
|
||||
const dirty = new Map<string, number>();
|
||||
if (!git.success) return dirty;
|
||||
const entries = git.stdout.toString().split("\0");
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i]!;
|
||||
// renames/copies carry their source path as the next entry; skip it
|
||||
if (/[RC]/.test(entry.slice(0, 2))) i++;
|
||||
const rel = relative(WORK_DIR, join(ROOT, entry.slice(3)));
|
||||
// deletions and non-solution files are not in the scan
|
||||
if (scanned.has(rel)) dirty.set(rel, statSync(join(WORK_DIR, rel)).mtimeMs);
|
||||
}
|
||||
return dirty;
|
||||
}
|
||||
|
||||
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
|
||||
export async function runOnSolution(command: "test" | "submit", message: string): Promise<never> {
|
||||
const files = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })]
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
export async function runOnSolution(
|
||||
command: "test" | "submit",
|
||||
message: string,
|
||||
): Promise<never> {
|
||||
const db = problemDb();
|
||||
|
||||
const scanned = [
|
||||
...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync(
|
||||
{ cwd: WORK_DIR },
|
||||
),
|
||||
];
|
||||
|
||||
// register creation times so files older than the db still sort correctly
|
||||
for (const f of scanned) {
|
||||
const num = lcNumber(f);
|
||||
if (num === undefined) continue;
|
||||
const stat = statSync(join(WORK_DIR, f));
|
||||
db.ensureSolution(
|
||||
num,
|
||||
new Date(stat.birthtimeMs || stat.mtimeMs).toISOString(),
|
||||
);
|
||||
}
|
||||
const activity = db.solutionActivity();
|
||||
|
||||
// test → most recently tested first; submit → most recently created first
|
||||
const sortKey = (f: string) => {
|
||||
const num = lcNumber(f);
|
||||
const row = num === undefined ? undefined : activity.get(num);
|
||||
return (command === "test" ? row?.lastTested : row?.createdAt) ?? undefined;
|
||||
};
|
||||
|
||||
const dirty = dirtySolutions(new Set(scanned));
|
||||
const files = scanned.sort((a, b) => {
|
||||
const ma = dirty.get(a);
|
||||
const mb = dirty.get(b);
|
||||
if (ma !== mb) {
|
||||
if (ma === undefined) return 1;
|
||||
if (mb === undefined) return -1;
|
||||
return mb - ma;
|
||||
}
|
||||
const ka = sortKey(a);
|
||||
const kb = sortKey(b);
|
||||
if (ka !== kb) {
|
||||
if (ka === undefined) return 1;
|
||||
if (kb === undefined) return -1;
|
||||
return kb.localeCompare(ka); // ISO timestamps: lexicographic = chronological
|
||||
}
|
||||
return a.localeCompare(b, undefined, { numeric: true });
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -24,12 +117,15 @@ export async function runOnSolution(command: "test" | "submit", message: string)
|
||||
placeholder: "Type to search...",
|
||||
maxItems: 12,
|
||||
options: files.map((f) => {
|
||||
// work layout: Difficulty/Category/<id>.<slug>.<ext>
|
||||
const [difficulty, category, name] = f.split("/");
|
||||
const parsed = parseWorkPath(f);
|
||||
const stage =
|
||||
parsed?.bucket === 1 ? "first solve" : `${parsed?.bucket}-day review`;
|
||||
return {
|
||||
value: f,
|
||||
label: name ?? f,
|
||||
hint: category ? `${difficulty} · ${category}` : difficulty,
|
||||
label: basename(f),
|
||||
hint: parsed
|
||||
? `${dirty.has(f) ? "uncommitted · " : ""}${stage} · ${parsed.difficulty} · ${parsed.category}`
|
||||
: f,
|
||||
};
|
||||
}),
|
||||
filter: (search, option) => {
|
||||
@@ -55,5 +151,12 @@ export async function runOnSolution(command: "test" | "submit", message: string)
|
||||
],
|
||||
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
|
||||
);
|
||||
process.exit(await proc.exited);
|
||||
const code = await proc.exited;
|
||||
const num = lcNumber(picked);
|
||||
if (code === 0 && num !== undefined) {
|
||||
if (command === "test") db.markTested(num);
|
||||
else db.markSubmitted(num);
|
||||
}
|
||||
db.close();
|
||||
process.exit(code);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Executable
+252
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Reconcile the "Interview Prep" Project's Set and Difficulty fields from the
|
||||
* labels on each problem issue.
|
||||
*
|
||||
* The issue labels are the truth: `set:core|optional|deferred` and
|
||||
* `diff:easy|medium|hard` are fixed when a problem issue is filed, and the
|
||||
* Project's single-selects are a projection of them so the board can group and
|
||||
* filter. This recomputes every desired value from scratch and writes only the
|
||||
* ones that differ, so re-runs are no-ops and a backfill needs no special
|
||||
* casing — same reconcile-don't-react shape as close-solved / close-topics.
|
||||
*
|
||||
* Scope guard: only items whose issue carries the `problem` label are touched.
|
||||
* Topic, cadence and review issues have no set or difficulty, so they are
|
||||
* skipped outright — never written, never cleared.
|
||||
*
|
||||
* Deliberately NOT mirrored: SRS stage and first-attempt result. Those live
|
||||
* only in D1 (apps/api owns them; the charts, digest and /api/stats read them
|
||||
* from there). The Project mirror writes Target Date and nothing else.
|
||||
*
|
||||
* Auth: needs a token carrying the `project` scope — neither the default
|
||||
* `gh auth token` nor Actions' GITHUB_TOKEN has it. Pass one in GH_TOKEN: the
|
||||
* PROJECT_PAT secret in CI, or the Worker's GH_PAT locally:
|
||||
* GH_TOKEN=$GH_PAT bun run sync-project-fields -- --dry-run
|
||||
*/
|
||||
import { github, type GitHub } from "./github.ts";
|
||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
|
||||
const PROJECT_TITLE = "Interview Prep";
|
||||
|
||||
/** Label → single-select option name, per field. Static tables, so Record. */
|
||||
const FIELDS: Record<string, Record<string, string>> = {
|
||||
Set: { "set:core": "Core", "set:optional": "Optional", "set:deferred": "Deferred" },
|
||||
Difficulty: { "diff:easy": "Easy", "diff:medium": "Medium", "diff:hard": "Hard" },
|
||||
};
|
||||
|
||||
const DRY =
|
||||
process.argv.includes("--dry-run") ||
|
||||
process.env.DRY_RUN === "1" ||
|
||||
process.env.DRY_RUN === "true";
|
||||
|
||||
// ── project + field resolution ───────────────────────────────────
|
||||
|
||||
interface SelectField {
|
||||
id: string;
|
||||
/** option name → option id */
|
||||
options: Record<string, string>;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
fields: Record<string, SelectField>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field and option IDs are resolved by NAME, so re-creating a field in the UI
|
||||
* does not strand this script on a dead id.
|
||||
*/
|
||||
async function readProject(gh: GitHub, owner: string): Promise<Project> {
|
||||
const data = (await gh.graphql(
|
||||
`query($owner: String!, $title: String!) {
|
||||
user(login: $owner) {
|
||||
projectsV2(first: 10, query: $title) {
|
||||
nodes {
|
||||
id title
|
||||
fields(first: 30) {
|
||||
nodes {
|
||||
... on ProjectV2SingleSelectField { id name options { id name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner, title: PROJECT_TITLE },
|
||||
)) as {
|
||||
user: {
|
||||
projectsV2: {
|
||||
nodes: {
|
||||
id: string;
|
||||
title: string;
|
||||
fields: { nodes: ({ id: string; name: string; options: { id: string; name: string }[] } | Record<string, never>)[] };
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
|
||||
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
|
||||
|
||||
const fields: Record<string, SelectField> = {};
|
||||
for (const name of Object.keys(FIELDS)) {
|
||||
const field = node.fields.nodes.find((f) => "name" in f && f.name === name);
|
||||
if (!field || !("options" in field)) {
|
||||
throw new Error(`single-select field "${name}" missing from "${PROJECT_TITLE}"`);
|
||||
}
|
||||
fields[name] = {
|
||||
id: field.id,
|
||||
options: Object.fromEntries(field.options.map((o) => [o.name, o.id])),
|
||||
};
|
||||
}
|
||||
return { id: node.id, fields };
|
||||
}
|
||||
|
||||
// ── items ────────────────────────────────────────────────────────
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
issue: number;
|
||||
title: string;
|
||||
labels: string[];
|
||||
/** field name → current option name */
|
||||
current: Record<string, string>;
|
||||
}
|
||||
|
||||
async function* readItems(gh: GitHub, project: string): AsyncGenerator<Item, void, void> {
|
||||
let cursor: string | null = null;
|
||||
for (;;) {
|
||||
const data = (await gh.graphql(
|
||||
`query($id: ID!, $after: String) {
|
||||
node(id: $id) {
|
||||
... on ProjectV2 {
|
||||
items(first: 100, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id
|
||||
fieldValues(first: 25) {
|
||||
nodes {
|
||||
... on ProjectV2ItemFieldSingleSelectValue {
|
||||
name field { ... on ProjectV2FieldCommon { name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
content {
|
||||
... on Issue { number title labels(first: 25) { nodes { name } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ id: project, after: cursor },
|
||||
)) as {
|
||||
node: {
|
||||
items: {
|
||||
pageInfo: { hasNextPage: boolean; endCursor: string };
|
||||
nodes: {
|
||||
id: string;
|
||||
fieldValues: { nodes: ({ name: string; field: { name: string } } | Record<string, never>)[] };
|
||||
content: { number: number; title: string; labels: { nodes: { name: string }[] } } | Record<string, never>;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
for (const node of data.node.items.nodes) {
|
||||
const content = node.content;
|
||||
// Draft items and PRs have no issue number; nothing to reconcile.
|
||||
if (!("number" in content) || typeof content.number !== "number") continue;
|
||||
const current: Record<string, string> = {};
|
||||
for (const value of node.fieldValues.nodes) {
|
||||
if ("name" in value && value.field?.name) current[value.field.name] = value.name;
|
||||
}
|
||||
yield {
|
||||
id: node.id,
|
||||
issue: content.number,
|
||||
title: content.title,
|
||||
labels: content.labels.nodes.map((l) => l.name),
|
||||
current,
|
||||
};
|
||||
}
|
||||
|
||||
const page = data.node.items.pageInfo;
|
||||
if (!page.hasNextPage) return;
|
||||
cursor = page.endCursor;
|
||||
}
|
||||
}
|
||||
|
||||
// ── reconcile ────────────────────────────────────────────────────
|
||||
|
||||
interface Write {
|
||||
item: string;
|
||||
issue: number;
|
||||
title: string;
|
||||
field: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
async function setField(gh: GitHub, project: string, w: Write, option: string): Promise<void> {
|
||||
await gh.graphql(
|
||||
`mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) {
|
||||
updateProjectV2ItemFieldValue(
|
||||
input: {
|
||||
projectId: $project, itemId: $item, fieldId: $field
|
||||
value: { singleSelectOptionId: $option }
|
||||
}
|
||||
) { projectV2Item { id } }
|
||||
}`,
|
||||
{ project, item: w.item, field: w.field, option },
|
||||
);
|
||||
}
|
||||
|
||||
const gh = await github();
|
||||
const owner = gh.repo.split("/")[0]!;
|
||||
const project = await readProject(gh, owner);
|
||||
|
||||
const writes: Write[] = [];
|
||||
let problems = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for await (const item of readItems(gh, project.id)) {
|
||||
if (!item.labels.includes("problem")) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
problems++;
|
||||
for (const [field, mapping] of Object.entries(FIELDS)) {
|
||||
const want = item.labels.map((l) => mapping[l]).find(Boolean);
|
||||
if (!want) {
|
||||
console.warn(`#${item.issue} has no ${field.toLowerCase()} label — left untouched`);
|
||||
continue;
|
||||
}
|
||||
const from = item.current[field];
|
||||
if (from === want) continue;
|
||||
if (!project.fields[field]!.options[want]) {
|
||||
throw new Error(`field "${field}" has no option "${want}"`);
|
||||
}
|
||||
writes.push({
|
||||
item: item.id,
|
||||
issue: item.issue,
|
||||
title: item.title,
|
||||
field: project.fields[field]!.id,
|
||||
from: from ?? "(empty)",
|
||||
to: want,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rows: string[][] = [["ISSUE", "PROBLEM", "FIELD", "FROM", "TO"]];
|
||||
for (const w of writes) {
|
||||
const name = Object.entries(project.fields).find(([, f]) => f.id === w.field)![0];
|
||||
rows.push([`#${w.issue}`, w.title.slice(0, 46), name, w.from, w.to]);
|
||||
if (DRY) continue;
|
||||
await setField(gh, project.id, w, project.fields[name]!.options[w.to]!);
|
||||
}
|
||||
|
||||
if (writes.length) printTable(rows);
|
||||
console.log(
|
||||
`\n${DRY ? "would update" : "updated"} ${writes.length} field value(s) across ${problems} problem item(s); ${skipped} non-problem item(s) skipped`,
|
||||
);
|
||||
if (writes.length) await writeStepSummary(markdownTable(rows));
|
||||
+675
-39
@@ -1,21 +1,91 @@
|
||||
#!/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.
|
||||
* - Already-ported pages only get their `## Solution` section regenerated,
|
||||
* so hand-curated prose is never touched.
|
||||
* - Already-ported pages only get their script-owned parts regenerated — the
|
||||
* frontmatter `icon:` (`PAGE_ICON`) and the `## Solution` section — so
|
||||
* hand-curated prose is never touched.
|
||||
* - When a problem is solved in both JavaScript and Python the solution
|
||||
* renders as a <CodeGroup> (Python first); single-language solutions
|
||||
* render as a plain fence.
|
||||
* - Every page carries LeetCode's own topic tags as frontmatter `topics:`
|
||||
* (`{ name, slug }` per tag, LeetCode's order), fetched from the GraphQL
|
||||
* `topicTags` once — when the page is created, or on any later run that
|
||||
* finds the key missing — and never refreshed after, so a sync with nothing
|
||||
* new makes no request. Delete the key to refetch it. The page itself
|
||||
* renders no badges: the docs MDX `wrapper`
|
||||
* (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the
|
||||
* `video:` key `videos.ts` writes into the badge row under the description.
|
||||
* - Every planned page (a problem the README plan lists) carries its GitHub
|
||||
* problem issue as frontmatter `issue:` (the URL of the `problem`-labelled
|
||||
* issue titled `LC <num> · …`), rendered by the same wrapper as a GitHub
|
||||
* badge after the others; an unplanned warm-up has no catalog issue and no
|
||||
* key. Like `topics:` it is filled once — on create, or on any run that
|
||||
* finds it missing — and the issue list is fetched only on such a run
|
||||
* (never on a sync with nothing new); offline, or with no `gh` auth, the
|
||||
* page is written without it and the next sync retries. Delete the key to
|
||||
* refetch it.
|
||||
* - Every page carries the companies that asked it in the last six months as
|
||||
* frontmatter `companies:` (`{ name, period, frequency }`, the most recent
|
||||
* window each company falls in and its frequency there, most recent first;
|
||||
* `apps/docs/companies.ts`), read from the local DuckDB
|
||||
* company-frequency database (`COMPANY_DB`, built by its own `mise run
|
||||
* lc:build`) with the `duckdb` CLI. Unlike the keys above it is rewritten
|
||||
* every run, since recency ages and a rebuilt database should show; a
|
||||
* problem no company asked lately carries no key. Where the database or the
|
||||
* CLI is missing (CI), the step is skipped and pages keep the key as is.
|
||||
* - Every tag a page carries that no campaign topic teaches (not a key of
|
||||
* `apps/docs/topic-landings.ts`) belongs in the Appendix glossary,
|
||||
* `apps/docs/appendix.ts`, which is hand-written: sync only checks it,
|
||||
* warning about a carried tag with no term (its badge falls back to
|
||||
* LeetCode's list), a term whose tag a topic now teaches, and a term icon
|
||||
* that is missing, a topic's (`TOPIC_ICONS`), or another term's — every
|
||||
* badge linking to the term shows it.
|
||||
*
|
||||
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`,
|
||||
* ordered by topic number via a script-owned `meta.ts`). A problem outside the
|
||||
* plan (warm-ups) has no topic, so it groups under the data-structure CATEGORY
|
||||
* its file sits under in `work/1/<Difficulty>/<Category>/` instead.
|
||||
*
|
||||
* Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`.
|
||||
* The phase folder is a Blume `(group)` — a sidebar section adding no route
|
||||
* segment — while the topic folder is a real segment
|
||||
* (`/tree-dfs/invert-binary-tree`), so a hand-written `index.mdx` in it is the
|
||||
* topic's landing page at `/tree-dfs` and Blume links the sidebar group row to
|
||||
* it. The topic's problem pages sit one level down in a `(problems)` group,
|
||||
* `tree-dfs/(problems)/226_invert-binary-tree.mdx`: no route segment either,
|
||||
* and Blume strips the `<num>_` prefix from the route while sorting by it, so
|
||||
* the URL is still `/tree-dfs/invert-binary-tree`. Sync never writes or moves
|
||||
* an `index.mdx`, and never removes a folder that still holds one. A phase
|
||||
* folder's `meta.ts` is written once, when missing, then hand-owned (its
|
||||
* title is yours to rename); a topic's is script-owned. An unplanned category
|
||||
* group has no phase and sits at the content root.
|
||||
*
|
||||
* Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves
|
||||
* of problems whose page already exists (see work.ts), and a fresh scaffold
|
||||
* there would otherwise wipe the page's `## Solution`.
|
||||
*
|
||||
* Every page also gets a short link: `/<num>` → `/<topic>/<slug>` (so
|
||||
* `/leetcode/217` opens Contains Duplicate). The rules live in the generated
|
||||
* `apps/docs/redirects.ts`, which `blume.config.ts` imports — Blume takes exact
|
||||
* root-relative paths and prepends the deployment base itself.
|
||||
*/
|
||||
import { mkdir, rename } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
|
||||
import { APPENDIX } from "../docs/appendix.ts";
|
||||
import { RECENCY, type Company } from "../docs/companies.ts";
|
||||
import { TOPIC_LANDINGS } from "../docs/topic-landings.ts";
|
||||
import { github } from "./github.ts";
|
||||
|
||||
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 REDIRECTS = join(ROOT, "apps", "docs", "redirects.ts");
|
||||
|
||||
type Lang = "js" | "py";
|
||||
|
||||
@@ -46,11 +116,15 @@ interface Solution {
|
||||
// ── work/ parsing ────────────────────────────────────────────────
|
||||
|
||||
/** Split a source file into (header comment text, code). */
|
||||
function splitSource(src: string, lang: Lang): { header: string; code: string } {
|
||||
function splitSource(
|
||||
src: string,
|
||||
lang: Lang,
|
||||
): { header: string; code: string } {
|
||||
let header: string, code: string;
|
||||
if (lang === "js") {
|
||||
const end = src.indexOf("*/");
|
||||
if (!src.trimStart().startsWith("/*") || end === -1) throw new Error("missing /* header */");
|
||||
if (!src.trimStart().startsWith("/*") || end === -1)
|
||||
throw new Error("missing /* header */");
|
||||
header = src
|
||||
.slice(src.indexOf("/*") + 2, end)
|
||||
.split("\n")
|
||||
@@ -82,7 +156,11 @@ function paragraphs(lines: string[]): string[] {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
if (/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(text)) {
|
||||
if (
|
||||
/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(
|
||||
text,
|
||||
)
|
||||
) {
|
||||
flush();
|
||||
cur = text;
|
||||
} else {
|
||||
@@ -100,7 +178,10 @@ function parseHeader(header: string): Header {
|
||||
const num = Number.parseInt(titleLine, 10);
|
||||
const title = titleLine.replace(/^\d+\.\s*/, "");
|
||||
const difficulty =
|
||||
lines.find((l) => l.trim().startsWith("Difficulty:"))?.split(":")[1]?.trim() ?? "";
|
||||
lines
|
||||
.find((l) => l.trim().startsWith("Difficulty:"))
|
||||
?.split(":")[1]
|
||||
?.trim() ?? "";
|
||||
|
||||
// Body: everything after the ───── separator.
|
||||
const sep = lines.findIndex((l) => /^[─]{3,}/.test(l.trim()));
|
||||
@@ -154,37 +235,360 @@ function parseHeader(header: string): Header {
|
||||
|
||||
// ── README topic map ─────────────────────────────────────────────
|
||||
|
||||
/** A campaign phase: `## Phase N — Name · Weeks …` in the README. */
|
||||
interface Phase {
|
||||
order: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** A campaign topic: `<strong>NN. Name</strong>` in the README table. */
|
||||
interface Topic {
|
||||
order?: number; // README topic number; absent for an unplanned problem's category group
|
||||
name: string;
|
||||
phase?: Phase; // absent for an unplanned category group
|
||||
}
|
||||
|
||||
/** README phase names → the short labels the overview page uses. */
|
||||
const PHASE_ALIASES: Record<string, string> = {
|
||||
"Nodal & Grid Structures": "Nodal & Grid",
|
||||
"Hierarchical Structures": "Hierarchical",
|
||||
"Relational Structures": "Relational",
|
||||
"Decision-Space Exploration": "Decision Space",
|
||||
};
|
||||
|
||||
const ROMAN = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"];
|
||||
|
||||
const TOPIC_ALIASES: Record<string, string> = {
|
||||
"Hash-Based Lookup": "Hash Table",
|
||||
};
|
||||
|
||||
async function readmeTopics(): Promise<Map<number, string>> {
|
||||
/**
|
||||
* Sidebar icon per group, keyed by the (post-alias) topic name — the same key an
|
||||
* unplanned problem's category group uses, so `Stack` the category and `Stack`
|
||||
* the topic share one row. Lucide names; Blume resolves them at build time and
|
||||
* reports a diagnostic for a typo, but an unmapped group is caught here first.
|
||||
*/
|
||||
const TOPIC_ICONS: Record<string, string> = {
|
||||
// README topics, in curriculum order
|
||||
"Hash Table": "hash",
|
||||
"Prefix Sum": "sigma",
|
||||
"Two Pointers": "arrow-left-right",
|
||||
"Sliding Window": "app-window",
|
||||
"Binary Search": "search",
|
||||
Stack: "layers",
|
||||
"Monotonic Stack": "trending-up",
|
||||
"Linked List": "link",
|
||||
"Hybrid Structures": "combine",
|
||||
"Matrix Index Math": "grid-3x3",
|
||||
"Tree DFS": "folder-tree",
|
||||
"Tree BFS": "network",
|
||||
"Binary Search Tree": "git-fork",
|
||||
"Heap / Priority Queue": "triangle",
|
||||
Trie: "spell-check",
|
||||
"Graph DFS": "waypoints",
|
||||
"Graph BFS": "radar",
|
||||
"Topological Sort": "workflow",
|
||||
"Union-Find": "merge",
|
||||
Backtracking: "undo-2",
|
||||
"1-D Dynamic Programming": "columns-3",
|
||||
"Multi-D / Grid DP": "table",
|
||||
Greedy: "coins",
|
||||
Intervals: "calendar-range",
|
||||
// work/1 categories that can surface as an unplanned group
|
||||
Array: "brackets",
|
||||
String: "type",
|
||||
Tree: "network",
|
||||
"Depth-First Search": "folder-tree",
|
||||
};
|
||||
|
||||
/** LC number -> topic. A problem listed under two topics keeps the first. */
|
||||
async function readmeTopics(): Promise<Map<number, Topic>> {
|
||||
const md = await Bun.file(join(ROOT, "README.md")).text();
|
||||
const map = new Map<number, string>();
|
||||
let topic = "";
|
||||
const map = new Map<number, Topic>();
|
||||
let phase: Phase | undefined;
|
||||
let topic: Topic | null = null;
|
||||
for (const line of md.split("\n")) {
|
||||
const t = line.match(/<strong>\d+\.\s*([^<]+)<\/strong>/);
|
||||
if (t) topic = TOPIC_ALIASES[t[1]!.trim()] ?? t[1]!.trim();
|
||||
const p = line.match(/^## Phase ([IVX]+) — (.+?) · /);
|
||||
if (p) {
|
||||
const name = p[2]!.replaceAll("&", "&");
|
||||
phase = {
|
||||
order: ROMAN.indexOf(p[1]!) + 1,
|
||||
name: PHASE_ALIASES[name] ?? name,
|
||||
};
|
||||
}
|
||||
const t = line.match(/<strong>(\d+)\.\s*([^<]+)<\/strong>/);
|
||||
if (t)
|
||||
topic = {
|
||||
order: Number(t[1]),
|
||||
name: TOPIC_ALIASES[t[2]!.trim()] ?? t[2]!.trim(),
|
||||
phase,
|
||||
};
|
||||
if (!topic) continue;
|
||||
const n = line.match(/<td align="center">(\d+)<\/td>/);
|
||||
if (n) map.set(Number(n[1]), topic);
|
||||
if (n && !map.has(Number(n[1]))) map.set(Number(n[1]), topic);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder slug for a topic or phase name. Blume reads a leading `<digits>-` on a
|
||||
* folder as a sidebar-order prefix and strips it from the route (`1-d-…` →
|
||||
* `/d-…`), so a slug that would start that way keeps the digits glued to the
|
||||
* next token (`1d-dynamic-programming`).
|
||||
*/
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.replace(/^(\d+)-/, "$1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Content folder for a topic, relative to `DOCS`: `(0N-<phase>)/<topic>` for a
|
||||
* planned topic (the phase is a `(group)`, so the route is still `/<topic>`),
|
||||
* bare `<topic>` for an unplanned category group.
|
||||
*/
|
||||
function topicDir(topic: Topic): string {
|
||||
const slug = slugify(topic.name);
|
||||
return topic.phase ? `${phaseDir(topic.phase)}/${slug}` : slug;
|
||||
}
|
||||
|
||||
function phaseDir(phase: Phase): string {
|
||||
return `(${String(phase.order).padStart(2, "0")}-${slugify(phase.name)})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blume `(group)` meta for a phase: the overview page's heading, in phase
|
||||
* order. Written only when the folder has none — the title is hand-owned after
|
||||
* that. Nothing is lost by never rewriting it: the order and name it is built
|
||||
* from are also the folder name (`phaseDir`), so a README change to either
|
||||
* lands in a new folder that gets a fresh meta.
|
||||
*/
|
||||
function phaseMeta(phase: Phase): string {
|
||||
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: ${JSON.stringify(`Phase ${phase.order}: ${phase.name}`)}, order: ${phase.order} });\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blume folder meta for a topic: exact label (the folder slug loses `/` and
|
||||
* case), curriculum order, the sidebar icon, and `display: "page"` so the
|
||||
* topic is one row under its phase heading that drills into its own panel
|
||||
* (the landing page, then its `(problems)` list). An unplanned category group
|
||||
* (warm-ups) has no README number and sorts at 0, ahead of phase 1, so the
|
||||
* sidebar opens on the basics.
|
||||
*/
|
||||
function groupMeta(topic: Topic): 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",
|
||||
);
|
||||
}
|
||||
|
||||
// ── GitHub problem issues ────────────────────────────────────────
|
||||
|
||||
let issueUrls: Promise<Map<number, string> | undefined> | undefined;
|
||||
|
||||
/**
|
||||
* LC number → the URL of its `problem` issue (`LC <num> · <Name>`, open or
|
||||
* closed), listed once per run and only when a page first needs it — or
|
||||
* undefined, warned, when GitHub cannot answer (offline commit, no `gh`
|
||||
* auth): pages go without `issue:` and the next sync fills it in.
|
||||
*/
|
||||
function problemIssues(): Promise<Map<number, string> | undefined> {
|
||||
issueUrls ??= (async () => {
|
||||
try {
|
||||
const gh = await github();
|
||||
const urls = new Map<number, string>();
|
||||
for await (const raw of gh.list(
|
||||
`/repos/${gh.repo}/issues?labels=problem&state=all`,
|
||||
)) {
|
||||
if (!raw || typeof raw !== "object" || "pull_request" in raw) continue;
|
||||
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||
if (!("html_url" in raw) || typeof raw.html_url !== "string") continue;
|
||||
const lc = raw.title.match(/^LC (\d+) · /)?.[1];
|
||||
if (lc && !urls.has(Number(lc))) urls.set(Number(lc), raw.html_url);
|
||||
}
|
||||
return urls;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`no problem issues: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
return issueUrls;
|
||||
}
|
||||
|
||||
/**
|
||||
* The problem's issue URL, or undefined (warned when GitHub answered but has
|
||||
* no such issue). Only a problem the README plan lists has a catalog issue;
|
||||
* an unplanned warm-up never will, so it is not looked up — otherwise its
|
||||
* missing key would re-list the issues and re-warn on every sync.
|
||||
*/
|
||||
async function issueUrl(sol: Solution): Promise<string | undefined> {
|
||||
if (!topics.has(sol.num)) return undefined;
|
||||
const urls = await problemIssues();
|
||||
const url = urls?.get(sol.num);
|
||||
if (urls && !url)
|
||||
console.warn(
|
||||
`no problem issue titled "LC ${sol.num} · …" for ${sol.num}.${sol.slug}`,
|
||||
);
|
||||
return url;
|
||||
}
|
||||
|
||||
// ── company frequency ────────────────────────────────────────────
|
||||
|
||||
// The windows and the key's shape are `apps/docs/companies.ts`, shared with
|
||||
// the schema and the readers; `More Than Six Months` and `All` are not recent.
|
||||
const COMPANY_DB = join(
|
||||
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
|
||||
"duckdb",
|
||||
"data",
|
||||
"leetcode-company-frequency.db",
|
||||
);
|
||||
|
||||
/**
|
||||
* LC number → the companies that asked it in the last six months, each at the
|
||||
* most recent window it appears in (the windows nest), ordered most recent
|
||||
* first, then by that window's frequency. Undefined when this machine has no
|
||||
* database or no `duckdb` CLI — the step is skipped — or the query fails
|
||||
* (warned). One query for every solved problem.
|
||||
*/
|
||||
function recentCompanies(nums: number[]): Map<number, Company[]> | undefined {
|
||||
if (!existsSync(COMPANY_DB) || !Bun.which("duckdb")) return undefined;
|
||||
const order = `list_position([${RECENCY.map((p) => `'${p}'`).join(", ")}], period)`;
|
||||
const sql = `SELECT question_id, company, period, frequency FROM company_frequency
|
||||
WHERE question_id IN (${nums.join(", ")}) AND ${order} IS NOT NULL
|
||||
QUALIFY row_number() OVER (PARTITION BY question_id, company ORDER BY ${order}) = 1
|
||||
ORDER BY question_id, ${order}, frequency DESC, company`;
|
||||
const proc = Bun.spawnSync(["duckdb", "-readonly", "-json", COMPANY_DB, "-c", sql]);
|
||||
if (!proc.success) {
|
||||
console.warn(`no companies: duckdb -> ${proc.stderr.toString().trim()}`);
|
||||
return undefined;
|
||||
}
|
||||
const out = proc.stdout.toString().trim();
|
||||
const rows: unknown = out ? JSON.parse(out) : [];
|
||||
const companies = new Map<number, Company[]>(nums.map((n) => [n, []]));
|
||||
for (const row of Array.isArray(rows) ? rows : []) {
|
||||
if (!row || typeof row !== "object") continue;
|
||||
if (!("question_id" in row) || typeof row.question_id !== "number") continue;
|
||||
if (!("company" in row) || typeof row.company !== "string") continue;
|
||||
if (!("period" in row) || !("frequency" in row) || typeof row.frequency !== "number") continue;
|
||||
const period = RECENCY.find((p) => p === row.period);
|
||||
if (period)
|
||||
companies.get(row.question_id)?.push({ name: row.company, period, frequency: row.frequency });
|
||||
}
|
||||
return companies;
|
||||
}
|
||||
|
||||
/** The frontmatter `companies:` block, one flow mapping per company (JSON strings are valid YAML). */
|
||||
function companiesYaml(list: Company[]): string {
|
||||
return [
|
||||
"companies:",
|
||||
...list.map(
|
||||
(c) =>
|
||||
` - { name: ${JSON.stringify(c.name)}, period: ${JSON.stringify(c.period)}, frequency: ${c.frequency} }`,
|
||||
),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// ── inline-code formatting heuristics ────────────────────────────
|
||||
|
||||
/** Identifiers worth backticking in prose, harvested from the code. */
|
||||
function identifiers(sol: Solution): Set<string> {
|
||||
const ids = new Set<string>(["n", "m", "k"]);
|
||||
for (const code of Object.values(sol.code)) {
|
||||
for (const m of code.matchAll(/@param\s*\{[^}]*\}\s*(\w+)/g)) ids.add(m[1]!);
|
||||
for (const m of code.matchAll(/@param\s*\{[^}]*\}\s*(\w+)/g))
|
||||
ids.add(m[1]!);
|
||||
for (const m of code.matchAll(/def \w+\(self,?\s*([^)]*)\)/g))
|
||||
for (const arg of m[1]!.split(","))
|
||||
if (arg.trim()) ids.add(arg.split(":")[0]!.trim());
|
||||
for (const m of code.matchAll(/(?:var|const|let) (\w+) = function\s*\(([^)]*)\)/g))
|
||||
for (const arg of m[2]!.split(","))
|
||||
if (arg.trim()) ids.add(arg.trim());
|
||||
for (const m of code.matchAll(
|
||||
/(?:var|const|let) (\w+) = function\s*\(([^)]*)\)/g,
|
||||
))
|
||||
for (const arg of m[2]!.split(",")) if (arg.trim()) ids.add(arg.trim());
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@@ -241,7 +645,11 @@ function backtickify(text: string, ids: Set<string>): string {
|
||||
while (j > i && /^[+\-*/=%]$/.test(toks[j]!.core)) j--;
|
||||
const span = toks
|
||||
.slice(i, j + 1)
|
||||
.map((tok, idx, arr) => (idx === arr.length - 1 ? `${tok.pre}${tok.core}` : `${tok.pre}${tok.core}${tok.post}`))
|
||||
.map((tok, idx, arr) =>
|
||||
idx === arr.length - 1
|
||||
? `${tok.pre}${tok.core}`
|
||||
: `${tok.pre}${tok.core}${tok.post}`,
|
||||
)
|
||||
.join(" ");
|
||||
out.push(`\`${span}\`${toks[j]!.post}`);
|
||||
i = j + 1;
|
||||
@@ -269,11 +677,23 @@ function warningText(h: Header): string {
|
||||
return (
|
||||
h.statement
|
||||
.slice(1)
|
||||
.find((p) => /^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(p)) ?? ""
|
||||
.find((p) =>
|
||||
/^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(
|
||||
p,
|
||||
),
|
||||
) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function renderPage(sol: Solution, topic: string): string {
|
||||
/** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */
|
||||
const PAGE_ICON = "dot";
|
||||
|
||||
function renderPage(
|
||||
sol: Solution,
|
||||
tags: TopicTag[] | undefined,
|
||||
issue: string | undefined,
|
||||
companies: Company[] | undefined,
|
||||
): string {
|
||||
const h = sol.header;
|
||||
const ids = identifiers(sol);
|
||||
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
|
||||
@@ -282,14 +702,18 @@ function renderPage(sol: Solution, topic: string): string {
|
||||
parts.push(
|
||||
"---",
|
||||
`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)] : []),
|
||||
...(issue ? [`issue: ${JSON.stringify(issue)}`] : []),
|
||||
...(companies?.length ? [companiesYaml(companies)] : []),
|
||||
"sidebar:",
|
||||
` label: '${h.title.replaceAll("'", "''")}'`,
|
||||
` badge: '${h.difficulty}'`,
|
||||
"---",
|
||||
"",
|
||||
`<Badge variant="accent">${topic}</Badge>`,
|
||||
"",
|
||||
);
|
||||
|
||||
const warning = warningText(h);
|
||||
@@ -303,7 +727,8 @@ function renderPage(sol: Solution, topic: string): string {
|
||||
parts.push(`- Explanation: ${backtickify(ex.explanation[0]!, ids)}`);
|
||||
else if (ex.explanation.length > 1) {
|
||||
parts.push("- Explanation:");
|
||||
for (const item of ex.explanation) parts.push(` - ${backtickify(item, ids)}`);
|
||||
for (const item of ex.explanation)
|
||||
parts.push(` - ${backtickify(item, ids)}`);
|
||||
}
|
||||
parts.push("");
|
||||
});
|
||||
@@ -323,12 +748,15 @@ for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
||||
const file = basename(rel);
|
||||
const m = file.match(/^(\d+)\.(.+)\.(?:js|py)$/);
|
||||
if (!m) {
|
||||
console.warn(`skip (unrecognized name): work/${rel}`);
|
||||
console.warn(`skip (unrecognized name): work/1/${rel}`);
|
||||
continue;
|
||||
}
|
||||
const num = Number(m[1]);
|
||||
const slug = m[2]!;
|
||||
const { header, code } = splitSource(await Bun.file(join(WORK, rel)).text(), lang);
|
||||
const { header, code } = splitSource(
|
||||
await Bun.file(join(WORK, rel)).text(),
|
||||
lang,
|
||||
);
|
||||
const parsed = parseHeader(header);
|
||||
const existing = solutions.get(num);
|
||||
if (existing) {
|
||||
@@ -346,6 +774,12 @@ for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
||||
}
|
||||
|
||||
const topics = await readmeTopics();
|
||||
const companies = recentCompanies([...solutions.keys()]);
|
||||
// An unplanned problem whose work/ category is also a README topic name (a
|
||||
// `Hash Table` warm-up) files under that topic, in its phase, not a second
|
||||
// root-level `hash-table/`.
|
||||
const topicByName = new Map<string, Topic>();
|
||||
for (const topic of topics.values()) topicByName.set(topic.name, topic);
|
||||
|
||||
/** Insert a number-free `sidebar.label` into existing frontmatter when missing. */
|
||||
function ensureLabel(page: string, h: Header): string {
|
||||
@@ -355,9 +789,81 @@ function ensureLabel(page: string, h: Header): string {
|
||||
return page.replace(/^sidebar:$/m, `sidebar:\n${label}`);
|
||||
}
|
||||
|
||||
/** Set the frontmatter's top-level `icon:` to `PAGE_ICON`, inserting it before `sidebar:`. */
|
||||
function ensureIcon(page: string): string {
|
||||
const fm = page.match(/^---\n[\s\S]*?\n---/);
|
||||
if (!fm) return page;
|
||||
const line = `icon: ${PAGE_ICON}`;
|
||||
const next = /^icon: .*$/m.test(fm[0])
|
||||
? fm[0].replace(/^icon: .*$/m, line)
|
||||
: fm[0].replace(/^sidebar:$/m, `${line}\nsidebar:`);
|
||||
return next + page.slice(fm[0].length);
|
||||
}
|
||||
|
||||
/** Insert `topics:` before `sidebar:` when the frontmatter lacks it; an existing list is kept as is. */
|
||||
async function ensureTopics(page: string, sol: Solution): Promise<string> {
|
||||
const fm = page.match(/^---\n[\s\S]*?\n---/);
|
||||
if (!fm || /^topics:/m.test(fm[0])) return page;
|
||||
const tags = await fetchTopicTags(sol);
|
||||
if (!tags) return page;
|
||||
return (
|
||||
fm[0].replace(/^sidebar:$/m, `${topicsYaml(tags)}\nsidebar:`) +
|
||||
page.slice(fm[0].length)
|
||||
);
|
||||
}
|
||||
|
||||
/** Insert `issue:` before `sidebar:` when the frontmatter lacks it; an existing URL is kept as is. */
|
||||
async function ensureIssue(page: string, sol: Solution): Promise<string> {
|
||||
const fm = page.match(/^---\n[\s\S]*?\n---/);
|
||||
if (!fm || /^issue:/m.test(fm[0])) return page;
|
||||
const url = await issueUrl(sol);
|
||||
if (!url) return page;
|
||||
return (
|
||||
fm[0].replace(/^sidebar:$/m, `issue: ${JSON.stringify(url)}\nsidebar:`) +
|
||||
page.slice(fm[0].length)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the frontmatter's `companies:` with this run's list, inserted before
|
||||
* `sidebar:` (dropped when no company asked lately). Without the database the
|
||||
* page is returned untouched, so an environment lacking it never strips the key.
|
||||
*/
|
||||
function ensureCompanies(page: string, sol: Solution): string {
|
||||
if (!companies) return page;
|
||||
const fm = page.match(/^---\n[\s\S]*?\n---/);
|
||||
if (!fm) return page;
|
||||
const list = companies.get(sol.num) ?? [];
|
||||
const without = fm[0].replace(/^companies:.*\n(?: {2}- .*\n)*/m, "");
|
||||
const next = list.length
|
||||
? without.replace(/^sidebar:$/m, `${companiesYaml(list)}\nsidebar:`)
|
||||
: without;
|
||||
return next + page.slice(fm[0].length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offset just past the script-owned `## Solution` section: the next top-level
|
||||
* heading that is not inside a code fence, or end of file. Sections a human
|
||||
* appends after the solution survive regeneration.
|
||||
*/
|
||||
function solutionEnd(page: string, from: number): number {
|
||||
const lines = page.slice(from).split("\n");
|
||||
let offset = from + lines[0]!.length + 1; // past the '## Solution' line itself
|
||||
let fenced = false;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
if (line.startsWith("```")) fenced = !fenced;
|
||||
else if (!fenced && line.startsWith("## ")) return offset;
|
||||
offset += line.length + 1;
|
||||
}
|
||||
return page.length;
|
||||
}
|
||||
|
||||
// Existing docs pages, keyed by leetcode number from the frontmatter title.
|
||||
const pages = new Map<number, string>(); // num -> path relative to DOCS
|
||||
for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).sort()) {
|
||||
for (const rel of (
|
||||
await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))
|
||||
).sort()) {
|
||||
const text = await Bun.file(join(DOCS, rel)).text();
|
||||
const t = text.match(/^title: '(\d+)\./m);
|
||||
if (t) pages.set(Number(t[1]), rel);
|
||||
@@ -365,16 +871,28 @@ for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).s
|
||||
|
||||
const report: [string, string, string][] = [];
|
||||
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
|
||||
const groups = new Map<string, Topic>(); // topic folder -> topic, for meta.ts
|
||||
const phases = new Map<string, Phase>(); // phase folder -> phase, for meta.ts
|
||||
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
|
||||
const redirects: string[] = []; // `{ from: "/<num>", to: "/<topic>/<slug>" }` rows, numeric order
|
||||
const tags = new Map<string, TopicTag>(); // every tag slug any problem page carries -> tag
|
||||
|
||||
for (const sol of ordered) {
|
||||
const langs = Object.keys(sol.code).sort().join("+");
|
||||
const workRef = `${sol.num}.${sol.slug} (${langs})`;
|
||||
// (category) group folder + leetcode-number prefix: numeric sidebar order, both stripped from the URL.
|
||||
const group = `(${sol.category.toLowerCase().replaceAll(" ", "-")})`;
|
||||
const name = `${group}/${sol.num}-${sol.slug}.mdx`;
|
||||
// (0N-<phase>)/<topic>/(problems)/ folder (the topic is a real route segment,
|
||||
// so its index.mdx is the landing page; `(problems)` adds none) +
|
||||
// leetcode-number prefix: numeric sidebar order, prefix stripped from the URL.
|
||||
const topic = topics.get(sol.num) ??
|
||||
topicByName.get(sol.category) ?? { name: sol.category };
|
||||
const dir = topicDir(topic);
|
||||
groups.set(dir, topic);
|
||||
if (topic.phase) phases.set(phaseDir(topic.phase), topic.phase);
|
||||
const name = `${dir}/${sol.num}_${sol.slug}.mdx`;
|
||||
const existing = pages.get(sol.num);
|
||||
await mkdir(join(DOCS, group), { recursive: true });
|
||||
await mkdir(join(DOCS, dirname(name)), { recursive: true });
|
||||
|
||||
let written: string; // the page as it stands after this run, for its topics
|
||||
if (existing) {
|
||||
const page = await Bun.file(join(DOCS, existing)).text();
|
||||
const at = page.indexOf("## Solution");
|
||||
@@ -382,10 +900,20 @@ for (const sol of ordered) {
|
||||
report.push([workRef, existing, "ERROR: no '## Solution' heading"]);
|
||||
continue;
|
||||
}
|
||||
const next = ensureLabel(page.slice(0, at) + solutionSection(sol), sol.header);
|
||||
const tail = page.slice(solutionEnd(page, at));
|
||||
const body =
|
||||
page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
|
||||
const next = ensureCompanies(
|
||||
await ensureIssue(
|
||||
await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol),
|
||||
sol,
|
||||
),
|
||||
sol,
|
||||
);
|
||||
const actions: string[] = [];
|
||||
if (existing !== name) {
|
||||
await rename(join(DOCS, existing), join(DOCS, name));
|
||||
vacated.add(dirname(existing));
|
||||
actions.push("moved");
|
||||
}
|
||||
if (next !== page) {
|
||||
@@ -393,10 +921,117 @@ for (const sol of ordered) {
|
||||
actions.push("updated");
|
||||
}
|
||||
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
|
||||
written = next;
|
||||
} else {
|
||||
const topic = topics.get(sol.num) ?? sol.category;
|
||||
await Bun.write(join(DOCS, name), renderPage(sol, topic));
|
||||
const page = renderPage(
|
||||
sol,
|
||||
await fetchTopicTags(sol),
|
||||
await issueUrl(sol),
|
||||
companies?.get(sol.num),
|
||||
);
|
||||
await Bun.write(join(DOCS, name), page);
|
||||
report.push([workRef, name, "created"]);
|
||||
written = page;
|
||||
}
|
||||
for (const tag of frontmatterTopics(written)) tags.set(tag.slug, tag);
|
||||
// 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",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Every carried tag no campaign topic teaches is an Appendix term, filed by
|
||||
// hand in apps/docs/appendix.ts: warn about the gaps and the dead weight. A
|
||||
// term's icon is also the icon on every problem-page badge linking to its
|
||||
// card, so it must not be a topic group's (`TOPIC_ICONS`), so a term badge
|
||||
// never reads as a topic's, nor another term's.
|
||||
for (const tag of [...tags.values()].sort((a, b) =>
|
||||
a.slug.localeCompare(b.slug),
|
||||
)) {
|
||||
if (!TOPIC_LANDINGS[tag.slug] && !APPENDIX[tag.slug])
|
||||
console.warn(
|
||||
`tag ${tag.slug}: no Appendix term; add ${JSON.stringify(tag.name)} to apps/docs/appendix.ts`,
|
||||
);
|
||||
}
|
||||
const topicIcons = new Set(Object.values(TOPIC_ICONS));
|
||||
const termIcons = new Map<string, string>(); // icon -> the term that took it first
|
||||
for (const [slug, term] of Object.entries(APPENDIX)) {
|
||||
if (TOPIC_LANDINGS[slug])
|
||||
console.warn(
|
||||
`appendix ${slug}: a campaign topic teaches it now; delete its term`,
|
||||
);
|
||||
if (topicIcons.has(term.icon))
|
||||
console.warn(
|
||||
`appendix ${slug}: icon ${JSON.stringify(term.icon)} is a topic's (TOPIC_ICONS); pick another`,
|
||||
);
|
||||
else if (termIcons.has(term.icon))
|
||||
console.warn(
|
||||
`appendix ${slug}: icon ${JSON.stringify(term.icon)} is ${termIcons.get(term.icon)}'s too; pick another`,
|
||||
);
|
||||
else termIcons.set(term.icon, slug);
|
||||
}
|
||||
|
||||
// Phase metas: created when missing (an empty file counts as missing), never
|
||||
// rewritten, so a renamed phase title survives every sync.
|
||||
for (const [dir, phase] of phases) {
|
||||
const file = Bun.file(join(DOCS, dir, "meta.ts"));
|
||||
if ((await file.exists()) && file.size > 0) continue;
|
||||
await Bun.write(file, phaseMeta(phase));
|
||||
report.push([`phase ${phase.order}`, `${dir}/meta.ts`, "created"]);
|
||||
}
|
||||
|
||||
// Script-owned folder metas — one per live topic folder — rewritten only when
|
||||
// they differ.
|
||||
const metas: [label: string, dir: string, meta: string][] = [...groups].map(
|
||||
([dir, topic]) => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)],
|
||||
);
|
||||
for (const [label, dir, meta] of metas) {
|
||||
const path = join(DOCS, dir, "meta.ts");
|
||||
if ((await Bun.file(path).exists()) && (await Bun.file(path).text()) === meta)
|
||||
continue;
|
||||
await Bun.write(path, meta);
|
||||
report.push([label, `${dir}/meta.ts`, "written"]);
|
||||
}
|
||||
|
||||
// A folder every page left behind is dead: drop its meta and the folder itself,
|
||||
// then the phase folder above it if that emptied too. A folder that still holds
|
||||
// anything else (a hand-written index.mdx, another topic) stops the climb.
|
||||
for (const start of vacated) {
|
||||
for (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)"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,4 +1039,5 @@ const w0 = Math.max(...report.map((r) => r[0].length), 9);
|
||||
const w1 = Math.max(...report.map((r) => r[1].length), 9);
|
||||
console.log(`${"work file".padEnd(w0)} ${"docs page".padEnd(w1)} status`);
|
||||
console.log(`${"─".repeat(w0)} ${"─".repeat(w1)} ${"─".repeat(20)}`);
|
||||
for (const [a, b, c] of report) console.log(`${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`);
|
||||
for (const [a, b, c] of report)
|
||||
console.log(`${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`);
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Topic issues and their problem sub-issues: the ONE reading of "is this topic
|
||||
* finished", shared by close-topics.ts (closes the topic issue) and
|
||||
* release-topics.ts (publishes the topic's timever release) so the two can
|
||||
* never disagree about when a topic was done. Also the one reading of "is this
|
||||
* Review issue finished", which close-topics.ts closes on.
|
||||
*
|
||||
* Rule: a topic is finished when it has a core set (`set:core` sub-issues) and
|
||||
* every one of them is closed. Optional and deferred problems are extra credit
|
||||
* and never hold a topic open. A topic with no core sub-issues is never
|
||||
* finished — that would be indistinguishable from a mis-labelled problem set.
|
||||
*
|
||||
* Review issues (`Review <N> — <Phase>`, label `review`) are day blocks for
|
||||
* the transfer phase: problems were re-parented into them from their topics
|
||||
* (a GitHub issue has one parent). A topic still OWNS such a problem —
|
||||
* readProblems() returns it alongside the topic's direct sub-issues, with the
|
||||
* ownership recovered by apps/api/src/reviews.ts (shared with the Worker's
|
||||
* catalog reconcile) — so the finished rule and the release snapshots do not
|
||||
* change when a problem moves. A Review issue is finished when every one of
|
||||
* its sub-issues is closed: it is a fixed assignment, so the set labels do
|
||||
* not matter there.
|
||||
*
|
||||
* Library: side-effect-free on import; every call takes the caller's github().
|
||||
*/
|
||||
import type { Problem } from "../api/src/reviews.ts";
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
/** Completion of a required set: a topic's core problems, or every problem of a review. */
|
||||
export interface Core {
|
||||
/** The required problems, closed and open alike, in the order given. */
|
||||
all: number[];
|
||||
/** The required problems still open — non-empty means the issue stays open. */
|
||||
pending: number[];
|
||||
/** When the last required problem closed; null until the set is finished (never, if empty). */
|
||||
finishedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every problem a topic owns: its sub-issues plus the ones moved from it into
|
||||
* a Review issue (`moved`, from movedFromTopics()). The sub_issues payload
|
||||
* carries the full issue objects (state + labels), so no per-problem
|
||||
* follow-up request is needed.
|
||||
*/
|
||||
export async function readProblems(
|
||||
gh: GitHub,
|
||||
topic: number,
|
||||
moved: Map<number, Problem[]>,
|
||||
): Promise<Problem[]> {
|
||||
const problems = [...(moved.get(topic) ?? [])];
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||
if (!("state" in raw) || typeof raw.state !== "string") continue;
|
||||
if (!("labels" in raw) || !Array.isArray(raw.labels)) continue;
|
||||
const closedAt =
|
||||
raw.state === "closed" && "closed_at" in raw && typeof raw.closed_at === "string"
|
||||
? raw.closed_at
|
||||
: null;
|
||||
const labels = raw.labels.flatMap((label: unknown) =>
|
||||
label && typeof label === "object" && "name" in label && typeof label.name === "string"
|
||||
? [label.name]
|
||||
: [],
|
||||
);
|
||||
problems.push({ number: raw.number, title: raw.title, closedAt, labels });
|
||||
}
|
||||
return problems.sort((a, b) => a.number - b.number);
|
||||
}
|
||||
|
||||
/** The value of a `prefix:value` label (`set:` → `core`), if the problem has one. */
|
||||
export function labelValue(problem: Problem, prefix: string): string | undefined {
|
||||
return problem.labels.find((l) => l.startsWith(prefix))?.slice(prefix.length);
|
||||
}
|
||||
|
||||
/** A topic's rule: its `set:core` problems are the required set. */
|
||||
export function coreSet(problems: Problem[]): Core {
|
||||
return completion(problems.filter((p) => labelValue(p, "set:") === "core"));
|
||||
}
|
||||
|
||||
/** The shared rule: finished when non-empty and every required problem is closed. */
|
||||
export function completion(required: Problem[]): Core {
|
||||
const pending = required.filter((p) => !p.closedAt).map((p) => p.number);
|
||||
// ISO instants from the API share one format, so string order is time order.
|
||||
const finishedAt =
|
||||
required.length && !pending.length
|
||||
? required.map((p) => p.closedAt!).reduce((a, b) => (a > b ? a : b))
|
||||
: null;
|
||||
return { all: required.map((p) => p.number), pending, finishedAt };
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Terminal drawing primitives for the stats dashboard: colour, block-glyph
|
||||
* charts, and the alternate-screen plumbing.
|
||||
*
|
||||
* Hand-rolled for the same reason apps/api/src/charts.ts and png.ts are — the
|
||||
* repo carries near-zero runtime dependencies, and everything a progress
|
||||
* dashboard needs is a bar, a sparkline and a coloured cell. The palette is
|
||||
* charts.ts's, so the terminal, the README SVGs and the digest email are the
|
||||
* same picture in three renderers.
|
||||
*
|
||||
* Side-effect-free on import except for reading the colour environment once;
|
||||
* nothing is written to the terminal until openScreen() is called.
|
||||
*/
|
||||
|
||||
// ── palette (apps/api/src/charts.ts) ─────────────────────────────
|
||||
|
||||
export const FG = "#fafafa"; // zinc-50 text
|
||||
export const MUTED = "#a1a1aa"; // zinc-400 secondary text
|
||||
export const LINE = "#27272a"; // zinc-800 structure / empty track
|
||||
export const GREEN = "#16a34a"; // green-600 (core / pass)
|
||||
export const BLUE = "#2563eb"; // blue-600 (optional)
|
||||
export const YELLOW = "#eab308"; // yellow-500 (attention)
|
||||
export const RED = "#f87171"; // red-400 (fail, on dark)
|
||||
|
||||
/** GitHub dark-mode contribution hues; level 0 is the empty cell. */
|
||||
export const GREENS = ["#27272a", "#0e4429", "#006d32", "#26a641", "#39d353"];
|
||||
|
||||
// ── colour ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Honour NO_COLOR and pipes, and let FORCE_COLOR override both — the dashboard
|
||||
* is legible without colour, and `bun run stats | less -R` should still be.
|
||||
*/
|
||||
export const COLOR =
|
||||
process.env.FORCE_COLOR !== undefined
|
||||
? process.env.FORCE_COLOR !== "0"
|
||||
: process.env.NO_COLOR === undefined && process.stdout.isTTY === true;
|
||||
|
||||
const RESET = "\x1b[0m";
|
||||
|
||||
function channels(hex: string): string {
|
||||
return `${parseInt(hex.slice(1, 3), 16)};${parseInt(hex.slice(3, 5), 16)};${parseInt(hex.slice(5, 7), 16)}`;
|
||||
}
|
||||
|
||||
export function fg(hex: string, text: string): string {
|
||||
return COLOR ? `\x1b[38;2;${channels(hex)}m${text}${RESET}` : text;
|
||||
}
|
||||
|
||||
export function bg(hex: string, text: string): string {
|
||||
return COLOR ? `\x1b[48;2;${channels(hex)}m${text}${RESET}` : text;
|
||||
}
|
||||
|
||||
export function bold(text: string): string {
|
||||
return COLOR ? `\x1b[1m${text}${RESET}` : text;
|
||||
}
|
||||
|
||||
const ANSI = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
/** Printable width — every glyph used here is one cell wide. */
|
||||
export function width(text: string): number {
|
||||
return text.replace(ANSI, "").length;
|
||||
}
|
||||
|
||||
/** Pad or cut a *plain* string to exactly n cells; colour it afterwards. */
|
||||
export function cell(text: string, n: number, align: "left" | "right" = "left"): string {
|
||||
const clipped = text.length > n ? `${text.slice(0, Math.max(0, n - 1))}…` : text;
|
||||
return align === "left" ? clipped.padEnd(n) : clipped.padStart(n);
|
||||
}
|
||||
|
||||
/** Pad an already-coloured string to n cells. */
|
||||
export function padTo(text: string, n: number): string {
|
||||
return text + " ".repeat(Math.max(0, n - width(text)));
|
||||
}
|
||||
|
||||
// ── charts ───────────────────────────────────────────────────────
|
||||
|
||||
const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
|
||||
const SPARKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
||||
|
||||
/**
|
||||
* Filled and empty. The two differ by GLYPH as well as by colour, so a bar
|
||||
* still reads under NO_COLOR or in a pipe — every chart here has to survive
|
||||
* `bun run stats > notes.txt`.
|
||||
*/
|
||||
const FILL = "█";
|
||||
const TRACK = "░";
|
||||
|
||||
/**
|
||||
* One bar over a muted track, `cells` wide. Sub-cell remainders use the
|
||||
* eighth-block glyphs, so one solved problem out of 161 still shows.
|
||||
*/
|
||||
export function bar(value: number, total: number, cells: number, color = GREEN): string {
|
||||
const units = Math.round(Math.max(0, Math.min(1, total > 0 ? value / total : 0)) * cells * 8);
|
||||
const filled = FILL.repeat(Math.floor(units / 8)) + EIGHTHS[units % 8]!;
|
||||
return fg(color, filled) + fg(LINE, TRACK.repeat(Math.max(0, cells - filled.length)));
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
value: number;
|
||||
color: string;
|
||||
/** Defaults to the solid block; pass TRACK-ish glyphs for "not done yet". */
|
||||
glyph?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segments laid end to end, `total` mapped to `cells`. Anything short of
|
||||
* `total` is left BLANK, not tracked: bars sharing a scale must show their
|
||||
* different lengths. A caller wanting a visible remainder passes it as a
|
||||
* segment of its own.
|
||||
*
|
||||
* Non-zero segments are floored but never vanish; the overshoot that floor
|
||||
* creates is taken back from the widest segment, which can spare it.
|
||||
*/
|
||||
export function stackedBar(segments: Segment[], total: number, cells: number): string {
|
||||
if (total <= 0) return " ".repeat(cells);
|
||||
const widths = segments.map((s) =>
|
||||
s.value > 0 ? Math.max(1, Math.floor((s.value / total) * cells)) : 0,
|
||||
);
|
||||
let over = widths.reduce((a, b) => a + b, 0) - cells;
|
||||
while (over > 0) {
|
||||
const i = widths.indexOf(Math.max(...widths));
|
||||
if (widths[i]! <= 1) break;
|
||||
widths[i]!--;
|
||||
over--;
|
||||
}
|
||||
const drawn = widths.map((n, i) => fg(segments[i]!.color, (segments[i]!.glyph ?? FILL).repeat(n)));
|
||||
return drawn.join("") + " ".repeat(Math.max(0, cells - widths.reduce((a, b) => a + b, 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* One cell of the campaign heatmap. With colour it is the GREENS ramp the
|
||||
* README and the digest use; without it, the same five levels as shading.
|
||||
*/
|
||||
export function heatCell(attempts: number, body: string): string {
|
||||
const level = Math.min(attempts, GREENS.length - 1);
|
||||
return COLOR ? bg(GREENS[level]!, body) : [" ", "·", "▒", "▓", "█"][level]!.repeat(body.length);
|
||||
}
|
||||
|
||||
/** One glyph per value, scaled to the largest. All-zero renders as a floor. */
|
||||
export function sparkline(values: number[], color = GREEN): string {
|
||||
const peak = Math.max(...values, 0);
|
||||
const line = values
|
||||
.map((v) => (peak === 0 ? SPARKS[0]! : SPARKS[Math.min(7, Math.round((v / peak) * 7))]!))
|
||||
.join("");
|
||||
return fg(color, line);
|
||||
}
|
||||
|
||||
// ── layout ───────────────────────────────────────────────────────
|
||||
|
||||
/** A section rule that fills the width: `── name ────────────`. */
|
||||
export function rule(title: string, cells: number): string {
|
||||
const label = ` ${title} `;
|
||||
return (
|
||||
fg(LINE, "──") + fg(MUTED, label) + fg(LINE, "─".repeat(Math.max(0, cells - 2 - label.length)))
|
||||
);
|
||||
}
|
||||
|
||||
// ── screen ───────────────────────────────────────────────────────
|
||||
|
||||
const ALT_ON = "\x1b[?1049h\x1b[?25l";
|
||||
const ALT_OFF = "\x1b[?25h\x1b[?1049l";
|
||||
|
||||
export interface Screen {
|
||||
readonly rows: number;
|
||||
readonly columns: number;
|
||||
/** Draw a frame, clipped to the terminal height. */
|
||||
draw(lines: string[]): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take over the terminal: alternate buffer, hidden cursor, raw keys. `onKey`
|
||||
* receives the raw chunk (`"\t"`, `"\x1b[B"`, `"q"`, …) — decoding belongs to
|
||||
* the app, which is the only thing that knows what a key means.
|
||||
*
|
||||
* The caller MUST close() in a finally: raw mode survives a thrown exception.
|
||||
*/
|
||||
export function openScreen(onKey: (chunk: string) => void, onResize: () => void): Screen {
|
||||
const out = process.stdout;
|
||||
const input = process.stdin;
|
||||
|
||||
out.write(ALT_ON);
|
||||
input.setRawMode?.(true);
|
||||
input.setEncoding("utf8");
|
||||
input.resume();
|
||||
input.on("data", onKey);
|
||||
out.on("resize", onResize);
|
||||
|
||||
let closed = false;
|
||||
return {
|
||||
get rows() {
|
||||
return out.rows ?? 24;
|
||||
},
|
||||
get columns() {
|
||||
return out.columns ?? 80;
|
||||
},
|
||||
draw(lines: string[]): void {
|
||||
const visible = lines.slice(0, this.rows);
|
||||
// Home, then erase each line as it is rewritten plus everything below the
|
||||
// last one. No full clear, so a redraw does not flash, and no trailing
|
||||
// newline, which would scroll the alternate screen by a row per frame.
|
||||
out.write(`\x1b[H${visible.map((l) => `${l}\x1b[K`).join("\r\n")}\x1b[J`);
|
||||
},
|
||||
close(): void {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
input.off("data", onKey);
|
||||
out.off("resize", onResize);
|
||||
input.setRawMode?.(false);
|
||||
input.pause();
|
||||
out.write(ALT_OFF);
|
||||
},
|
||||
};
|
||||
}
|
||||
Executable
+171
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Find the NeetCode explanation video for every docs page that has none.
|
||||
*
|
||||
* A finished solution page carries frontmatter `video: <YouTube id>` —
|
||||
* NeetCode's walkthrough of the problem — which the docs MDX `wrapper`
|
||||
* (`apps/docs/components/ProblemBadges.astro`) renders as the red
|
||||
* "Explanation" badge beside the topic badges. `sync.ts` never writes that key
|
||||
* (it owns `icon:`, `topics:` and `## Solution`), so pages accumulate without
|
||||
* one. This script lists every numbered page lacking `video:` and looks each
|
||||
* up on NeetCode's two channels, main channel first, accepting only a title
|
||||
* that names the page's exact LC number (`… - Leetcode 226 - …`) and is not a
|
||||
* #short. No YouTube API key: the channel search page embeds its results as
|
||||
* `ytInitialData` JSON, which is all we parse. Ranking is the channel's own —
|
||||
* the first exact-number match wins.
|
||||
*
|
||||
* bun apps/cli/videos.ts # report: page → video (or "no match")
|
||||
* bun apps/cli/videos.ts --write # also add `video:` to the frontmatter
|
||||
*
|
||||
* --write inserts the key before `sidebar:`. A page with no match is left
|
||||
* untouched, so re-runs are no-ops once every page has its video.
|
||||
*/
|
||||
import { join } from "node:path";
|
||||
|
||||
import { printTable } from "./report.ts";
|
||||
import { ROOT } from "./work.ts";
|
||||
|
||||
const DOCS = join(ROOT, "apps", "docs", "content");
|
||||
const WRITE = process.argv.includes("--write");
|
||||
|
||||
/** Channel handles searched in order; the first exact match wins. */
|
||||
const CHANNELS = ["NeetCode", "NeetCodeIO"] as const;
|
||||
|
||||
interface Video {
|
||||
id: string;
|
||||
title: string;
|
||||
channel: string;
|
||||
}
|
||||
|
||||
// ── YouTube channel search (no API key) ──────────────────────────
|
||||
|
||||
const HEADERS = { "accept-language": "en-US,en;q=0.9", "user-agent": "Mozilla/5.0" };
|
||||
|
||||
/**
|
||||
* The `var ytInitialData = {…};</script>` blob of a YouTube page, parsed.
|
||||
* YouTube intermittently answers 200 with a page that lacks the blob or cuts
|
||||
* it short, so a bad page is retried a few times before it is an error.
|
||||
*/
|
||||
async function initialData(url: string): Promise<unknown> {
|
||||
let reason = "";
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if (attempt) await Bun.sleep(500 * attempt);
|
||||
const res = await fetch(url, { headers: HEADERS });
|
||||
if (!res.ok) {
|
||||
reason = `status ${res.status}`;
|
||||
continue;
|
||||
}
|
||||
const html = await res.text();
|
||||
// Slice rather than regex: the blob is ~1 MB, and a lazy `.*?` across it
|
||||
// is what made the first cut of this script fail at random.
|
||||
const open = html.search(/ytInitialData\s*=\s*\{/);
|
||||
const close = open === -1 ? -1 : html.indexOf("</script>", open);
|
||||
if (close === -1) {
|
||||
reason = `no ytInitialData in ${html.length}-byte page`;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(html.slice(html.indexOf("{", open), close).replace(/;\s*$/, ""));
|
||||
} catch {
|
||||
reason = "truncated ytInitialData";
|
||||
}
|
||||
}
|
||||
throw new Error(`GET ${url} -> ${reason}`);
|
||||
}
|
||||
|
||||
/** Every videoRenderer in a channel search page, in the page's own order. */
|
||||
async function channelSearch(channel: string, query: string): Promise<Video[]> {
|
||||
const url = `https://www.youtube.com/@${channel}/search?query=${encodeURIComponent(query)}`;
|
||||
const data = await initialData(url);
|
||||
|
||||
const videos: Video[] = [];
|
||||
const walk = (node: unknown): void => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if ("videoRenderer" in node) {
|
||||
const v = node.videoRenderer;
|
||||
if (v && typeof v === "object" && "videoId" in v && typeof v.videoId === "string") {
|
||||
const runs =
|
||||
"title" in v && v.title && typeof v.title === "object" && "runs" in v.title
|
||||
? v.title.runs
|
||||
: undefined;
|
||||
const title = Array.isArray(runs)
|
||||
? runs.map((r) => (r && typeof r === "object" && "text" in r ? String(r.text) : "")).join("")
|
||||
: "";
|
||||
videos.push({ id: v.videoId, title, channel });
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const child of Object.values(node)) walk(child);
|
||||
};
|
||||
walk(data);
|
||||
return videos;
|
||||
}
|
||||
|
||||
/** The LC number a NeetCode title names (`… - Leetcode 226 - …`), if any. */
|
||||
function titledNumber(title: string): number | undefined {
|
||||
const m = title.match(/\bleetcode\s*#?\s*(\d+)\b/i);
|
||||
return m ? Number(m[1]) : undefined;
|
||||
}
|
||||
|
||||
async function findVideo(num: number): Promise<Video | undefined> {
|
||||
for (const channel of CHANNELS) {
|
||||
const hit = (await channelSearch(channel, `leetcode ${num}`)).find(
|
||||
(v) => titledNumber(v.title) === num && !/#shorts\b/i.test(v.title),
|
||||
);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── docs pages ───────────────────────────────────────────────────
|
||||
|
||||
interface Page {
|
||||
num: number;
|
||||
rel: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** The page's frontmatter block, `---` to `---`. */
|
||||
const FRONTMATTER = /^---\n[\s\S]*?\n---/;
|
||||
|
||||
/** Numbered solution pages without a `video:` key, by LC number. */
|
||||
async function pagesMissingVideo(): Promise<Page[]> {
|
||||
const pages: Page[] = [];
|
||||
for (const rel of await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))) {
|
||||
const text = await Bun.file(join(DOCS, rel)).text();
|
||||
const t = text.match(/^title: '(\d+)\./m);
|
||||
const fm = text.match(FRONTMATTER)?.[0] ?? "";
|
||||
if (t && !/^video:/m.test(fm)) pages.push({ num: Number(t[1]), rel, text });
|
||||
}
|
||||
return pages.sort((a, b) => a.num - b.num);
|
||||
}
|
||||
|
||||
// ── main ─────────────────────────────────────────────────────────
|
||||
|
||||
const missing = await pagesMissingVideo();
|
||||
if (missing.length === 0) {
|
||||
console.log("every solution page has a video");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const rows: string[][] = [["docs page", "video", "title", WRITE ? "status" : ""]];
|
||||
let found = 0;
|
||||
for (const page of missing) {
|
||||
const video = await findVideo(page.num);
|
||||
if (!video) {
|
||||
rows.push([page.rel, "—", "no NeetCode match", WRITE ? "skipped" : ""]);
|
||||
continue;
|
||||
}
|
||||
found++;
|
||||
let status = "";
|
||||
if (WRITE) {
|
||||
const next = page.text.replace(FRONTMATTER, (fm) => fm.replace(/^sidebar:$/m, `video: ${JSON.stringify(video.id)}\nsidebar:`));
|
||||
await Bun.write(join(DOCS, page.rel), next);
|
||||
status = "written";
|
||||
}
|
||||
rows.push([page.rel, `https://youtu.be/${video.id}`, `${video.title} (${video.channel})`, status]);
|
||||
}
|
||||
|
||||
if (!WRITE) for (const row of rows) row.pop();
|
||||
printTable(rows);
|
||||
console.log(`\n${found}/${missing.length} page(s) matched${WRITE ? `, ${found} written` : ""}`);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* The Appendix glossary: one term per LeetCode topic tag that problem pages
|
||||
* carry but no campaign topic teaches (not a key of `topic-landings.ts`).
|
||||
* `pages/appendix.astro` renders each as a card — icon, category, definition,
|
||||
* and the solved problems carrying the tag — anchored at `/appendix#<slug>`,
|
||||
* which is where `tagTarget()` sends that tag's badge on a problem page.
|
||||
*
|
||||
* Hand-written: `apps/cli/sync.ts` only checks it, warning about a carried
|
||||
* tag with no entry (its card renders bare), an entry whose tag a campaign
|
||||
* topic now teaches (dead weight, delete it), and an icon that is a topic's
|
||||
* (`TOPIC_ICONS`) or another term's — the icon is also the one on every
|
||||
* badge linking here, so it must not read as anything else. Plain data, no
|
||||
* Astro imports: sync imports this file under Bun.
|
||||
*/
|
||||
|
||||
export type AppendixCategory = "Data Structures" | "Algorithms" | "Techniques";
|
||||
|
||||
export interface AppendixTerm {
|
||||
/** LeetCode's tag name. */
|
||||
name: string;
|
||||
/** Lucide icon, unique across terms and topics. */
|
||||
icon: string;
|
||||
category: AppendixCategory;
|
||||
/** One sentence: what the term means, the way a solution uses it. */
|
||||
definition: string;
|
||||
}
|
||||
|
||||
export const APPENDIX: Record<string, AppendixTerm> = {
|
||||
// ── data structures ──────────────────────────────────────────────
|
||||
array: {
|
||||
name: "Array",
|
||||
icon: "list-ordered",
|
||||
category: "Data Structures",
|
||||
definition: "A contiguous, index-addressed sequence: O(1) access by position, O(n) to insert or delete in the middle.",
|
||||
},
|
||||
"binary-tree": {
|
||||
name: "Binary Tree",
|
||||
icon: "git-branch",
|
||||
category: "Data Structures",
|
||||
definition: "A rooted tree in which every node has at most two children, left and right.",
|
||||
},
|
||||
"directed-acyclic-graph": {
|
||||
name: "Directed Acyclic Graph",
|
||||
icon: "route",
|
||||
category: "Data Structures",
|
||||
definition: "A directed graph with no cycles, which is exactly what lets its nodes be put in a topological order.",
|
||||
},
|
||||
"doubly-linked-list": {
|
||||
name: "Doubly-Linked List",
|
||||
icon: "link-2",
|
||||
category: "Data Structures",
|
||||
definition: "A linked list whose nodes point to both neighbours, so a node you already hold is unlinked in O(1).",
|
||||
},
|
||||
graph: {
|
||||
name: "Graph Theory",
|
||||
icon: "share-2",
|
||||
category: "Data Structures",
|
||||
definition: "Nodes joined by edges, directed or not, weighted or not, stored as an adjacency list or matrix.",
|
||||
},
|
||||
"k-d-tree": {
|
||||
name: "K-D Tree",
|
||||
icon: "axis-3d",
|
||||
category: "Data Structures",
|
||||
definition: "A binary tree that splits k-dimensional points on alternating axes, for nearest-neighbour and range queries.",
|
||||
},
|
||||
queue: {
|
||||
name: "Queue",
|
||||
icon: "list-end",
|
||||
category: "Data Structures",
|
||||
definition: "First in, first out: push at the back, pop from the front, both O(1).",
|
||||
},
|
||||
string: {
|
||||
name: "String",
|
||||
icon: "quote",
|
||||
category: "Data Structures",
|
||||
definition: "A sequence of characters; solutions lean on its small alphabet, its substrings, and its immutability.",
|
||||
},
|
||||
tree: {
|
||||
name: "Tree",
|
||||
icon: "tree-deciduous",
|
||||
category: "Data Structures",
|
||||
definition: "A connected acyclic graph, usually rooted, in which every node but the root has exactly one parent.",
|
||||
},
|
||||
|
||||
// ── algorithms ───────────────────────────────────────────────────
|
||||
"bidirectional-search": {
|
||||
name: "Bidirectional Search",
|
||||
icon: "move-horizontal",
|
||||
category: "Algorithms",
|
||||
definition: "Search from the start and the goal at once and stop where the frontiers meet, about 2·b^(d/2) states instead of b^d.",
|
||||
},
|
||||
"binary-lifting": {
|
||||
name: "Binary Lifting",
|
||||
icon: "chevrons-up",
|
||||
category: "Algorithms",
|
||||
definition: "Precompute every node's 2^k-th ancestor so any k-th ancestor, and so the LCA, takes O(log n) jumps.",
|
||||
},
|
||||
"bit-manipulation": {
|
||||
name: "Bit Manipulation",
|
||||
icon: "binary",
|
||||
category: "Algorithms",
|
||||
definition: "Treat integers as bit vectors: masks, shifts, and XOR in place of arithmetic or extra memory.",
|
||||
},
|
||||
"bracket-sequences": {
|
||||
name: "Bracket Sequences",
|
||||
icon: "parentheses",
|
||||
category: "Algorithms",
|
||||
definition: "Strings of opening and closing brackets; balanced means the running depth never dips below zero and ends at zero.",
|
||||
},
|
||||
"bucket-sort": {
|
||||
name: "Bucket Sort",
|
||||
icon: "container",
|
||||
category: "Algorithms",
|
||||
definition: "Scatter values into buckets by range or frequency, then read the buckets in order: linear when the key range is bounded.",
|
||||
},
|
||||
"complete-knapsack": {
|
||||
name: "Complete Knapsack",
|
||||
icon: "package-plus",
|
||||
category: "Algorithms",
|
||||
definition: "Knapsack in which every item may be taken any number of times, so the DP walks capacity upward and reuses the row.",
|
||||
},
|
||||
"divide-and-conquer": {
|
||||
name: "Divide and Conquer",
|
||||
icon: "split",
|
||||
category: "Algorithms",
|
||||
definition: "Split the input into independent parts, solve each recursively, and combine their answers.",
|
||||
},
|
||||
"floyds-cycle-finding-algorithm": {
|
||||
name: "Floyd's Cycle Finding Algorithm",
|
||||
icon: "repeat",
|
||||
category: "Algorithms",
|
||||
definition: "A slow and a fast pointer meet inside any cycle; restarting one from the head finds the cycle's entry, in O(1) space.",
|
||||
},
|
||||
"knapsack-problem": {
|
||||
name: "Knapsack Problem",
|
||||
icon: "backpack",
|
||||
category: "Algorithms",
|
||||
definition: "Pick items with weights and values to maximise value under a capacity; the 0/1 version is an O(n·W) DP.",
|
||||
},
|
||||
"longest-common-subsequence": {
|
||||
name: "Longest Common Subsequence",
|
||||
icon: "diff",
|
||||
category: "Algorithms",
|
||||
definition: "The longest sequence that appears in order, not necessarily contiguously, in two strings: the classic O(n·m) 2-D DP.",
|
||||
},
|
||||
"lowest-common-ancestor": {
|
||||
name: "Lowest Common Ancestor",
|
||||
icon: "git-merge",
|
||||
category: "Algorithms",
|
||||
definition: "The deepest node of a rooted tree that is an ancestor of both given nodes.",
|
||||
},
|
||||
memoization: {
|
||||
name: "Memoization",
|
||||
icon: "database",
|
||||
category: "Algorithms",
|
||||
definition: "Cache a pure function's result per argument so every subproblem is solved once: top-down dynamic programming.",
|
||||
},
|
||||
"merge-sort": {
|
||||
name: "Merge Sort",
|
||||
icon: "blend",
|
||||
category: "Algorithms",
|
||||
definition: "Sort each half recursively, then merge the two sorted halves: O(n log n), stable, O(n) extra space.",
|
||||
},
|
||||
quickselect: {
|
||||
name: "Quickselect",
|
||||
icon: "crosshair",
|
||||
category: "Algorithms",
|
||||
definition: "Partition around a pivot and recurse into one side only, finding the k-th smallest element in O(n) on average.",
|
||||
},
|
||||
quicksort: {
|
||||
name: "Quicksort",
|
||||
icon: "shuffle",
|
||||
category: "Algorithms",
|
||||
definition: "Partition around a pivot, then sort each side in place: O(n log n) on average, O(n²) at worst.",
|
||||
},
|
||||
recursion: {
|
||||
name: "Recursion",
|
||||
icon: "iteration-ccw",
|
||||
category: "Algorithms",
|
||||
definition: "Solve a problem by calling the same function on smaller inputs until a base case answers directly.",
|
||||
},
|
||||
simulation: {
|
||||
name: "Simulation",
|
||||
icon: "monitor-play",
|
||||
category: "Algorithms",
|
||||
definition: "Carry out the process the statement describes, step by step; the work is modelling the state faithfully.",
|
||||
},
|
||||
sorting: {
|
||||
name: "Sorting",
|
||||
icon: "arrow-down-wide-narrow",
|
||||
category: "Algorithms",
|
||||
definition: "Order the input first so a later step, such as two pointers, a greedy choice, or binary search, becomes possible.",
|
||||
},
|
||||
"tournament-sort": {
|
||||
name: "Tournament Sort",
|
||||
icon: "trophy",
|
||||
category: "Algorithms",
|
||||
definition: "A winner tree of pairwise comparisons: the root is the extreme element, and replaying one path yields the next in O(log n).",
|
||||
},
|
||||
|
||||
// ── techniques ───────────────────────────────────────────────────
|
||||
combinatorics: {
|
||||
name: "Combinatorics",
|
||||
icon: "dices",
|
||||
category: "Techniques",
|
||||
definition: "Count arrangements and selections (permutations, combinations, Catalan numbers), usually modulo a prime.",
|
||||
},
|
||||
counting: {
|
||||
name: "Counting",
|
||||
icon: "tally-5",
|
||||
category: "Techniques",
|
||||
definition: "Tally occurrences in a hash map or fixed array, then answer from the counts instead of the raw input.",
|
||||
},
|
||||
design: {
|
||||
name: "Design",
|
||||
icon: "pencil-ruler",
|
||||
category: "Techniques",
|
||||
definition: "Implement a class to an API spec, choosing the internals so every operation meets its time bound.",
|
||||
},
|
||||
geometry: {
|
||||
name: "Geometry",
|
||||
icon: "shapes",
|
||||
category: "Techniques",
|
||||
definition: "Points, lines, and areas on a plane or grid: distances, slopes, overlaps, and orientation by cross product.",
|
||||
},
|
||||
math: {
|
||||
name: "Math",
|
||||
icon: "calculator",
|
||||
category: "Techniques",
|
||||
definition: "An arithmetic or number-theory insight (modular arithmetic, GCD, digit manipulation, a closed form) in place of brute force.",
|
||||
},
|
||||
};
|
||||
+156
-9
@@ -1,21 +1,168 @@
|
||||
import { defineConfig } from "blume";
|
||||
import type { StandardSchema } from "blume/core/standard-schema.ts";
|
||||
import { filesystem } from "blume/sources";
|
||||
import { APPENDIX } from "./appendix";
|
||||
import { RECENCY, type Company } from "./companies";
|
||||
import redirects from "./redirects";
|
||||
|
||||
// Blume's defaults (`inter`, `ibm-plex-mono`) are fetched from fonts.gstatic.com
|
||||
// on every build, which has timed out on the Pages runner and, fully offline,
|
||||
// silently ships no fonts at all. The same two families from Fontsource, as
|
||||
// local files, keep the build network-free. Blume resolves `src` from this
|
||||
// directory; the workspace links these packages under ./node_modules.
|
||||
const inter = {
|
||||
name: "Inter",
|
||||
variants: [
|
||||
{
|
||||
src: "./node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2",
|
||||
weight: "100..900",
|
||||
},
|
||||
],
|
||||
};
|
||||
const plexMono = {
|
||||
name: "IBM Plex Mono",
|
||||
variants: [
|
||||
{
|
||||
src: "./node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-400-normal.woff2",
|
||||
weight: 400,
|
||||
},
|
||||
{
|
||||
src: "./node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
|
||||
weight: 500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom frontmatter, rendered between the description and the body by
|
||||
* components/ProblemBadges.astro. Problem pages: `topics:` is LeetCode's topic
|
||||
* tags, `issue:` the problem's GitHub issue URL and `companies:` the companies
|
||||
* that asked it in the last six months (`companies.ts`; the table under the
|
||||
* badge row) — all three written by apps/cli/sync.ts — and `video:` the
|
||||
* NeetCode walkthrough's YouTube id (apps/cli/videos.ts). Blume rejects
|
||||
* undeclared keys and takes any Standard Schema; these are hand-rolled so the
|
||||
* docs workspace needs no zod. All optional — every other page carries none.
|
||||
*/
|
||||
const optional = <T>(
|
||||
message: string,
|
||||
valid: (value: unknown) => value is T,
|
||||
): StandardSchema<unknown, T | undefined> => ({
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "prdlk",
|
||||
validate: (value) =>
|
||||
value === undefined || valid(value)
|
||||
? { value }
|
||||
: { issues: [{ message }] },
|
||||
},
|
||||
});
|
||||
|
||||
const topicsSchema = optional(
|
||||
"expected a list of { name, slug } LeetCode topic tags",
|
||||
(value): value is { name: string; slug: string }[] =>
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(t) => typeof t?.name === "string" && typeof t?.slug === "string",
|
||||
),
|
||||
);
|
||||
const videoSchema = optional(
|
||||
"expected an 11-character YouTube video id",
|
||||
(value): value is string =>
|
||||
typeof value === "string" && /^[\w-]{11}$/.test(value),
|
||||
);
|
||||
const issueSchema = optional(
|
||||
"expected a https://github.com/<owner>/<repo>/issues/<n> URL",
|
||||
(value): value is string =>
|
||||
typeof value === "string" &&
|
||||
/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+$/.test(value),
|
||||
);
|
||||
const companiesSchema = optional(
|
||||
`expected a list of { name, period, frequency } with period one of ${RECENCY.join(" | ")}`,
|
||||
(value): value is Company[] =>
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(c) =>
|
||||
typeof c?.name === "string" &&
|
||||
RECENCY.includes(c?.period) &&
|
||||
typeof c?.frequency === "number",
|
||||
),
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
title: "Leetcode",
|
||||
description: "Documentation for LeetCode solutions",
|
||||
title: "prdlk",
|
||||
description: "Documentation for my LeetCode solutions",
|
||||
logo: "./public/logo.svg",
|
||||
deployment: {
|
||||
site: "https://prdlk.github.io",
|
||||
base: "/leetcode",
|
||||
site: "https://lc.prd.lk",
|
||||
},
|
||||
github: {
|
||||
owner: "prdlk",
|
||||
repo: "leetcode",
|
||||
},
|
||||
lastModified: "git",
|
||||
// Sync's `/<num>` short links, then the Appendix's former per-term pages
|
||||
// (`/appendix/<slug>`, published until the glossary became one page) onto
|
||||
// their cards, so old links and search results still land.
|
||||
redirects: [
|
||||
...redirects,
|
||||
...Object.keys(APPENDIX).map((slug) => ({
|
||||
from: `/appendix/${slug}`,
|
||||
to: `/appendix#${slug}`,
|
||||
})),
|
||||
],
|
||||
frontmatter: {
|
||||
extend: {
|
||||
topics: topicsSchema,
|
||||
video: videoSchema,
|
||||
issue: issueSchema,
|
||||
companies: companiesSchema,
|
||||
},
|
||||
},
|
||||
seo: {
|
||||
og: { enabled: true },
|
||||
// No `rss`: the changelog is one custom page (pages/changelog.astro), not
|
||||
// `type: changelog` entries, so a changelog feed would have nothing in it.
|
||||
sitemap: true,
|
||||
robots: true,
|
||||
structuredData: true,
|
||||
x: { handle: "@prdlk" },
|
||||
},
|
||||
// Blume's own assistant route is server-rendered (it reads the provider key
|
||||
// per request), which a static Pages build cannot ship. The SRS Worker
|
||||
// answers instead (apps/api/src/ask.ts: OpenRouter, grounded on this site's
|
||||
// llms-full.txt, which `agents.llmsTxt` publishes).
|
||||
ai: {
|
||||
assistant: {
|
||||
enabled: true,
|
||||
endpoint: "https://srs-api.prdlk.workers.dev/ask",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
llmsTxt: true,
|
||||
mcp: { enabled: false },
|
||||
},
|
||||
// The only content source. `/changelog`, `/appendix` and `/progress` are
|
||||
// custom pages (pages/*.astro): `/changelog` reads the GitHub releases
|
||||
// itself — one timeline, no per-release pages — `/appendix` is one glossary
|
||||
// page built from appendix.ts, and `/progress` is the live stats cards, so
|
||||
// none enters the sidebar tree or prev/next.
|
||||
content: {
|
||||
root: "content",
|
||||
sources: [filesystem({ root: "content" })],
|
||||
},
|
||||
theme: {
|
||||
fonts: { display: inter, body: inter, mono: plexMono },
|
||||
},
|
||||
navigation: {
|
||||
tabs: [
|
||||
{
|
||||
label: "Reference",
|
||||
path: "/reference",
|
||||
repo: "https://github.com/prdlk/leetcode",
|
||||
sidebar: {
|
||||
display: "flat", // "flat" | "group" | "page"
|
||||
},
|
||||
// The custom pages (pages/*.astro) as header tabs. None is a content
|
||||
// folder, so the tabs scope nothing out of the sidebar tree.
|
||||
tabs: [
|
||||
{ label: "Changelog", path: "/changelog" },
|
||||
{ label: "Progress", path: "/progress" },
|
||||
{ label: "Appendix", path: "/appendix" },
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* The `companies:` frontmatter vocabulary, shared by its writer
|
||||
* (`apps/cli/sync.ts`, from the local DuckDB company-frequency database), its
|
||||
* schema (`blume.config.ts`) and its two readers: the company table under a
|
||||
* problem page's badge row (`components/ProblemBadges.astro`) and the
|
||||
* Companies section of the Appendix (`pages/appendix.astro`), where each
|
||||
* company's card — anchored at `companyHref()` — lists the problems it asked.
|
||||
* Plain data, no Astro imports: sync imports this file under Bun.
|
||||
*/
|
||||
|
||||
/** The database's recency windows, most recent first; they nest, so a company is filed under the first it appears in. */
|
||||
export const RECENCY = ["Thirty Days", "Three Months", "Six Months"] as const;
|
||||
export type Recency = (typeof RECENCY)[number];
|
||||
|
||||
/** Read under an "Asked within" heading (the company table) or as a row chip (the Appendix card). */
|
||||
export const RECENCY_LABEL: Record<Recency, string> = {
|
||||
"Thirty Days": "30 days",
|
||||
"Three Months": "3 months",
|
||||
"Six Months": "6 months",
|
||||
};
|
||||
|
||||
export interface Company {
|
||||
name: string;
|
||||
/** The most recent window the company asked the problem in. */
|
||||
period: Recency;
|
||||
/** 5–100, relative within the company and window: 100 is its most-asked problem there. */
|
||||
frequency: number;
|
||||
}
|
||||
|
||||
/** The company's card on the Appendix; `company-` keeps it clear of the tag cards' `#<slug>` ids. */
|
||||
export function companyHref(name: string): string {
|
||||
return `/appendix#company-${name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")}`;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "theme.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Project MDX components, available on every page with no import, and layout
|
||||
* slot overrides. Static `.astro` files ship no framework JS (a custom element
|
||||
* carries any behaviour); interactive React lives in `islands/` instead.
|
||||
*/
|
||||
import { defineComponents } from "blume";
|
||||
|
||||
export default defineComponents({
|
||||
mdx: {
|
||||
Carousel: "./components/Carousel.astro",
|
||||
CarouselImage: "./components/CarouselImage.astro",
|
||||
// MDX's own content wrapper: problem pages' topic + explanation badges, from
|
||||
// `topics:` / `video:` frontmatter, between the description and the body.
|
||||
wrapper: "./components/ProblemBadges.astro",
|
||||
},
|
||||
layout: {
|
||||
// Topic landing pages' "what links here": every problem pointing at the page.
|
||||
PageFooter: "./components/BacklinksList.astro",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
/**
|
||||
* The `PageFooter` slot: an Obsidian-style backlinks list under a topic landing
|
||||
* page — every problem page that points at it, as `<num> <name>` rows in
|
||||
* LeetCode-number order. Pages nothing points at (problem pages, the
|
||||
* overview) render nothing; a tag no topic teaches lists its problems on its
|
||||
* Appendix card instead (`pages/appendix.astro`), from the same answer.
|
||||
*
|
||||
* A problem points at a page when either
|
||||
* - one of its topic badges links there — the same `tagTarget()` answer
|
||||
* `ProblemBadges.astro` renders, so this list and the badges cannot drift
|
||||
* (a DFS problem is a backlink of the one DFS landing its badge picks); or
|
||||
* - it lives under the page's route (`/hybrid-structures/lru-cache` under
|
||||
* `/hybrid-structures`), so a topic no LeetCode tag names still lists its
|
||||
* own problems.
|
||||
*
|
||||
* Problem pages are recognised by their `<num>. <name>` title, the shape
|
||||
* `apps/cli/sync.ts` writes.
|
||||
*
|
||||
* The list is a native `<details>`, open on load: the chevron at the top
|
||||
* right of the header row collapses it like an accordion, with no JS.
|
||||
*/
|
||||
import { getEntry } from "astro:content";
|
||||
import data from "blume:data";
|
||||
import { withBase } from "blume/components/islands/base-path.ts";
|
||||
import Icon from "blume/components/Icon.astro";
|
||||
import { tagTarget, type TopicTag } from "../topic-landings.ts";
|
||||
|
||||
interface Props {
|
||||
route: string;
|
||||
}
|
||||
|
||||
interface Route {
|
||||
path: string;
|
||||
title: string;
|
||||
collection: string;
|
||||
entryId: string;
|
||||
}
|
||||
|
||||
const { route } = Astro.props;
|
||||
const routes: Route[] = data.routes;
|
||||
const served = new Set(routes.map((r) => r.path));
|
||||
|
||||
const problems = routes.flatMap((r) => {
|
||||
const m = r.title.match(/^(\d+)\.\s+(.+)$/);
|
||||
return m ? [{ route: r, num: Number(m[1]), name: m[2]! }] : [];
|
||||
});
|
||||
|
||||
// A problem page is never a backlink target: skip the per-problem lookups there.
|
||||
const backlinks = problems.some((p) => p.route.path === route)
|
||||
? []
|
||||
: (
|
||||
await Promise.all(
|
||||
problems.map(async (p) => {
|
||||
if (p.route.path.startsWith(`${route}/`)) return p;
|
||||
const entry = await getEntry(p.route.collection, p.route.entryId);
|
||||
const tags: TopicTag[] = entry?.data.topics ?? [];
|
||||
const page = { route: p.route.path, tags };
|
||||
return tags.some((tag) => tagTarget(tag, page, served).href === route) ? p : undefined;
|
||||
}),
|
||||
)
|
||||
)
|
||||
.filter((p) => p !== undefined)
|
||||
.sort((a, b) => a.num - b.num);
|
||||
---
|
||||
|
||||
{
|
||||
backlinks.length > 0 && (
|
||||
<details
|
||||
class="group not-prose mx-auto mt-12 max-w-content border-border border-t pt-6"
|
||||
open
|
||||
>
|
||||
<summary class="mb-2 flex cursor-pointer list-none items-center justify-between gap-2 text-muted-foreground transition-colors hover:text-foreground [&::-webkit-details-marker]:hidden">
|
||||
<h2 class="flex items-baseline gap-2 font-medium text-xs uppercase tracking-wide" id="backlinks">
|
||||
Backlinks
|
||||
<span class="tabular-nums">{backlinks.length}</span>
|
||||
</h2>
|
||||
<Icon class="shrink-0 transition-transform group-open:rotate-180" name="chevron-down" size={16} />
|
||||
</summary>
|
||||
<ul class="text-sm">
|
||||
{backlinks.map((p) => (
|
||||
<li>
|
||||
<a
|
||||
class="-mx-2 flex items-baseline gap-3 rounded-md px-2 py-1 text-foreground no-underline transition-colors hover:bg-muted"
|
||||
href={withBase(p.route.path)}
|
||||
>
|
||||
<span class="w-10 shrink-0 text-right font-mono text-muted-foreground text-xs tabular-nums">{p.num}</span>
|
||||
<span class="truncate">{p.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
/**
|
||||
* A horizontal carousel of <CarouselImage> slides, built the way Blume's own
|
||||
* content components are: static markup, a custom element for the behaviour,
|
||||
* no framework. The track is a CSS scroll-snap strip (so it also swipes on
|
||||
* touch and the browser does the animation); prev/next sit outside it and
|
||||
* the play toggle sits below with a "n / N" counter.
|
||||
*
|
||||
* `time` is seconds per slide while playing (default 1.25); `autoplay` starts
|
||||
* playing on load. Play wraps around, pauses whenever the tab is hidden, and
|
||||
* never starts on its own for a reader who prefers reduced motion — the
|
||||
* button still works for them. Manual navigation (buttons, arrow keys, swipe)
|
||||
* does not stop playback; it only restarts the interval so a step is never
|
||||
* doubled.
|
||||
*/
|
||||
import Icon from "blume/components/Icon.astro";
|
||||
|
||||
interface Props {
|
||||
/** Seconds each slide stays while playing. */
|
||||
time?: number;
|
||||
autoplay?: boolean;
|
||||
}
|
||||
|
||||
const { time = 1.25, autoplay = false } = Astro.props;
|
||||
|
||||
const buttonClass =
|
||||
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors hover:border-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40";
|
||||
---
|
||||
|
||||
<blume-carousel
|
||||
class="not-prose my-6 block"
|
||||
data-time={String(time)}
|
||||
data-autoplay={autoplay ? "" : undefined}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class={buttonClass} data-prev type="button" aria-label="Previous slide">
|
||||
<Icon class="rtl:-scale-x-100" name="chevron-left" size={18} />
|
||||
</button>
|
||||
<div
|
||||
class="flex min-w-0 flex-1 snap-x snap-mandatory overflow-x-auto scroll-smooth rounded-blume [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
data-track
|
||||
tabindex="0"
|
||||
aria-live="polite"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<button class={buttonClass} data-next type="button" aria-label="Next slide">
|
||||
<Icon class="rtl:-scale-x-100" name="chevron-right" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-center gap-3">
|
||||
<button class={buttonClass} data-play type="button" aria-label="Play" aria-pressed="false">
|
||||
<Icon class="[[aria-pressed=true]_&]:hidden" name="play" size={16} />
|
||||
<Icon class="[[aria-pressed=false]_&]:hidden" name="pause" size={16} />
|
||||
</button>
|
||||
<span class="text-muted-foreground text-xs tabular-nums leading-5" data-count></span>
|
||||
</div>
|
||||
</blume-carousel>
|
||||
|
||||
<script>
|
||||
class BlumeCarousel extends HTMLElement {
|
||||
#track!: HTMLElement;
|
||||
#play!: HTMLButtonElement;
|
||||
#count!: HTMLElement;
|
||||
#timer: number | null = null;
|
||||
#index = 0;
|
||||
|
||||
connectedCallback() {
|
||||
this.#track = this.querySelector("[data-track]")!;
|
||||
this.#play = this.querySelector("[data-play]")!;
|
||||
this.#count = this.querySelector("[data-count]")!;
|
||||
const prev = this.querySelector<HTMLButtonElement>("[data-prev]")!;
|
||||
const next = this.querySelector<HTMLButtonElement>("[data-next]")!;
|
||||
|
||||
const slides = this.#slides().length;
|
||||
if (slides < 2) {
|
||||
for (const b of [prev, next, this.#play]) b.disabled = true;
|
||||
}
|
||||
this.#render();
|
||||
|
||||
prev.addEventListener("click", () => this.#step(-1));
|
||||
next.addEventListener("click", () => this.#step(1));
|
||||
this.#play.addEventListener("click", () => (this.#timer === null ? this.#start() : this.#stop()));
|
||||
this.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowLeft") this.#step(-1);
|
||||
else if (event.key === "ArrowRight") this.#step(1);
|
||||
else return;
|
||||
event.preventDefault();
|
||||
});
|
||||
// Swipes and trackpad scrolls move the track without #step; follow them
|
||||
// once the scroll settles. Settle-only, not per event: a long smooth
|
||||
// scroll from #step passes over intermediate slides and must not be
|
||||
// read back as the reader's position mid-flight.
|
||||
let settle: number | undefined;
|
||||
this.#track.addEventListener(
|
||||
"scroll",
|
||||
() => {
|
||||
window.clearTimeout(settle);
|
||||
settle = window.setTimeout(() => this.#syncFromScroll(), 150);
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
document.addEventListener("visibilitychange", this.#onVisibility);
|
||||
|
||||
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (this.hasAttribute("data-autoplay") && slides > 1 && !reduced) this.#start();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.#stop();
|
||||
document.removeEventListener("visibilitychange", this.#onVisibility);
|
||||
}
|
||||
|
||||
#slides(): HTMLElement[] {
|
||||
return [...this.#track.querySelectorAll<HTMLElement>(":scope > blume-carousel-image")];
|
||||
}
|
||||
|
||||
#step(delta: number) {
|
||||
const slides = this.#slides();
|
||||
this.#index = (this.#index + delta + slides.length) % slides.length;
|
||||
slides[this.#index]!.scrollIntoView({ block: "nearest", inline: "start", behavior: "smooth" });
|
||||
this.#render();
|
||||
if (this.#timer !== null) this.#start(); // restart the interval from this slide
|
||||
}
|
||||
|
||||
#syncFromScroll() {
|
||||
const at = Math.round(this.#track.scrollLeft / this.#track.clientWidth);
|
||||
if (at !== this.#index && Number.isFinite(at)) {
|
||||
this.#index = Math.min(Math.max(at, 0), this.#slides().length - 1);
|
||||
this.#render();
|
||||
}
|
||||
}
|
||||
|
||||
#start() {
|
||||
this.#stop();
|
||||
const ms = Number(this.dataset.time) * 1000;
|
||||
this.#timer = window.setInterval(() => this.#step(1), ms > 0 ? ms : 1250);
|
||||
this.#play.setAttribute("aria-pressed", "true");
|
||||
this.#play.setAttribute("aria-label", "Pause");
|
||||
}
|
||||
|
||||
#stop() {
|
||||
if (this.#timer !== null) window.clearInterval(this.#timer);
|
||||
this.#timer = null;
|
||||
this.#play.setAttribute("aria-pressed", "false");
|
||||
this.#play.setAttribute("aria-label", "Play");
|
||||
}
|
||||
|
||||
// A background tab keeps its timers but not its layout; resume on return.
|
||||
#wasPlaying = false;
|
||||
#onVisibility = () => {
|
||||
if (document.hidden) {
|
||||
this.#wasPlaying = this.#timer !== null;
|
||||
this.#stop();
|
||||
} else if (this.#wasPlaying) this.#start();
|
||||
};
|
||||
|
||||
#render() {
|
||||
const slides = this.#slides();
|
||||
this.#count.textContent = `${this.#index + 1} / ${slides.length}`;
|
||||
slides.forEach((s, i) => s.setAttribute("aria-hidden", i === this.#index ? "false" : "true"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("blume-carousel")) {
|
||||
customElements.define("blume-carousel", BlumeCarousel);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
/**
|
||||
* One slide of a <Carousel>: Blume's own <Frame> around an <img>, so a slide
|
||||
* is pixel-identical to a framed image elsewhere on the site — `alt` doubles
|
||||
* as the frame's hint line and `caption` is Markdown, exactly as in Frame.
|
||||
*
|
||||
* `src` accepts what MDX authors already write for images: a URL, a
|
||||
* root-relative `/file.png` (served from public/, prefixed with the deploy
|
||||
* base), or the page-relative `../../public/file.png` form. A relative path
|
||||
* that does not pass through public/ has nothing to serve it, so it is a
|
||||
* build error rather than a silent 404.
|
||||
*/
|
||||
import Frame from "blume/components/content/Frame.astro";
|
||||
import { withBase } from "blume/components/islands/base-path.ts";
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
const { src, alt, caption } = Astro.props;
|
||||
|
||||
const resolveSrc = (value: string): string => {
|
||||
if (URL.canParse(value)) return value;
|
||||
if (value.startsWith("/")) return withBase(value);
|
||||
const at = value.indexOf("public/");
|
||||
if (at === -1) {
|
||||
throw new Error(
|
||||
`<CarouselImage src="${value}">: a relative src must point into public/ (or use /file.png)`
|
||||
);
|
||||
}
|
||||
return withBase(`/${value.slice(at + "public/".length)}`);
|
||||
};
|
||||
---
|
||||
|
||||
<blume-carousel-image
|
||||
class="block w-full shrink-0 snap-center [&_blume-frame]:my-0"
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
>
|
||||
<Frame caption={caption} hint={alt}>
|
||||
<img alt={alt ?? ""} src={resolveSrc(src)} loading="lazy" draggable="false" />
|
||||
</Frame>
|
||||
</blume-carousel-image>
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
/**
|
||||
* The MDX `wrapper`: the badge row (and, below it, the company table) between
|
||||
* a page's description and its body.
|
||||
* Blume's page template renders the title and description itself and then
|
||||
* the MDX content, with no slot in between, so wrapping the content is the
|
||||
* only way to land right after the description. MDX hands a wrapper no
|
||||
* route, so it is read off the URL, and the page's frontmatter is then
|
||||
* looked up through the route manifest — the same `getEntry` call Blume's own
|
||||
* page template makes. Pages with none of the keys below (landing pages, the
|
||||
* changelog) render only their content.
|
||||
*
|
||||
* - `topics:` (problem pages, written by `apps/cli/sync.ts`) is LeetCode's own
|
||||
* topic tags. A tag the campaign teaches (`topic-landings.ts`) links to that
|
||||
* topic's landing page and is filled; any other tag links to its card on the
|
||||
* Appendix glossary, `/appendix#<slug>` (`appendix.ts`), and is outlined.
|
||||
* `tagTarget()` picks the route (and between two landings, e.g. DFS); the
|
||||
* `PageFooter` backlinks list and the Appendix page read the same answer.
|
||||
* Each tag badge is a pill carrying the icon its target shows (a topic's
|
||||
* `meta.ts` icon in the sidebar, a term's `icon` on its card) and the tag's
|
||||
* own colour (`lib/tag-colors.ts`), assigned over every tag the site has a
|
||||
* page or card for — no two tags share one, and tags that share problem
|
||||
* pages are spread furthest apart. A tag with neither (the LeetCode-list
|
||||
* fallback) stays gray and iconless.
|
||||
* - `video:` (problem pages, written by `apps/cli/videos.ts`) is the NeetCode
|
||||
* walkthrough's YouTube id, the red "Explanation" badge.
|
||||
* - `issue:` (problem pages, written by sync) is the problem's GitHub issue
|
||||
* URL, a neutral "Issue #N" badge with the GitHub mark, right after
|
||||
* "Explanation" at the end of the row.
|
||||
* - `companies:` (problem pages, written by sync; `companies.ts`) is the
|
||||
* companies that asked the problem in the last six months, a table laid
|
||||
* out like Blume's TypeTable under the badge row: each company a pill
|
||||
* linking to its card in the Appendix's Companies section, the most recent
|
||||
* window it asked in, and its frequency there (relative within that
|
||||
* company and window, so a bar of 100 is its most-asked problem).
|
||||
*/
|
||||
import { getEntry } from "astro:content";
|
||||
import data from "blume:data";
|
||||
import Badge from "blume/components/content/Badge.astro";
|
||||
import { stripBase, withBase } from "blume/components/islands/base-path.ts";
|
||||
import { APPENDIX } from "../appendix.ts";
|
||||
import { RECENCY_LABEL, companyHref, type Company } from "../companies.ts";
|
||||
import { tagColors } from "../lib/tag-colors.ts";
|
||||
import { TOPIC_LANDINGS, tagTarget, type TopicTag } from "../topic-landings.ts";
|
||||
|
||||
// `/sub/tree-dfs/invert-binary-tree/` (or `.../index.html` in a static build)
|
||||
// -> `/tree-dfs/invert-binary-tree`, the base-less shape of `data.routes`.
|
||||
const route =
|
||||
stripBase(import.meta.env.BASE_URL ?? "/", Astro.url.pathname)
|
||||
.replace(/(\/index)?\.html$/u, "")
|
||||
.replace(/(.)\/+$/u, "$1");
|
||||
const at = data.routes.find((r: { path: string }) => r.path === route);
|
||||
const entry = at ? await getEntry(at.collection, at.entryId) : undefined;
|
||||
const tags: TopicTag[] = entry?.data.topics ?? [];
|
||||
const video: string | undefined = entry?.data.video;
|
||||
const issue: string | undefined = entry?.data.issue;
|
||||
const companies: Company[] = entry?.data.companies ?? [];
|
||||
|
||||
const served = new Set<string>(data.routes.map((r: { path: string }) => r.path));
|
||||
|
||||
// route → the icon its sidebar row shows: a page's own, or a topic group's
|
||||
// (from its `meta.ts`) for the landing page the group row links to.
|
||||
interface NavNode {
|
||||
route?: string;
|
||||
icon?: string;
|
||||
children?: NavNode[];
|
||||
}
|
||||
const icons = new Map<string, string>();
|
||||
const walk = (nodes: NavNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.route && node.icon && !icons.has(node.route)) icons.set(node.route, node.icon);
|
||||
if (node.children) walk(node.children);
|
||||
}
|
||||
};
|
||||
walk(data.navigation.sidebar);
|
||||
|
||||
// Every tag with a landing or an Appendix card, coloured against how problem
|
||||
// pages (`<num>. <name>` titles, the shape sync writes) combine them. Only a
|
||||
// page with tags needs the answer.
|
||||
const colors = tags.length
|
||||
? tagColors(
|
||||
[...Object.keys(TOPIC_LANDINGS), ...Object.keys(APPENDIX)],
|
||||
await Promise.all(
|
||||
data.routes
|
||||
.filter((r: { title: string }) => /^\d+\.\s/u.test(r.title))
|
||||
.map(async (r: { collection: string; entryId: string }) => {
|
||||
const page = await getEntry(r.collection, r.entryId);
|
||||
return (page?.data.topics ?? []).map((t: TopicTag) => t.slug);
|
||||
}),
|
||||
),
|
||||
)
|
||||
: new Map<string, string>();
|
||||
|
||||
const badges = tags.map((tag) => {
|
||||
const target = tagTarget(tag, { route, tags }, served);
|
||||
const external = target.kind === "external";
|
||||
return {
|
||||
name: tag.name,
|
||||
href: external ? target.href : withBase(target.href),
|
||||
external,
|
||||
icon: target.kind === "appendix" ? APPENDIX[tag.slug]?.icon : external ? undefined : icons.get(target.href),
|
||||
color: external ? undefined : colors.get(tag.slug),
|
||||
stroke: target.kind !== "landing",
|
||||
};
|
||||
});
|
||||
|
||||
const linkClass = "no-underline transition-opacity hover:opacity-80";
|
||||
// TypeTable's column track, so the header and every row align.
|
||||
const columns = "grid grid-cols-[2fr_1fr_auto] items-center gap-4";
|
||||
---
|
||||
|
||||
{
|
||||
(badges.length > 0 || video || issue) && (
|
||||
<div class="not-prose mb-6 flex flex-wrap gap-1.5">
|
||||
{badges.map((b) => (
|
||||
<a
|
||||
href={b.href}
|
||||
target={b.external ? "_blank" : undefined}
|
||||
rel={b.external ? "noopener" : undefined}
|
||||
class={linkClass}
|
||||
>
|
||||
<Badge color={b.color} icon={b.icon} shape="pill" stroke={b.stroke}>
|
||||
{b.name}
|
||||
</Badge>
|
||||
</a>
|
||||
))}
|
||||
{video && (
|
||||
<a href={`https://www.youtube.com/watch?v=${video}`} target="_blank" rel="noopener" class={linkClass}>
|
||||
<Badge variant="danger" icon="youtube" shape="pill">
|
||||
Explanation
|
||||
</Badge>
|
||||
</a>
|
||||
)}
|
||||
{issue && (
|
||||
<a href={issue} target="_blank" rel="noopener" class={linkClass}>
|
||||
<Badge icon="github" shape="pill">
|
||||
Issue #{issue.slice(issue.lastIndexOf("/") + 1)}
|
||||
</Badge>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
companies.length > 0 && (
|
||||
<div class="not-prose mb-6 rounded-blume border border-border bg-muted/30 p-1.5" data-blume-narration="skip">
|
||||
<div class={`${columns} px-3 py-2 font-medium text-muted-foreground text-xs`}>
|
||||
<span>Company</span>
|
||||
<span>Asked within</span>
|
||||
<span title="Relative within the company and window: 100 is its most-asked problem there">Frequency</span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-col">
|
||||
{companies.map((c) => (
|
||||
<div class={`${columns} border-border border-t px-3 py-2 text-xs`}>
|
||||
<a href={withBase(companyHref(c.name))} class={linkClass}>
|
||||
<Badge icon="building-2" shape="pill" stroke>
|
||||
{c.name}
|
||||
</Badge>
|
||||
</a>
|
||||
<span class="whitespace-nowrap text-foreground">{RECENCY_LABEL[c.period]}</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="h-1.5 w-16 overflow-hidden rounded-full bg-muted" aria-hidden="true">
|
||||
<span class="block h-full rounded-full bg-accent" style={`width: ${c.frequency}%`} />
|
||||
</span>
|
||||
<code class="w-8 text-right font-mono text-foreground tabular-nums">{Math.round(c.frequency)}</code>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<slot />
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* The ten most recently solved problems — LeetCode's "Recent AC" list. Each
|
||||
* row is dated by the problem's first pass (an ET calendar date: the Worker
|
||||
* logs days, not times), read relative to today in ET with the full date on
|
||||
* hover. A row links to the problem's docs page when one exists: the
|
||||
* sync-generated short links (`redirects.ts`) are exactly the pages built.
|
||||
*/
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
import { useStats, utcDate } from "../../lib/stats";
|
||||
import redirects from "../../redirects";
|
||||
import { StatsPending } from "./shared";
|
||||
|
||||
const PAGES = new Map(redirects.map((r) => [r.from, r.to]));
|
||||
|
||||
const ET_TODAY = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York" });
|
||||
const RELATIVE = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
const FULL = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" });
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
export default function RecentSolves() {
|
||||
const load = useStats();
|
||||
|
||||
if (load.phase !== "ready") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recently solved</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatsPending load={load} className="h-80 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const today = utcDate(ET_TODAY.format(new Date())).getTime();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recently solved</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{load.stats.recent.length === 0 ? (
|
||||
<p className="my-0! text-muted-foreground text-sm">Nothing solved yet.</p>
|
||||
) : (
|
||||
<ol className="my-0! flex flex-col">
|
||||
{load.stats.recent.map((p) => {
|
||||
const date = utcDate(p.date);
|
||||
const page = PAGES.get(`/${p.lc}`);
|
||||
const name = (
|
||||
<>
|
||||
<span className="text-muted-foreground tabular-nums">{p.lc}.</span> {p.title}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={p.lc}
|
||||
className="flex items-center justify-between gap-4 rounded-lg px-4 py-3 text-sm odd:bg-muted"
|
||||
>
|
||||
{page ? (
|
||||
<a href={page} className="truncate font-normal! text-foreground no-underline! hover:underline!">
|
||||
{name}
|
||||
</a>
|
||||
) : (
|
||||
<span className="truncate">{name}</span>
|
||||
)}
|
||||
<time dateTime={p.date} title={FULL.format(date)} className="shrink-0 text-muted-foreground">
|
||||
{RELATIVE.format(Math.round((date.getTime() - today) / DAY_MS), "day")}
|
||||
</time>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Solved-by-difficulty ring — LeetCode's profile gauge. A 270° arc split into
|
||||
* one segment per difficulty, each sized by that difficulty's share of the
|
||||
* catalog; the faint track is the whole set, the solid arc the solved part.
|
||||
* The centre reads the total, or one difficulty while its segment (or its
|
||||
* stat box) is hovered. "Attempting" is the open-miss count: problems whose
|
||||
* latest attempt failed.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Pie, PieChart } from "recharts";
|
||||
import { Card, CardContent } from "../ui/card";
|
||||
import { type ChartConfig, ChartContainer } from "../ui/chart";
|
||||
import { DIFFICULTIES, type Difficulty, useStats } from "../../lib/stats";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { StatsPending } from "./shared";
|
||||
|
||||
const chartConfig = {
|
||||
easy: { label: "Easy", color: "var(--chart-1)" },
|
||||
medium: { label: "Med.", color: "var(--chart-2)" },
|
||||
hard: { label: "Hard", color: "var(--chart-3)" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
// Static class names, so Tailwind's scanner sees every one (the chart's
|
||||
// --color-* variables exist only inside ChartContainer).
|
||||
const DIFFICULTY_TEXT: Record<Difficulty, string> = {
|
||||
easy: "text-chart-1",
|
||||
medium: "text-chart-2",
|
||||
hard: "text-chart-3",
|
||||
};
|
||||
|
||||
// The gauge opens at the bottom: from 225° (lower left) clockwise to -45°
|
||||
// (lower right), in Recharts' counter-clockwise-from-3-o'clock degrees.
|
||||
const START = 225;
|
||||
const SWEEP = 270;
|
||||
const GAP = 8;
|
||||
|
||||
export default function SolvedRing() {
|
||||
const load = useStats();
|
||||
const [focus, setFocus] = useState<Difficulty | null>(null);
|
||||
|
||||
if (load.phase !== "ready") {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<StatsPending load={load} className="h-52 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = DIFFICULTIES.map(
|
||||
(difficulty) => load.stats.difficulty.find((d) => d.difficulty === difficulty) ?? { difficulty, total: 0, done: 0 },
|
||||
);
|
||||
const total = rows.reduce((n, r) => n + r.total, 0);
|
||||
const done = rows.reduce((n, r) => n + r.done, 0);
|
||||
const span = SWEEP - GAP * (rows.length - 1);
|
||||
|
||||
const segments: ((typeof rows)[number] & { from: number; to: number; solvedTo: number })[] = [];
|
||||
let cursor = START;
|
||||
for (const r of rows) {
|
||||
const size = total > 0 ? (span * r.total) / total : 0;
|
||||
const solved = r.total > 0 ? size * (r.done / r.total) : 0;
|
||||
segments.push({ ...r, from: cursor, to: cursor - size, solvedTo: cursor - solved });
|
||||
cursor -= size + GAP;
|
||||
}
|
||||
|
||||
const focused = focus ? rows.find((r) => r.difficulty === focus) : undefined;
|
||||
const arc = { innerRadius: "80%", outerRadius: "88%", cornerRadius: 99, stroke: "none", dataKey: "value" } as const;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-6 sm:flex-row">
|
||||
<div className="relative w-full max-w-56 shrink-0">
|
||||
<ChartContainer config={chartConfig} className="aspect-square w-full">
|
||||
<PieChart>
|
||||
{segments.map((s) => (
|
||||
<Pie
|
||||
key={`${s.difficulty}-track`}
|
||||
{...arc}
|
||||
data={[{ value: 1 }]}
|
||||
startAngle={s.from}
|
||||
endAngle={s.to}
|
||||
fill={`var(--color-${s.difficulty})`}
|
||||
fillOpacity={focus && focus !== s.difficulty ? 0.12 : 0.25}
|
||||
isAnimationActive={false}
|
||||
onMouseEnter={() => setFocus(s.difficulty)}
|
||||
onMouseLeave={() => setFocus(null)}
|
||||
/>
|
||||
))}
|
||||
{segments
|
||||
.filter((s) => s.done > 0)
|
||||
.map((s) => (
|
||||
<Pie
|
||||
key={`${s.difficulty}-solved`}
|
||||
{...arc}
|
||||
data={[{ value: 1 }]}
|
||||
startAngle={s.from}
|
||||
endAngle={s.solvedTo}
|
||||
fill={`var(--color-${s.difficulty})`}
|
||||
fillOpacity={focus && focus !== s.difficulty ? 0.35 : 1}
|
||||
onMouseEnter={() => setFocus(s.difficulty)}
|
||||
onMouseLeave={() => setFocus(null)}
|
||||
/>
|
||||
))}
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1">
|
||||
<div className="flex items-baseline">
|
||||
<span className="font-semibold text-4xl tabular-nums">{focused ? focused.done : done}</span>
|
||||
<span className="text-muted-foreground tabular-nums">/{focused ? focused.total : total}</span>
|
||||
</div>
|
||||
<span className={cn("text-sm", focused ? DIFFICULTY_TEXT[focused.difficulty] : "text-muted-foreground")}>
|
||||
{focused ? chartConfig[focused.difficulty].label : "Solved"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-3 text-center text-muted-foreground text-xs">
|
||||
<span className="font-semibold text-foreground tabular-nums">{load.stats.attempting}</span> Attempting
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-1">
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.difficulty}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-0.5 rounded-lg bg-muted px-4 py-2 transition-opacity",
|
||||
focus && focus !== r.difficulty && "opacity-50",
|
||||
)}
|
||||
onPointerEnter={() => setFocus(r.difficulty)}
|
||||
onPointerLeave={() => setFocus(null)}
|
||||
>
|
||||
<span className={cn("font-medium text-sm", DIFFICULTY_TEXT[r.difficulty])}>
|
||||
{chartConfig[r.difficulty].label}
|
||||
</span>
|
||||
<span className="text-sm tabular-nums">
|
||||
{r.done}/{r.total}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* This calendar year by difficulty — LeetCode's solved/submissions bar chart,
|
||||
* sharing its row with the heatmap. Stacked Easy / Med. / Hard bars per month
|
||||
* or per week (Monday-start, like the heatmap rows); "Solved" counts each
|
||||
* problem once, on its first-pass date (the date `recent` uses), while
|
||||
* "Submissions" counts every logged attempt. The totals under the chart are
|
||||
* the year's, in the selected mode. Buckets are cut from `year`, which always
|
||||
* covers January 1 to today.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import { Card, CardAction, CardContent, CardFooter, CardHeader } from "../ui/card";
|
||||
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "../ui/chart";
|
||||
import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
|
||||
import { DIFFICULTIES, type Day, type Difficulty, useStats, utcDate } from "../../lib/stats";
|
||||
import { StatsPending } from "./shared";
|
||||
|
||||
const chartConfig = {
|
||||
easy: { label: "Easy", color: "var(--chart-1)" },
|
||||
medium: { label: "Med.", color: "var(--chart-2)" },
|
||||
hard: { label: "Hard", color: "var(--chart-3)" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
// Static class names, so Tailwind's scanner sees every one.
|
||||
const DIFFICULTY_TEXT: Record<Difficulty, string> = {
|
||||
easy: "text-chart-1",
|
||||
medium: "text-chart-2",
|
||||
hard: "text-chart-3",
|
||||
};
|
||||
|
||||
type Mode = "solved" | "attempts";
|
||||
type Grain = "month" | "week";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
|
||||
const MONTH_YEAR = new Intl.DateTimeFormat("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
|
||||
const SHORT = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
|
||||
|
||||
interface Bucket extends Record<Difficulty, number> {
|
||||
index: number;
|
||||
/** Axis tick: the month the bucket starts in. */
|
||||
tick: string;
|
||||
/** Tooltip heading. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** The year's buckets, every one present (future ones empty), filled from `days`. */
|
||||
function buckets(days: Day[], year: number, grain: Grain, mode: Mode): Bucket[] {
|
||||
const jan1 = Date.UTC(year, 0, 1);
|
||||
const lead = (new Date(jan1).getUTCDay() + 6) % 7; // Jan 1's Monday-based weekday
|
||||
const length = Math.round((Date.UTC(year + 1, 0, 1) - jan1) / DAY_MS);
|
||||
const out: Bucket[] = [];
|
||||
if (grain === "month") {
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const start = new Date(Date.UTC(year, m, 1));
|
||||
out.push({ index: m, tick: MONTH.format(start), label: MONTH_YEAR.format(start), easy: 0, medium: 0, hard: 0 });
|
||||
}
|
||||
} else {
|
||||
const weeks = Math.floor((length - 1 + lead) / 7) + 1;
|
||||
for (let w = 0; w < weeks; w++) {
|
||||
const start = new Date(Math.max(jan1, jan1 + (w * 7 - lead) * DAY_MS));
|
||||
const end = new Date(Math.min(jan1 + (length - 1) * DAY_MS, jan1 + (w * 7 - lead + 6) * DAY_MS));
|
||||
out.push({
|
||||
index: w,
|
||||
tick: MONTH.format(start),
|
||||
label: `${SHORT.format(start)} – ${SHORT.format(end)}`,
|
||||
easy: 0,
|
||||
medium: 0,
|
||||
hard: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const day of days) {
|
||||
const date = utcDate(day.date);
|
||||
if (date.getUTCFullYear() !== year) continue;
|
||||
const i = grain === "month" ? date.getUTCMonth() : Math.floor(((date.getTime() - jan1) / DAY_MS + lead) / 7);
|
||||
const bucket = out[i];
|
||||
if (!bucket) continue;
|
||||
for (const d of DIFFICULTIES) bucket[d] += day[mode][d];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function SubmissionChart() {
|
||||
const load = useStats();
|
||||
const [mode, setMode] = useState<Mode>("solved");
|
||||
const [grain, setGrain] = useState<Grain>("month");
|
||||
|
||||
if (load.phase !== "ready") {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<StatsPending load={load} className="h-40 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const year = utcDate(load.stats.year.at(-1)?.date ?? "1970-01-01").getUTCFullYear();
|
||||
const data = buckets(load.stats.year, year, grain, mode);
|
||||
const totals = Object.fromEntries(
|
||||
DIFFICULTIES.map((d) => [d, data.reduce((n, b) => n + b[d], 0)]),
|
||||
);
|
||||
const peak = Math.max(...data.map((b) => b.easy + b.medium + b.hard));
|
||||
// A round ceiling for the one labelled gridline, as LeetCode draws it.
|
||||
const top = peak <= 5 ? 5 : Math.ceil(peak / 10) * 10;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-wrap items-center justify-between gap-2">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
spacing={1}
|
||||
value={mode}
|
||||
onValueChange={(value: string) => value && setMode(value as Mode)}
|
||||
className="rounded-full bg-muted p-0.5"
|
||||
aria-label="Count"
|
||||
>
|
||||
<ToggleGroupItem value="solved" className="h-7 rounded-full px-2.5 text-xs">
|
||||
Solved
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="attempts" className="h-7 rounded-full px-2.5 text-xs">
|
||||
Submissions
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div className="flex items-center">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
spacing={1}
|
||||
value={grain}
|
||||
onValueChange={(value: string) => value && setGrain(value as Grain)}
|
||||
className="rounded-full bg-muted p-0.5"
|
||||
aria-label="Bucket size"
|
||||
>
|
||||
<ToggleGroupItem value="month" className="h-7 rounded-full px-2.5 text-xs" aria-label="Per month">
|
||||
M
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="week" className="h-7 rounded-full px-2.5 text-xs" aria-label="Per week">
|
||||
W
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-40 w-full">
|
||||
<BarChart accessibilityLayer data={data} margin={{ top: 8, right: 0, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="index"
|
||||
type="category"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={6}
|
||||
ticks={[0, data.length - 1]}
|
||||
interval={0}
|
||||
// First label anchored at its start, last at its end, so neither
|
||||
// is clipped when a week band is only a few pixels wide.
|
||||
tick={({ x, y, payload }) => (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
dy={10}
|
||||
textAnchor={payload.value === 0 ? "start" : "end"}
|
||||
className="fill-muted-foreground"
|
||||
>
|
||||
{data[Number(payload.value)]?.tick}
|
||||
</text>
|
||||
)}
|
||||
/>
|
||||
<YAxis
|
||||
orientation="right"
|
||||
domain={[0, top]}
|
||||
ticks={[0, top]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={28}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
// Recharts applies itemSorter only in its default tooltip; this
|
||||
// content gets the stack's bottom-up order, so flip it to read
|
||||
// top-down, Easy first, like the bars.
|
||||
content={({ active, label, payload }) => (
|
||||
<ChartTooltipContent
|
||||
active={active}
|
||||
label={label}
|
||||
payload={[...(payload ?? [])].reverse()}
|
||||
labelFormatter={(_, items) => items[0]?.payload?.label}
|
||||
indicator="dot"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{(["hard", "medium", "easy"] as const).map((d) => (
|
||||
<Bar
|
||||
key={d}
|
||||
dataKey={d}
|
||||
stackId="solved"
|
||||
fill={`var(--color-${d})`}
|
||||
radius={2}
|
||||
maxBarSize={grain === "month" ? 10 : 5}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className="gap-4 text-sm">
|
||||
{DIFFICULTIES.map((d) => (
|
||||
<span key={d} className="flex items-baseline gap-1">
|
||||
<span className={DIFFICULTY_TEXT[d]}>{chartConfig[d].label}</span>
|
||||
<span className="text-muted-foreground tabular-nums">{totals[d]}</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="ml-auto text-muted-foreground tabular-nums">{year}</span>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* The last six months of attempts — LeetCode's calendar heatmap, halved to
|
||||
* share its row with the submissions chart: one cell per day, weeks as
|
||||
* columns, each month its own block, and the window's attempt count, active
|
||||
* days and longest streak above it. The figures are counted from exactly the
|
||||
* cells drawn, so they cannot disagree with the grid. The colour ramp is the
|
||||
* README/email heatmap's (theme.css `--heat-0..4`, level = attempts capped at
|
||||
* 4) and rows start on Monday like it, so the three heatmaps read the same.
|
||||
* Drawn as SVG in a viewBox, so it scales to its column instead of scrolling.
|
||||
*/
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { type Day, useStats, utcDate } from "../../lib/stats";
|
||||
import { StatsPending, TipRow, useHoverTip } from "./shared";
|
||||
|
||||
const MONTHS = 6;
|
||||
const CELL = 10;
|
||||
const STEP = 13; // cell + gap
|
||||
const MONTH_GAP = 8;
|
||||
const LABEL_H = 16;
|
||||
const HEAT = ["var(--heat-0)", "var(--heat-1)", "var(--heat-2)", "var(--heat-3)", "var(--heat-4)"];
|
||||
|
||||
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
|
||||
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
|
||||
|
||||
interface Cell {
|
||||
x: number;
|
||||
y: number;
|
||||
date: Date;
|
||||
attempts: number;
|
||||
passes: number;
|
||||
}
|
||||
|
||||
interface Block {
|
||||
label: string;
|
||||
x: number;
|
||||
width: number;
|
||||
cells: Cell[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Month blocks laid left to right over the last MONTHS calendar months. Within
|
||||
* a block a day sits at row = weekday (Mon = 0) and column = its week counted
|
||||
* from the 1st, so a month starting on a Thursday opens with a short column.
|
||||
*/
|
||||
function layout(year: Day[]): { blocks: Block[]; width: number } {
|
||||
const last = utcDate(year.at(-1)?.date ?? "1970-01-01");
|
||||
const from = Date.UTC(last.getUTCFullYear(), last.getUTCMonth() - (MONTHS - 1), 1);
|
||||
const blocks: Block[] = [];
|
||||
let x = 0;
|
||||
for (const day of year) {
|
||||
const date = utcDate(day.date);
|
||||
if (date.getTime() < from) continue;
|
||||
let block = blocks.at(-1);
|
||||
if (date.getUTCDate() === 1 || !block) {
|
||||
if (block) x += block.width + MONTH_GAP;
|
||||
block = { label: MONTH.format(date), x, width: 0, cells: [] };
|
||||
blocks.push(block);
|
||||
}
|
||||
const first = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
|
||||
const index = (first.getUTCDay() + 6) % 7 + date.getUTCDate() - 1;
|
||||
const col = Math.floor(index / 7);
|
||||
block.cells.push({
|
||||
x: x + col * STEP,
|
||||
y: (index % 7) * STEP,
|
||||
date,
|
||||
attempts: day.attempts.easy + day.attempts.medium + day.attempts.hard,
|
||||
passes: day.passes,
|
||||
});
|
||||
block.width = col * STEP + CELL;
|
||||
}
|
||||
const end = blocks.at(-1);
|
||||
return { blocks, width: end ? end.x + end.width : 0 };
|
||||
}
|
||||
|
||||
export default function SubmissionHeatmap() {
|
||||
const load = useStats();
|
||||
const { ref, show, hide, node } = useHoverTip<HTMLDivElement>();
|
||||
|
||||
if (load.phase !== "ready") {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<StatsPending load={load} className="h-40 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { blocks, width } = layout(load.stats.year);
|
||||
const cells = blocks.flatMap((b) => b.cells);
|
||||
let run = 0;
|
||||
let streak = 0;
|
||||
for (const c of cells) {
|
||||
run = c.attempts > 0 ? run + 1 : 0;
|
||||
streak = Math.max(streak, run);
|
||||
}
|
||||
const height = 7 * STEP - (STEP - CELL) + LABEL_H;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-baseline gap-2">
|
||||
<span className="font-semibold text-2xl tabular-nums">{cells.reduce((n, c) => n + c.attempts, 0)}</span>
|
||||
<span className="font-normal text-muted-foreground text-sm">attempts in the past {MONTHS} months</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex gap-4">
|
||||
<span>
|
||||
Active days:{" "}
|
||||
<span className="text-foreground tabular-nums">{cells.filter((c) => c.attempts > 0).length}</span>
|
||||
</span>
|
||||
<span>
|
||||
Max streak: <span className="text-foreground tabular-nums">{streak}</span>
|
||||
</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 items-center">
|
||||
<div ref={ref} className="relative w-full" onPointerLeave={hide}>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="h-auto w-full"
|
||||
role="img"
|
||||
aria-label={`Attempts per day over the past ${MONTHS} months`}
|
||||
>
|
||||
{blocks.map((block) => (
|
||||
<g key={`${block.label}-${block.x}`}>
|
||||
{block.cells.map((cell) => (
|
||||
<rect
|
||||
key={cell.x * 1000 + cell.y}
|
||||
x={cell.x}
|
||||
y={cell.y}
|
||||
width={CELL}
|
||||
height={CELL}
|
||||
rx={2}
|
||||
fill={HEAT[Math.min(cell.attempts, 4)]}
|
||||
onPointerMove={(event) =>
|
||||
show(
|
||||
event,
|
||||
<>
|
||||
<div className="font-medium">{DAY.format(cell.date)}</div>
|
||||
<TipRow color={HEAT[Math.min(cell.attempts, 4)]!} label="Attempts" value={cell.attempts} />
|
||||
<TipRow color="var(--chart-4)" label="Passed" value={cell.passes} />
|
||||
</>,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<text
|
||||
x={block.x + block.width / 2}
|
||||
y={height - 3}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[9px]"
|
||||
>
|
||||
{block.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
{node}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* The campaign's topics as packed bubbles — LeetCode's "All Topics" view,
|
||||
* over our curriculum instead of LeetCode's tags. A bubble's area is the
|
||||
* topic's problem count; it fills from the bottom like a glass as problems
|
||||
* are solved, and turns solid once every one is. A topic the schedule has not
|
||||
* reached yet is dimmed. Packing is d3-hierarchy's front-chain (packSiblings,
|
||||
* largest first, so the layout is deterministic), drawn as SVG in a viewBox:
|
||||
* Recharts has no packed layout.
|
||||
*/
|
||||
import { packEnclose, packSiblings } from "d3-hierarchy";
|
||||
import { useId } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { type Stats, useStats } from "../../lib/stats";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { StatsPending, TipRow, useHoverTip } from "./shared";
|
||||
|
||||
const PAD = 2; // gap between bubbles, in layout units
|
||||
const UNIT = 12; // radius of a one-problem bubble
|
||||
|
||||
type Topic = Stats["topics"][number];
|
||||
type Bubble = Topic & { x: number; y: number; r: number };
|
||||
|
||||
/** Split a name across lines of at most ~10 characters, never breaking a word. */
|
||||
function lines(name: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const word of name.split(" ")) {
|
||||
const last = out.at(-1);
|
||||
if (last !== undefined && `${last} ${word}`.length <= 10) out[out.length - 1] = `${last} ${word}`;
|
||||
else out.push(word);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function TopicBubbles() {
|
||||
const load = useStats();
|
||||
const { ref, show, hide, node } = useHoverTip<HTMLDivElement>();
|
||||
const clip = useId().replace(/:/g, "");
|
||||
|
||||
if (load.phase !== "ready") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Topics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatsPending load={load} className="aspect-square w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const topics = load.stats.topics.filter((t) => t.total > 0);
|
||||
const circles: Bubble[] = topics
|
||||
.slice()
|
||||
.sort((a, b) => b.total - a.total || a.issue - b.issue)
|
||||
.map((t) => ({ ...t, x: 0, y: 0, r: UNIT * Math.sqrt(t.total) + PAD }));
|
||||
packSiblings(circles);
|
||||
const bound = packEnclose(circles) ?? { x: 0, y: 0, r: 1 };
|
||||
const solved = topics.reduce((n, t) => n + t.done, 0);
|
||||
const total = topics.reduce((n, t) => n + t.total, 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Topics</CardTitle>
|
||||
<CardDescription>
|
||||
{solved} of {total} problems across {topics.length} topics · size is problem count, fill is solved
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div ref={ref} className="relative mx-auto w-full max-w-xl" onPointerLeave={hide}>
|
||||
<svg
|
||||
viewBox={`${bound.x - bound.r} ${bound.y - bound.r} ${bound.r * 2} ${bound.r * 2}`}
|
||||
className="h-auto w-full"
|
||||
role="img"
|
||||
aria-label="Campaign topics sized by problem count and filled by problems solved"
|
||||
>
|
||||
{circles.map((c) => {
|
||||
const r = c.r - PAD;
|
||||
const ratio = c.total > 0 ? c.done / c.total : 0;
|
||||
const complete = c.done >= c.total;
|
||||
const words = lines(c.name);
|
||||
const size = Math.min(r / 3.2, (1.7 * r) / Math.max(...words.map((w) => w.length)));
|
||||
const id = `${clip}-${c.issue}`;
|
||||
return (
|
||||
<g
|
||||
key={c.issue}
|
||||
className={cn("transition-opacity hover:opacity-90", !c.taught && "opacity-50")}
|
||||
onPointerMove={(event) =>
|
||||
show(
|
||||
event,
|
||||
<>
|
||||
<div className="font-medium">{c.name}</div>
|
||||
<TipRow color="var(--chart-4)" label="Solved" value={`${c.done}/${c.total}`} />
|
||||
{!c.taught && <div className="text-muted-foreground">Not reached yet</div>}
|
||||
</>,
|
||||
)
|
||||
}
|
||||
>
|
||||
<clipPath id={id}>
|
||||
<circle cx={c.x} cy={c.y} r={r} />
|
||||
</clipPath>
|
||||
<circle cx={c.x} cy={c.y} r={r} className="fill-muted stroke-border" strokeWidth={0.5} />
|
||||
<rect
|
||||
x={c.x - r}
|
||||
y={c.y + r - 2 * r * ratio}
|
||||
width={2 * r}
|
||||
height={2 * r * ratio}
|
||||
clipPath={`url(#${id})`}
|
||||
fill="var(--chart-4)"
|
||||
fillOpacity={complete ? 1 : 0.35}
|
||||
/>
|
||||
<text
|
||||
x={c.x}
|
||||
y={c.y - ((words.length - 1) * size * 1.15) / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
clipPath={`url(#${id})`}
|
||||
className={cn("pointer-events-none select-none", complete ? "fill-background" : "fill-foreground")}
|
||||
style={{ fontSize: size }}
|
||||
>
|
||||
{words.map((w, i) => (
|
||||
<tspan key={`${i}-${w}`} x={c.x} dy={i === 0 ? 0 : size * 1.15}>
|
||||
{w}
|
||||
</tspan>
|
||||
))}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{node}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* What the progress cards share besides the data: the not-ready body
|
||||
* (a skeleton while /api/stats loads, one muted line if it fails) and the
|
||||
* hover tooltip for the two charts drawn as plain SVG (the heatmap calendar
|
||||
* and the topic bubbles — Recharts has neither layout). The tooltip wears the
|
||||
* exact classes of shadcn's ChartTooltipContent, so all four cards' tooltips
|
||||
* look like one component.
|
||||
*/
|
||||
import { type PointerEvent, type ReactNode, useRef, useState } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { Load } from "../../lib/stats";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export function StatsPending({ load, className }: { load: Exclude<Load, { phase: "ready" }>; className: string }) {
|
||||
if (load.phase === "loading") return <Skeleton className={className} />;
|
||||
return (
|
||||
<p className="my-0! text-muted-foreground text-sm">
|
||||
Stats unavailable — they are served live from the SRS Worker, and it did not respond.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
interface Tip {
|
||||
x: number;
|
||||
y: number;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pointer-following tooltip for SVG marks. Put `ref` on a `relative`
|
||||
* wrapper, call `show` from a mark's pointer events, render `tip` inside the
|
||||
* wrapper. Positions are relative to the wrapper, so page scroll never skews it.
|
||||
*/
|
||||
export function useHoverTip<T extends HTMLElement>() {
|
||||
const ref = useRef<T>(null);
|
||||
const [tip, setTip] = useState<Tip | null>(null);
|
||||
const show = (event: PointerEvent, content: ReactNode) => {
|
||||
const box = ref.current?.getBoundingClientRect();
|
||||
if (box) setTip({ x: event.clientX - box.left, y: event.clientY - box.top, content });
|
||||
};
|
||||
const hide = () => setTip(null);
|
||||
const node = tip && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute z-10 grid min-w-[8rem] -translate-x-1/2 -translate-y-[calc(100%+0.75rem)] items-start gap-1.5",
|
||||
"rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
)}
|
||||
style={{ left: tip.x, top: tip.y }}
|
||||
>
|
||||
{tip.content}
|
||||
</div>
|
||||
);
|
||||
return { ref, show, hide, node };
|
||||
}
|
||||
|
||||
/** One tooltip row, the shape ChartTooltipContent draws: colour dot, label, mono value. */
|
||||
export function TipRow({ color, label, value }: { color: string; label: ReactNode; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="size-2.5 shrink-0 rounded-[2px]" style={{ background: color }} />
|
||||
<div className="flex flex-1 items-center justify-between gap-4 leading-none">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium font-mono text-foreground tabular-nums">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
import type { TooltipValueType } from "recharts"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }. Blume switches dark mode with
|
||||
// [data-theme="dark"] on <html>, not shadcn's .dark class.
|
||||
const THEMES = { light: "", dark: ':root[data-theme="dark"]' } as const
|
||||
|
||||
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
|
||||
type TooltipNameType = number | string
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
>
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
initialDimension = INITIAL_DIMENSION,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
initialDimension?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer
|
||||
initialDimension={initialDimension}
|
||||
>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme ?? config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
} & Omit<
|
||||
RechartsPrimitive.DefaultTooltipContentProps<
|
||||
TooltipValueType,
|
||||
TooltipNameType
|
||||
>,
|
||||
"accessibilityLayer"
|
||||
>) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? (config[label]?.label ?? label)
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color ?? item.payload?.fill ?? item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ?? item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number"
|
||||
? item.value.toLocaleString()
|
||||
: String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
|
||||
|
||||
import { toggleVariants } from "./toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
||||
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { Toggle as TogglePrimitive } from "radix-ui"
|
||||
|
||||
// Blume's `accent` is its brand colour, not shadcn's subtle surface: the on
|
||||
// and hover states use background/muted instead (see theme.css).
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-muted hover:text-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 min-w-9 px-2",
|
||||
sm: "h-8 min-w-8 px-1.5",
|
||||
lg: "h-10 min-w-10 px-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: '153. Find Minimum in Rotated Sorted Array'
|
||||
description: 'Suppose an array of length n sorted in ascending order is rotated between 1 and n times. For example, the array nums = [0,1,2,4,5,6,7] might become:'
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
video: "nIVW4P8b1VA"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/62"
|
||||
companies:
|
||||
- { name: "Ola Cabs", period: "Thirty Days", frequency: 65.6 }
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 48.9 }
|
||||
- { name: "Google", period: "Thirty Days", frequency: 29 }
|
||||
- { name: "tcs", period: "Six Months", frequency: 43.5 }
|
||||
- { name: "Bloomberg", period: "Six Months", frequency: 40.6 }
|
||||
- { name: "Meta", period: "Six Months", frequency: 32.7 }
|
||||
sidebar:
|
||||
label: 'Find Minimum in Rotated Sorted Array'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
::::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]].
|
||||
::::
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [3,4,5,1,2]`
|
||||
- Output: `1`
|
||||
- Explanation: The original array was `[1,2,3,4,5]` rotated `3` times.
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [4,5,6,7,0,1,2]`
|
||||
- Output: `0`
|
||||
- Explanation: The original array was `[0,1,2,4,5,6,7]` and it was rotated `4` times.
|
||||
|
||||
### Example 3:
|
||||
- Input: `nums = [11,13,15,17]`
|
||||
- Output: `11`
|
||||
- Explanation: The original array was `[11,13,15,17]` and it was rotated `4` times.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `n == nums.length`
|
||||
- `1 <= n <= 5000`
|
||||
- `-5000 <= nums[i] <= 5000`
|
||||
- All the integers of `nums` are unique.
|
||||
- `nums` is sorted and rotated between 1 and `n` times.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["findMin(nums)"]) --> I["l = 0, r = n-1, lowest_index = -1"]
|
||||
I --> W{"l <= r?"}
|
||||
W -- no --> E(["return nums[lowest_index]"])
|
||||
W -- yes --> M["m = (l + r) // 2"]
|
||||
M --> C{"nums[m] <= nums[-1]?"}
|
||||
C -- yes --> A["in the right sorted run: record lowest_index = m, r = m - 1"]
|
||||
A --> W
|
||||
C -- no --> B["still in the left sorted run: l = m + 1"]
|
||||
B --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def findMin(self, nums: List[int]) -> int:
|
||||
l, r = 0, len(nums) - 1
|
||||
lowest_index = -1
|
||||
|
||||
while l <= r:
|
||||
m = (l + r) // 2
|
||||
if nums[m] <= nums[-1]:
|
||||
lowest_index = m
|
||||
r = m - 1
|
||||
else:
|
||||
l = m + 1
|
||||
|
||||
return nums[lowest_index]
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: '162. Find Peak Element'
|
||||
description: A peak element is an element that is strictly greater than its neighbors
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
issue: "https://github.com/prdlk/leetcode/issues/64"
|
||||
companies:
|
||||
- { name: "Infosys", period: "Thirty Days", frequency: 68.6 }
|
||||
- { name: "tcs", period: "Thirty Days", frequency: 67.6 }
|
||||
- { name: "Google", period: "Thirty Days", frequency: 29 }
|
||||
- { name: "Meta", period: "Three Months", frequency: 54.8 }
|
||||
- { name: "Microsoft", period: "Three Months", frequency: 42.1 }
|
||||
- { name: "Bloomberg", period: "Three Months", frequency: 36.8 }
|
||||
- { name: "Amazon", period: "Three Months", frequency: 34.6 }
|
||||
- { name: "Uber", period: "Six Months", frequency: 67.2 }
|
||||
sidebar:
|
||||
label: 'Find Peak Element'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
::::warning
|
||||
You must write an algorithm that runs in O(log n) time.
|
||||
::::
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,2,3,1]`
|
||||
- Output: `2`
|
||||
- Explanation: `3` is a peak element and your function should return the index number `2`.
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [1,2,1,3,5,6,4]`
|
||||
- Output: `5`
|
||||
- Explanation: Your function can return either index number `1` where the peak element is `2`, or index number `5` where the peak element is `6`.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= nums.length <= 1000`
|
||||
- `-2^31 <= nums[i] <= 2^31 - 1`
|
||||
- `nums[i] != nums[i + 1] for all valid i.`
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def findPeakElement(self, nums: list[int]) -> int:
|
||||
left, right = 0, len(nums) - 1
|
||||
while left < right:
|
||||
mid = (left + right) // 2
|
||||
if nums[mid] < nums[mid + 1]:
|
||||
left = mid + 1
|
||||
else:
|
||||
right = mid
|
||||
|
||||
return left
|
||||
```
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
---
|
||||
title: '33. Search in Rotated Sorted Array'
|
||||
description: There is an integer array nums sorted in ascending order (with distinct values)
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
video: "U8XENwh8Oy8"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/60"
|
||||
companies:
|
||||
- { name: "Meta", period: "Thirty Days", frequency: 53.8 }
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||
- { name: "Google", period: "Thirty Days", frequency: 36.7 }
|
||||
- { name: "Oracle", period: "Three Months", frequency: 100 }
|
||||
- { name: "Bloomberg", period: "Three Months", frequency: 68.3 }
|
||||
- { name: "Microsoft", period: "Three Months", frequency: 33.4 }
|
||||
- { name: "TikTok", period: "Six Months", frequency: 64.9 }
|
||||
- { name: "LinkedIn", period: "Six Months", frequency: 63.3 }
|
||||
- { name: "Yandex", period: "Six Months", frequency: 59.6 }
|
||||
- { name: "Goldman Sachs", period: "Six Months", frequency: 53.1 }
|
||||
- { name: "Apple", period: "Six Months", frequency: 48.1 }
|
||||
sidebar:
|
||||
label: 'Search in Rotated Sorted Array'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
::::warning
|
||||
You must write an algorithm with O(log n) runtime complexity.
|
||||
::::
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [4,5,6,7,0,1,2], target = 0`
|
||||
- Output: `4`
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [4,5,6,7,0,1,2], target = 3`
|
||||
- Output: `-1`
|
||||
|
||||
### Example 3:
|
||||
- Input: `nums = [1], target = 0`
|
||||
- Output: `-1`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= nums.length <= 5000`
|
||||
- `-10^4 <= nums[i] <= 10^4`
|
||||
- All values of `nums` are unique.
|
||||
- `nums` is an ascending array that is possibly rotated.
|
||||
- `-10^4 <= target <= 10^4`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["search(nums, target)"]) --> I["l = 0, r = n - 1"]
|
||||
I --> W{"l <= r?"}
|
||||
W -- no --> E(["return -1"])
|
||||
W -- yes --> M["mid = floor((l + r) / 2)"]
|
||||
M --> F{"nums[mid] == target?"}
|
||||
F -- yes --> R(["return mid"])
|
||||
F -- no --> H{"nums[l] <= nums[mid]? — left half sorted"}
|
||||
H -- yes --> A{"target outside [nums[l], nums[mid]]?"}
|
||||
A -- yes --> A1["l = mid + 1 — search the right half"]
|
||||
A -- no --> A2["r = mid - 1"]
|
||||
H -- no --> B{"target outside [nums[mid], nums[r]]?"}
|
||||
B -- yes --> B1["r = mid - 1 — search the left half"]
|
||||
B -- no --> B2["l = mid + 1"]
|
||||
A1 --> W
|
||||
A2 --> W
|
||||
B1 --> W
|
||||
B2 --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
/**
|
||||
* @param {number[]} nums
|
||||
* @param {number} target
|
||||
* @return {number}
|
||||
*/
|
||||
var search = function(nums, target) {
|
||||
let l = 0, r = nums.length - 1;
|
||||
|
||||
while (l <= r){
|
||||
let mid = Math.floor((l + r)/2);
|
||||
if (target === nums[mid]) {
|
||||
return mid
|
||||
}
|
||||
|
||||
// Left sorted portion
|
||||
if (nums[l] <= nums[mid]){
|
||||
if (target > nums[mid] || target < nums[l]){
|
||||
l = mid + 1;
|
||||
}else{
|
||||
r = mid - 1;
|
||||
}
|
||||
}
|
||||
// Right sorted portion
|
||||
else{
|
||||
if(target < nums[mid] || target > nums[r]){
|
||||
r = mid - 1;
|
||||
}else{
|
||||
l = mid + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
```
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: '34. Find First and Last Position of Element in Sorted Array'
|
||||
description: Given an array of integers nums sorted in non-decreasing order, find the starting and ending position of a given target value
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
issue: "https://github.com/prdlk/leetcode/issues/65"
|
||||
companies:
|
||||
- { name: "Microsoft", period: "Thirty Days", frequency: 57.2 }
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 48.9 }
|
||||
- { name: "Google", period: "Thirty Days", frequency: 36.7 }
|
||||
- { name: "TikTok", period: "Three Months", frequency: 70.9 }
|
||||
- { name: "tcs", period: "Three Months", frequency: 47.7 }
|
||||
- { name: "Meta", period: "Three Months", frequency: 43.3 }
|
||||
- { name: "Bloomberg", period: "Three Months", frequency: 36.8 }
|
||||
- { name: "Instacart", period: "Six Months", frequency: 100 }
|
||||
- { name: "Apple", period: "Six Months", frequency: 70.2 }
|
||||
- { name: "Infosys", period: "Six Months", frequency: 56.2 }
|
||||
sidebar:
|
||||
label: 'Find First and Last Position of Element in Sorted Array'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
::::warning
|
||||
You must write an algorithm with O(log n) runtime complexity.
|
||||
::::
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [5,7,7,8,8,10], target = 8`
|
||||
- Output: `[3,4]`
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [5,7,7,8,8,10], target = 6`
|
||||
- Output: `[-1,-1]`
|
||||
|
||||
### Example 3:
|
||||
- Input: `nums = [], target = 0`
|
||||
- Output: `[-1,-1]`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `0 <= nums.length <= 10^5`
|
||||
- `-10^9 <= nums[i] <= 10^9`
|
||||
- `nums` is a non-decreasing array.
|
||||
- `-10^9 <= target <= 10^9`
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def searchRange(self, nums: list[int], target: int) -> list[int]:
|
||||
def find(first):
|
||||
result = -1
|
||||
left, right = 0, len(nums) - 1
|
||||
while left <= right:
|
||||
mid = (left + right) // 2
|
||||
if nums[mid] == target:
|
||||
result = mid
|
||||
if first:
|
||||
right = mid - 1
|
||||
else:
|
||||
left = mid + 1
|
||||
elif nums[mid] < target:
|
||||
left = mid + 1
|
||||
else:
|
||||
right = mid - 1
|
||||
return result
|
||||
|
||||
return [find(True), find(False)]
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: '35. Search Insert Position'
|
||||
description: Given a sorted array of distinct integers and a target value, return the index if the target is found. If not, return the index where it would be if it were inserted in order
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
video: "K-RYzDZkzCI"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/63"
|
||||
companies:
|
||||
- { name: "Google", period: "Thirty Days", frequency: 50.2 }
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||
- { name: "Grammarly", period: "Three Months", frequency: 84.8 }
|
||||
- { name: "Bloomberg", period: "Three Months", frequency: 54 }
|
||||
- { name: "Meta", period: "Three Months", frequency: 49.8 }
|
||||
- { name: "tcs", period: "Three Months", frequency: 47.7 }
|
||||
- { name: "Microsoft", period: "Three Months", frequency: 42.1 }
|
||||
- { name: "Infosys", period: "Six Months", frequency: 56.2 }
|
||||
sidebar:
|
||||
label: 'Search Insert Position'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
::::warning
|
||||
You must write an algorithm with O(log n) runtime complexity.
|
||||
::::
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,3,5,6], target = 5`
|
||||
- Output: `2`
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [1,3,5,6], target = 2`
|
||||
- Output: `1`
|
||||
|
||||
### Example 3:
|
||||
- Input: `nums = [1,3,5,6], target = 7`
|
||||
- Output: `4`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= nums.length <= 10^4`
|
||||
- `-10^4 <= nums[i] <= 10^4`
|
||||
- `nums` contains distinct values sorted in ascending order.
|
||||
- `-10^4 <= target <= 10^4`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["searchInsert(nums, target)"]) --> I["l = 0, r = n - 1"]
|
||||
I --> W{"l <= r?"}
|
||||
W -- yes --> M["m = (l + r) // 2"]
|
||||
M --> Q{"nums[m] vs target"}
|
||||
Q -- equal --> R(["return m"])
|
||||
Q -- "nums[m] < target" --> L["l = m + 1"]
|
||||
L --> W
|
||||
Q -- "nums[m] > target" --> H["r = m - 1"]
|
||||
H --> W
|
||||
W -- no --> E(["return (l + r) // 2 + 1 — the insert slot"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def searchInsert(self, nums: List[int], target: int) -> int:
|
||||
l, r = 0, len(nums) - 1
|
||||
|
||||
while l <= r:
|
||||
m = (l + r) // 2
|
||||
if nums[m] == target:
|
||||
return m
|
||||
elif nums[m] < target:
|
||||
l = m + 1
|
||||
elif nums[m] > target:
|
||||
r = m - 1
|
||||
|
||||
return (l + r) // 2 + 1
|
||||
```
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: '4. Median of Two Sorted Arrays'
|
||||
description: Given two sorted arrays nums1 and nums2 of size m and n respectively, return the median of the two sorted arrays
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
- { name: "Divide and Conquer", slug: "divide-and-conquer" }
|
||||
issue: "https://github.com/prdlk/leetcode/issues/66"
|
||||
companies:
|
||||
- { name: "Rippling", period: "Thirty Days", frequency: 93 }
|
||||
- { name: "Bloomberg", period: "Thirty Days", frequency: 69.8 }
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 69.6 }
|
||||
- { name: "Meta", period: "Thirty Days", frequency: 63.8 }
|
||||
- { name: "Google", period: "Thirty Days", frequency: 62 }
|
||||
- { name: "Microsoft", period: "Thirty Days", frequency: 57.2 }
|
||||
- { name: "eBay", period: "Three Months", frequency: 82.2 }
|
||||
- { name: "Goldman Sachs", period: "Three Months", frequency: 57 }
|
||||
- { name: "tcs", period: "Three Months", frequency: 47.7 }
|
||||
- { name: "Zoho", period: "Six Months", frequency: 79.6 }
|
||||
- { name: "DE Shaw", period: "Six Months", frequency: 74.2 }
|
||||
- { name: "Apple", period: "Six Months", frequency: 48.1 }
|
||||
sidebar:
|
||||
label: 'Median of Two Sorted Arrays'
|
||||
badge: 'Hard'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums1 = [1,3], nums2 = [2]`
|
||||
- Output: `2.00000`
|
||||
- Explanation: merged array = `[1,2,3]` and median is `2`.
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums1 = [1,2], nums2 = [3,4]`
|
||||
- Output: `2.50000`
|
||||
- Explanation: merged array = `[1,2,3,4]` and median is `(2 + 3`) / `2 = 2.5`.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `nums1.length == m`
|
||||
- `nums2.length == n`
|
||||
- `0 <= m <= 1000`
|
||||
- `0 <= n <= 1000`
|
||||
- `1 <= m + n <= 2000`
|
||||
- `-10^6 <= nums1[i], nums2[i] <= 10^6`
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def findMedianSortedArrays(self, nums1: list[int], nums2: list[int]) -> float:
|
||||
if len(nums1) > len(nums2):
|
||||
return self.findMedianSortedArrays(nums2, nums1)
|
||||
|
||||
m, n = len(nums1), len(nums2)
|
||||
left, right = 0, m
|
||||
|
||||
while left <= right:
|
||||
partA = (left + right) // 2
|
||||
partB = (m + n + 1) // 2 - partA
|
||||
|
||||
maxLeftA = float("-inf") if partA == 0 else nums1[partA - 1]
|
||||
minRightA = float("inf") if partA == m else nums1[partA]
|
||||
maxLeftB = float("-inf") if partB == 0 else nums2[partB - 1]
|
||||
minRightB = float("inf") if partB == n else nums2[partB]
|
||||
|
||||
if maxLeftA <= minRightB and maxLeftB <= minRightA:
|
||||
if (m + n) % 2 == 0:
|
||||
return (max(maxLeftA, maxLeftB) + min(minRightA, minRightB)) / 2
|
||||
else:
|
||||
return max(maxLeftA, maxLeftB)
|
||||
elif maxLeftA > minRightB:
|
||||
right = partA - 1
|
||||
else:
|
||||
left = partA + 1
|
||||
```
|
||||
+28
-2
@@ -1,13 +1,23 @@
|
||||
---
|
||||
title: '704. Binary Search'
|
||||
description: Given an array of integers nums which is sorted in ascending order, and an integer target, write a function to search target in nums. If target exists, then return its index. Otherwise, return -1
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
video: "s4DPM8ct1pI"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/58"
|
||||
companies:
|
||||
- { name: "Google", period: "Thirty Days", frequency: 53.2 }
|
||||
- { name: "Bloomberg", period: "Three Months", frequency: 54 }
|
||||
- { name: "Meta", period: "Three Months", frequency: 49.8 }
|
||||
- { name: "Microsoft", period: "Three Months", frequency: 42.1 }
|
||||
- { name: "Amazon", period: "Three Months", frequency: 39.1 }
|
||||
sidebar:
|
||||
label: 'Binary Search'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search</Badge>
|
||||
|
||||
::::warning
|
||||
You must write an algorithm with O(log n) runtime complexity.
|
||||
::::
|
||||
@@ -29,6 +39,22 @@ You must write an algorithm with O(log n) runtime complexity.
|
||||
- All the integers in `nums` are unique.
|
||||
- `nums` is sorted in ascending order.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["search(nums, target)"]) --> I["left = 0, right = n - 1"]
|
||||
I --> W{"left <= right?"}
|
||||
W -- no --> E(["return -1"])
|
||||
W -- yes --> M["mid = left + floor((right - left) / 2)"]
|
||||
M --> Q{"nums[mid] vs target"}
|
||||
Q -- equal --> R(["return mid"])
|
||||
Q -- "nums[mid] < target" --> L["left = mid + 1"]
|
||||
L --> W
|
||||
Q -- "nums[mid] > target" --> H["right = mid - 1"]
|
||||
H --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: '74. Search a 2D Matrix'
|
||||
description: 'You are given an m x n integer matrix matrix with the following two properties:'
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
- { name: "Matrix", slug: "matrix" }
|
||||
video: "Ber2pi2C0j0"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/61"
|
||||
companies:
|
||||
- { name: "Goldman Sachs", period: "Thirty Days", frequency: 62.9 }
|
||||
- { name: "Microsoft", period: "Three Months", frequency: 57.2 }
|
||||
- { name: "Amazon", period: "Three Months", frequency: 34.6 }
|
||||
- { name: "Meta", period: "Three Months", frequency: 34.5 }
|
||||
- { name: "Google", period: "Three Months", frequency: 17.3 }
|
||||
- { name: "SAP", period: "Six Months", frequency: 100 }
|
||||
- { name: "tcs", period: "Six Months", frequency: 43.5 }
|
||||
- { name: "Bloomberg", period: "Six Months", frequency: 25.5 }
|
||||
sidebar:
|
||||
label: 'Search a 2D Matrix'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
::::warning
|
||||
You must write a solution in O(log(m * n)) time complexity.
|
||||
::::
|
||||
|
||||
### Example 1:
|
||||
- Input: `matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 3`
|
||||
- Output: `true`
|
||||
|
||||
### Example 2:
|
||||
- Input: `matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 13`
|
||||
- Output: `false`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `m == matrix.length`
|
||||
- `n == matrix[i].length`
|
||||
- `1 <= m, n <= 100`
|
||||
- `-10^4 <= matrix[i][j], target <= 10^4`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["searchMatrix(matrix, target)"]) --> I["top = 0, bot = ROWS - 1"]
|
||||
I --> W1{"top <= bot?"}
|
||||
W1 -- yes --> R1["row = (top + bot) // 2"]
|
||||
R1 --> C1{"target vs that row's range"}
|
||||
C1 -- "above matrix[row][-1]" --> U["top = row + 1"]
|
||||
C1 -- "below matrix[row][0]" --> V["bot = row - 1"]
|
||||
C1 -- inside --> B(["break — row found"])
|
||||
U --> W1
|
||||
V --> W1
|
||||
W1 -- no --> F(["return False — no row can hold it"])
|
||||
B --> P["row = (top + bot) // 2; l = 0, r = COLS - 1"]
|
||||
P --> W2{"l <= r?"}
|
||||
W2 -- no --> G(["return False"])
|
||||
W2 -- yes --> M["m = (l + r) // 2"]
|
||||
M --> C2{"target vs matrix[row][m]"}
|
||||
C2 -- greater --> X["l = m + 1"]
|
||||
C2 -- smaller --> Y["r = m - 1"]
|
||||
C2 -- equal --> T(["return True"])
|
||||
X --> W2
|
||||
Y --> W2
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
|
||||
ROWS, COLS = len(matrix), len(matrix[0])
|
||||
|
||||
top, bot = 0, ROWS - 1
|
||||
while top <= bot:
|
||||
row = (top + bot) // 2
|
||||
if target > matrix[row][-1]:
|
||||
top = row + 1
|
||||
elif target < matrix[row][0]:
|
||||
bot = row - 1
|
||||
else:
|
||||
break
|
||||
|
||||
if not (top <= bot):
|
||||
return False
|
||||
|
||||
row = (top + bot) // 2
|
||||
l, r = 0, COLS - 1
|
||||
while l <= r:
|
||||
m = (l + r) // 2
|
||||
if target > matrix[row][m]:
|
||||
l = m + 1
|
||||
elif target < matrix[row][m]:
|
||||
r = m - 1
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
```
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
title: '875. Koko Eating Bananas'
|
||||
description: Koko loves to eat bananas. There are n piles of bananas, the i^th pile has piles[i] bananas. The guards have gone and will come back in h hours
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Binary Search", slug: "binary-search" }
|
||||
video: "U2SozAs9RzA"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/59"
|
||||
companies:
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 69.6 }
|
||||
- { name: "Google", period: "Thirty Days", frequency: 29 }
|
||||
- { name: "Nvidia", period: "Three Months", frequency: 100 }
|
||||
- { name: "Infosys", period: "Three Months", frequency: 87.4 }
|
||||
- { name: "Meta", period: "Three Months", frequency: 62.3 }
|
||||
- { name: "Bloomberg", period: "Three Months", frequency: 54 }
|
||||
- { name: "Coupang", period: "Six Months", frequency: 100 }
|
||||
- { name: "Swiggy", period: "Six Months", frequency: 100 }
|
||||
- { name: "Accenture", period: "Six Months", frequency: 75 }
|
||||
- { name: "Microsoft", period: "Six Months", frequency: 56.9 }
|
||||
- { name: "Goldman Sachs", period: "Six Months", frequency: 44.8 }
|
||||
sidebar:
|
||||
label: 'Koko Eating Bananas'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `piles = [3,6,7,11], h = 8`
|
||||
- Output: `4`
|
||||
|
||||
### Example 2:
|
||||
- Input: `piles = [30,11,23,4,20], h = 5`
|
||||
- Output: `30`
|
||||
|
||||
### Example 3:
|
||||
- Input: `piles = [30,11,23,4,20], h = 6`
|
||||
- Output: `23`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= piles.length <= 10^4`
|
||||
- `piles.length <= h <= 10^9`
|
||||
- `1 <= piles[i] <= 10^9`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["minEatingSpeed(piles, h)"]) --> I["l = 1, r = max(piles)"]
|
||||
I --> W{"l < r?"}
|
||||
W -- no --> E(["return l — the smallest workable speed"])
|
||||
W -- yes --> M["mid = floor((l + r) / 2)"]
|
||||
M --> K["kWorks(mid): hours = sum of ceil(p / mid)"]
|
||||
K --> Q{"hours <= h?"}
|
||||
Q -- yes --> A["r = mid — keep mid, try slower"]
|
||||
Q -- no --> B["l = mid + 1 — too slow, speed up"]
|
||||
A --> W
|
||||
B --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
/**
|
||||
* @param {number[]} piles
|
||||
* @param {number} h
|
||||
* @return {number}
|
||||
*/
|
||||
var minEatingSpeed = function(piles, h) {
|
||||
// Function to determine if K works for the H
|
||||
function kWorks(k){
|
||||
let hours = 0;
|
||||
for (let p of piles){
|
||||
hours += Math.ceil(p/k)
|
||||
}
|
||||
return hours <= h;
|
||||
}
|
||||
|
||||
// Setup Binary Search
|
||||
let l = 1;
|
||||
let r = Math.max(...piles);
|
||||
|
||||
// Find the K value which works for H and consumes all bananas
|
||||
while (l < r){
|
||||
const mid = Math.floor((l + r) / 2);
|
||||
if (kWorks(mid)){
|
||||
r = mid;
|
||||
}else{
|
||||
l = mid + 1;
|
||||
}
|
||||
}
|
||||
return l;
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: Binary Search
|
||||
description: 'Half of a sorted range is removed at each step until the target is found.'
|
||||
hidden: true
|
||||
---
|
||||
|
||||
Binary search is an efficient array search algorithm. It works by narrowing down the search range by half each time. If you have looked up a word in a physical dictionary, you've already used binary search in real life. Let's look at a simple example:
|
||||
|
||||
*Given a sorted array of integers and an integer called target, find the element that equals the target and return its index. If the element is not found, return -1.*
|
||||
|
||||
The key observation here is that the array is sorted. We pick a random element in the array and compare it to the target.
|
||||
|
||||
- If we happen to pick the element that equals the target (how lucky!), then bingo. We don't need to do any more work; we return its index.
|
||||
- If the element is smaller than the target, then we know the target cannot be found in the section to the left of the current element, since everything to the left is even smaller. So, we discard the current element and everything on the left from the search range.
|
||||
- If the element is larger than the target, then we know the target cannot be found in the section to the right of the current element, since everything to the right is even larger. So, we discard the current element and everything on the right from the search range.
|
||||
|
||||
We repeat this process until we find the target. Instead of picking a random element, we always pick the **middle** element in the current search range. This way, we can discard half of the options and shrink the search range by half each time. This gives us `O(log(N))` runtime.
|
||||
|
||||
This idea can be implemented both iteratively and recursively. However, the major difference is that the iterative version of binary search uses `O(1)` memory while the recursive version uses `O(log(N))` memory.
|
||||
|
||||
## Implementation
|
||||
|
||||
The search range is represented by the `left` and `right` indices that start from both ends of the array and move towards each other as we search. When moving the index, we discard elements and shrink the search range.
|
||||
|
||||
Time Complexity: `O(log(n))`
|
||||
|
||||
|
||||
<Carousel>
|
||||
<CarouselImage src='../../../public/binary_search.001.png' />
|
||||
<CarouselImage src='../../../public/binary_search.002.png' />
|
||||
<CarouselImage src='../../../public/binary_search.003.png' />
|
||||
<CarouselImage src='../../../public/binary_search.004.png' />
|
||||
<CarouselImage src='../../../public/binary_search.005.png' />
|
||||
<CarouselImage src='../../../public/binary_search.006.png' />
|
||||
<CarouselImage src='../../../public/binary_search.007.png' />
|
||||
<CarouselImage src='../../../public/binary_search.008.png' />
|
||||
</Carousel>
|
||||
|
||||
```python
|
||||
def binary_search(arr: list[int], target: int) -> int:
|
||||
left, right = 0, len(arr) - 1
|
||||
# <= because left and right could point to the same element, < would miss it
|
||||
while left <= right:
|
||||
# double slash for integer division in python 3,
|
||||
# we don't have to worry about integer `left + right` overflow
|
||||
# since python integers can be arbitrarily large
|
||||
mid = (left + right) // 2
|
||||
# found target, return its index
|
||||
if arr[mid] == target:
|
||||
return mid
|
||||
# middle less than target, discard left half by making left search boundary `mid + 1`
|
||||
if arr[mid] < target:
|
||||
left = mid + 1
|
||||
# middle greater than target, discard right half by making right search boundary `mid - 1`
|
||||
else:
|
||||
right = mid - 1
|
||||
return -1 # if we get here we didn't hit above return so we didn't find target
|
||||
```
|
||||
|
||||
### Calculating `mid`
|
||||
|
||||
Note that when calculating `mid`, if the number of elements is even, there are two elements in the middle. We usually follow the convention of picking the first one, equivalent to doing integer division `(left + right) / 2`.
|
||||
|
||||
In most programming languages, we calculate `mid` with `left + floor((right-left) / 2)` to avoid potential integer overflow. However, in Python, we do not need to worry about integer overflow with `left + right` because Python3 integers can be arbitrarily large.
|
||||
|
||||
## Deduction
|
||||
|
||||
It's essential to understand and deduce the algorithm yourself instead of memorizing it. In an actual interview, interviewers may ask you additional questions to test your understanding, so simply memorizing the algorithm may not be enough to convince the interviewer.
|
||||
|
||||
Key elements in writing a correct binary search:
|
||||
|
||||
### 1. When to terminate the loop
|
||||
|
||||
Make sure the `while` loop includes an equality comparison; otherwise, we might skip the loop and miss the potential match for the edge case of a one-element array.
|
||||
|
||||
### 2. Whether/how to update `left` and `right` boundary in the `if` conditions
|
||||
|
||||
Consider which side to discard. If `arr[mid]` is smaller than the `target`, we should discard everything on the left by making `left = mid + 1`.
|
||||
|
||||
### 3. Should I discard the current element?
|
||||
|
||||
For vanilla binary search, we can discard it since it cannot be the final answer if it is not equal to the target. There might be situations where you would want to think twice before discarding the current element. We'll discuss this in the next module.
|
||||
|
||||
## When to use binary search
|
||||
|
||||
Interestingly, binary search works beyond sorted arrays. **You can use binary search whenever you make a binary decision to shrink the search range.**
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Binary Search", order: 5, icon: "search", display: "group" });
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: '128. Longest Consecutive Sequence'
|
||||
description: Given an unsorted array of integers nums, return the length of the longest consecutive elements sequence
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "Union-Find", slug: "union-find" }
|
||||
issue: "https://github.com/prdlk/leetcode/issues/34"
|
||||
companies:
|
||||
- { name: "tcs", period: "Thirty Days", frequency: 74.3 }
|
||||
- { name: "Bloomberg", period: "Thirty Days", frequency: 69.8 }
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 65.8 }
|
||||
- { name: "Microsoft", period: "Thirty Days", frequency: 57.2 }
|
||||
- { name: "Google", period: "Thirty Days", frequency: 53.2 }
|
||||
- { name: "Apple", period: "Three Months", frequency: 69 }
|
||||
- { name: "Meta", period: "Three Months", frequency: 34.5 }
|
||||
- { name: "Paytm", period: "Six Months", frequency: 100 }
|
||||
- { name: "Nvidia", period: "Six Months", frequency: 91.1 }
|
||||
- { name: "TikTok", period: "Six Months", frequency: 55.2 }
|
||||
- { name: "Salesforce", period: "Six Months", frequency: 50.2 }
|
||||
sidebar:
|
||||
label: 'Longest Consecutive Sequence'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
::::warning
|
||||
You must write an algorithm that runs in O(n) time.
|
||||
::::
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [100,4,200,1,3,2]`
|
||||
- Output: `4`
|
||||
- Explanation: The longest consecutive elements sequence is [1, `2`, `3`, 4]. Therefore its length is `4`.
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [0,3,7,2,5,8,4,6,0,1]`
|
||||
- Output: `9`
|
||||
|
||||
### Example 3:
|
||||
- Input: `nums = [1,0,1,2]`
|
||||
- Output: `3`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `0 <= nums.length <= 10^5`
|
||||
- `-10^9 <= nums[i] <= 10^9`
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def longestConsecutive(self, nums: list[int]) -> int:
|
||||
seen = set(nums)
|
||||
best = 0
|
||||
|
||||
for n in seen:
|
||||
if n - 1 in seen:
|
||||
continue
|
||||
|
||||
length = 1
|
||||
while length + n in seen:
|
||||
length += 1
|
||||
|
||||
best = max(length, best)
|
||||
|
||||
return best
|
||||
```
|
||||
+21
-2
@@ -1,13 +1,19 @@
|
||||
---
|
||||
title: '1832. Check if the Sentence Is Pangram'
|
||||
description: A pangram is a sentence where every letter of the English alphabet appears at least once
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "String", slug: "string" }
|
||||
companies:
|
||||
- { name: "Bloomberg", period: "Six Months", frequency: 34.2 }
|
||||
- { name: "Amazon", period: "Six Months", frequency: 14.3 }
|
||||
- { name: "Google", period: "Six Months", frequency: 11.8 }
|
||||
sidebar:
|
||||
label: 'Check if the Sentence Is Pangram'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `sentence = "thequickbrownfoxjumpsoverthelazydog"`
|
||||
- Output: `true`
|
||||
@@ -22,6 +28,19 @@ sidebar:
|
||||
- `1 <= sentence.length <= 1000`
|
||||
- `sentence` consists of lowercase English letters.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["checkIfPangram(sentence)"]) --> I["freq = {}"]
|
||||
I --> L{"more char c?"}
|
||||
L -- yes --> C["freq[c] = (freq[c] or 0) + 1"]
|
||||
C --> L
|
||||
L -- no --> Q{"distinct keys in freq == 26?"}
|
||||
Q -- yes --> R(["return true"])
|
||||
Q -- no --> F(["return false"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
title: '1. Two Sum'
|
||||
description: You are given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
video: "KLlXCFG5TnA"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/29"
|
||||
companies:
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 100 }
|
||||
- { name: "Google", period: "Thirty Days", frequency: 100 }
|
||||
- { name: "Meta", period: "Thirty Days", frequency: 100 }
|
||||
- { name: "Microsoft", period: "Thirty Days", frequency: 100 }
|
||||
- { name: "tcs", period: "Thirty Days", frequency: 79.6 }
|
||||
- { name: "Ola Cabs", period: "Thirty Days", frequency: 76.7 }
|
||||
- { name: "Bloomberg", period: "Thirty Days", frequency: 69.8 }
|
||||
- { name: "Infosys", period: "Thirty Days", frequency: 68.6 }
|
||||
- { name: "Altimetrik", period: "Three Months", frequency: 100 }
|
||||
- { name: "Capgemini", period: "Three Months", frequency: 100 }
|
||||
- { name: "Microstrategy", period: "Three Months", frequency: 100 }
|
||||
- { name: "MongoDB", period: "Three Months", frequency: 100 }
|
||||
- { name: "Cognizant", period: "Three Months", frequency: 88.7 }
|
||||
- { name: "Apple", period: "Three Months", frequency: 80.4 }
|
||||
- { name: "Barclays", period: "Six Months", frequency: 100 }
|
||||
- { name: "Cisco", period: "Six Months", frequency: 100 }
|
||||
- { name: "Deloitte", period: "Six Months", frequency: 100 }
|
||||
- { name: "Expedia", period: "Six Months", frequency: 100 }
|
||||
- { name: "NetApp", period: "Six Months", frequency: 100 }
|
||||
- { name: "Samsung", period: "Six Months", frequency: 100 }
|
||||
- { name: "Sony", period: "Six Months", frequency: 100 }
|
||||
- { name: "Oracle", period: "Six Months", frequency: 91.2 }
|
||||
- { name: "Qualcomm", period: "Six Months", frequency: 80.7 }
|
||||
- { name: "IBM", period: "Six Months", frequency: 77.2 }
|
||||
- { name: "eBay", period: "Six Months", frequency: 75.2 }
|
||||
- { name: "Walmart Labs", period: "Six Months", frequency: 70.5 }
|
||||
- { name: "Flipkart", period: "Six Months", frequency: 57.1 }
|
||||
- { name: "Goldman Sachs", period: "Six Months", frequency: 44.8 }
|
||||
sidebar:
|
||||
label: 'Two Sum'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
::::warning
|
||||
Can you come up with an algorithm that is less than O(n^2) time complexity?
|
||||
::::
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [2,7,11,15], target = 9`
|
||||
- Output: `[0,1]`
|
||||
- Explanation: Because `nums[0] + nums[1]` == `9`, we return [0, 1].
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [3,2,4], target = 6`
|
||||
- Output: `[1,2]`
|
||||
|
||||
### Example 3:
|
||||
- Input: `nums = [3,3], target = 6`
|
||||
- Output: `[0,1]`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `2 <= nums.length <= 10^4`
|
||||
- `-10^9 <= nums[i] <= 10^9`
|
||||
- `-10^9 <= target <= 10^9`
|
||||
- Only one valid answer exists.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["twoSum(nums, target)"]) --> I["seen = {} — value to index"]
|
||||
I --> L{"more (i, num) in nums?"}
|
||||
L -- no --> E(["return []"])
|
||||
L -- yes --> C["complement = target - num"]
|
||||
C --> H{"complement in seen?"}
|
||||
H -- yes --> R(["return [seen[complement], i]"])
|
||||
H -- no --> W["seen[num] = i"]
|
||||
W --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def twoSum(self, nums: List[int], target: int) -> List[int]:
|
||||
seen = {}
|
||||
for i, num in enumerate(nums):
|
||||
complement = target - num
|
||||
if complement in seen:
|
||||
return [seen[complement], i]
|
||||
seen[num] = i
|
||||
return []
|
||||
```
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: '205. Isomorphic Strings'
|
||||
description: Given two strings s and t, determine if they are isomorphic
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "String", slug: "string" }
|
||||
issue: "https://github.com/prdlk/leetcode/issues/33"
|
||||
companies:
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||
- { name: "Google", period: "Thirty Days", frequency: 36.7 }
|
||||
- { name: "Bloomberg", period: "Three Months", frequency: 54 }
|
||||
- { name: "tcs", period: "Three Months", frequency: 47.7 }
|
||||
- { name: "Microsoft", period: "Three Months", frequency: 33.4 }
|
||||
- { name: "Meta", period: "Six Months", frequency: 24.5 }
|
||||
sidebar:
|
||||
label: 'Isomorphic Strings'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "egg", t = "add"`
|
||||
- Output: `true`
|
||||
- Explanation: The strings `s` and `t` can be made identical by: • Mapping 'e' to 'a'. • Mapping 'g' to 'd'.
|
||||
|
||||
### Example 2:
|
||||
- Input: `s = "f11", t = "b23"`
|
||||
- Output: `false`
|
||||
- Explanation: The strings `s` and `t` can not be made identical as `'1`' needs to be mapped to both `'2`' and `'3`'.
|
||||
|
||||
### Example 3:
|
||||
- Input: `s = "paper", t = "title"`
|
||||
- Output: `true`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= s.length <= 5 * 10^4`
|
||||
- `t.length == s.length`
|
||||
- `s` and `t` consist of any valid ascii character.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def isIsomorphic(self, s: str, t: str) -> bool:
|
||||
N = len(t)
|
||||
seen = {}
|
||||
|
||||
for i in range(N):
|
||||
c1, c2 = s[i], t[i]
|
||||
if c1 in seen and seen[c1] != c2:
|
||||
return False
|
||||
elif c1 in seen and seen[c1] == c2:
|
||||
continue
|
||||
else:
|
||||
seen[c1] = c2
|
||||
|
||||
return len(set(s)) == len(set(t))
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: '217. Contains Duplicate'
|
||||
description: Given an integer array nums, return true if any value appears at least twice in the array, and return false if every element is distinct
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "Sorting", slug: "sorting" }
|
||||
video: "3OamzN90kPg"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/27"
|
||||
companies:
|
||||
- { name: "Meta", period: "Thirty Days", frequency: 53.8 }
|
||||
- { name: "Google", period: "Thirty Days", frequency: 50.2 }
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 48.9 }
|
||||
- { name: "Microsoft", period: "Three Months", frequency: 48.3 }
|
||||
- { name: "Bloomberg", period: "Three Months", frequency: 36.8 }
|
||||
sidebar:
|
||||
label: 'Contains Duplicate'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,2,3,1]`
|
||||
- Output: `true`
|
||||
- Explanation: The element `1` occurs at the indices `0` and `3`.
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [1,2,3,4]`
|
||||
- Output: `false`
|
||||
- Explanation: All elements are distinct.
|
||||
|
||||
### Example 3:
|
||||
- Input: `nums = [1,1,1,3,3,4,3,2,4,2]`
|
||||
- Output: `true`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= nums.length <= 10^5`
|
||||
- `-10^9 <= nums[i] <= 10^9`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["containsDuplicate(nums)"]) --> I["freq = {}"]
|
||||
I --> L{"more n in nums?"}
|
||||
L -- no --> E(["return false"])
|
||||
L -- yes --> C["freq[n] = (freq[n] or 0) + 1"]
|
||||
C --> Q{"freq[n] >= 2?"}
|
||||
Q -- yes --> R(["return true"])
|
||||
Q -- no --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def containsDuplicate(self, nums: List[int]) -> bool:
|
||||
return len(set(nums)) != len(nums)
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: '242. Valid Anagram'
|
||||
description: Given two strings s and t, return true if t is an anagram of s, and false otherwise
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "String", slug: "string" }
|
||||
- { name: "Sorting", slug: "sorting" }
|
||||
video: "9UtInBqnCgA"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/28"
|
||||
companies:
|
||||
- { name: "Microsoft", period: "Thirty Days", frequency: 67.9 }
|
||||
- { name: "tcs", period: "Thirty Days", frequency: 58.2 }
|
||||
- { name: "Google", period: "Thirty Days", frequency: 53.2 }
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 48.9 }
|
||||
- { name: "IBM", period: "Three Months", frequency: 100 }
|
||||
- { name: "Infosys", period: "Three Months", frequency: 66 }
|
||||
- { name: "Bloomberg", period: "Three Months", frequency: 64.4 }
|
||||
- { name: "Meta", period: "Three Months", frequency: 58.9 }
|
||||
- { name: "Deloitte", period: "Six Months", frequency: 81 }
|
||||
sidebar:
|
||||
label: 'Valid Anagram'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
::::warning
|
||||
What if the inputs contain Unicode characters? How would you adapt your solution to such a case?
|
||||
::::
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "anagram", t = "nagaram"`
|
||||
- Output: `true`
|
||||
|
||||
### Example 2:
|
||||
- Input: `s = "rat", t = "car"`
|
||||
- Output: `false`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= s.length, t.length <= 5 * 10^4`
|
||||
- `s` and `t` consist of lowercase English letters.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["isAnagram(s, t)"]) --> I["freq: count every char of s"]
|
||||
I --> L{"more char c in t?"}
|
||||
L -- no --> E(["return true"])
|
||||
L -- yes --> Q{"freq[c] missing or 0?"}
|
||||
Q -- yes --> R(["return false — t has a char s cannot cover"])
|
||||
Q -- no --> D["freq[c] -= 1"]
|
||||
D --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
/**
|
||||
* @param {string} s
|
||||
* @param {string} t
|
||||
* @return {boolean}
|
||||
*/
|
||||
var isAnagram = function(s, t) {
|
||||
let freq = {};
|
||||
for (let c of s) freq[c] = (freq[c] || 0) + 1;
|
||||
|
||||
for (let c of t) {
|
||||
if(!freq[c] || freq[c] === 0){
|
||||
return false;
|
||||
}
|
||||
freq[c] = freq[c] - 1;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: '290. Word Pattern'
|
||||
description: Given a pattern and a string s, find if s follows the same pattern
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "String", slug: "string" }
|
||||
issue: "https://github.com/prdlk/leetcode/issues/35"
|
||||
companies:
|
||||
- { name: "Google", period: "Three Months", frequency: 17.3 }
|
||||
- { name: "Microsoft", period: "Six Months", frequency: 24.1 }
|
||||
- { name: "Amazon", period: "Six Months", frequency: 20.9 }
|
||||
sidebar:
|
||||
label: 'Word Pattern'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `pattern = "abba", s = "dog cat cat dog"`
|
||||
- Output: `true`
|
||||
- Explanation: The bijection can be established as: • 'a' maps to "dog". • 'b' maps to "cat".
|
||||
|
||||
### Example 2:
|
||||
- Input: `pattern = "abba", s = "dog cat cat fish"`
|
||||
- Output: `false`
|
||||
|
||||
### Example 3:
|
||||
- Input: `pattern = "aaaa", s = "dog cat cat dog"`
|
||||
- Output: `false`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= pattern.length <= 300`
|
||||
- `pattern` contains only lower-case English letters.
|
||||
- `1 <= s.length <= 3000`
|
||||
- `s` contains only lowercase English letters and spaces ' '.
|
||||
- `s` does not contain any leading or trailing spaces.
|
||||
- All the words in `s` are separated by a single space.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def wordPattern(self, pattern: str, s: str) -> bool:
|
||||
if len(pattern) != len(s.split()):
|
||||
return False
|
||||
|
||||
words = s.split()
|
||||
map = {}
|
||||
|
||||
for i in range(len(pattern)):
|
||||
c = pattern[i]
|
||||
w = words[i]
|
||||
|
||||
if c in map and map[c] != w:
|
||||
return False
|
||||
else:
|
||||
map[c] = w
|
||||
|
||||
return len(set(pattern)) == len(set(words))
|
||||
```
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: '347. Top K Frequent Elements'
|
||||
description: Given an integer array nums and an integer k, return the k most frequent elements. You may return the answer in any order
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "Divide and Conquer", slug: "divide-and-conquer" }
|
||||
- { name: "Sorting", slug: "sorting" }
|
||||
- { name: "Heap (Priority Queue)", slug: "heap-priority-queue" }
|
||||
- { name: "Bucket Sort", slug: "bucket-sort" }
|
||||
- { name: "Counting", slug: "counting" }
|
||||
- { name: "Quickselect", slug: "quickselect" }
|
||||
video: "YPTqKIgVk-k"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/31"
|
||||
companies:
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 72.9 }
|
||||
- { name: "Microsoft", period: "Thirty Days", frequency: 67.9 }
|
||||
- { name: "Google", period: "Thirty Days", frequency: 42.3 }
|
||||
- { name: "Bloomberg", period: "Three Months", frequency: 54 }
|
||||
- { name: "Snap", period: "Six Months", frequency: 100 }
|
||||
- { name: "SoFi", period: "Six Months", frequency: 100 }
|
||||
- { name: "PayPal", period: "Six Months", frequency: 88.4 }
|
||||
- { name: "Walmart Labs", period: "Six Months", frequency: 81.4 }
|
||||
- { name: "Nvidia", period: "Six Months", frequency: 78.7 }
|
||||
- { name: "eBay", period: "Six Months", frequency: 75.2 }
|
||||
- { name: "Visa", period: "Six Months", frequency: 69.7 }
|
||||
- { name: "Snowflake", period: "Six Months", frequency: 58.7 }
|
||||
- { name: "TikTok", period: "Six Months", frequency: 55.2 }
|
||||
- { name: "Meta", period: "Six Months", frequency: 50.6 }
|
||||
- { name: "Apple", period: "Six Months", frequency: 48.1 }
|
||||
- { name: "tcs", period: "Six Months", frequency: 43.5 }
|
||||
sidebar:
|
||||
label: 'Top K Frequent Elements'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
::::warning
|
||||
Your algorithm's time complexity must be better than O(n log n), where n is the array's size.
|
||||
::::
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,1,1,2,2,3], k = 2`
|
||||
- Output: `[1,2]`
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [1], k = 1`
|
||||
- Output: `[1]`
|
||||
|
||||
### Example 3:
|
||||
- Input: `nums = [1,2,1,2,1,2,3,1,3,2], k = 2`
|
||||
- Output: `[1,2]`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= nums.length <= 10^5`
|
||||
- `-10^4 <= nums[i] <= 10^4`
|
||||
- `k` is in the range `[1, the number of unique elements in the array]`.
|
||||
- It is guaranteed that the answer is unique.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["topKFrequent(nums, k)"]) --> F["freq: count every value in nums"]
|
||||
F --> K["keys of freq, cast to Number"]
|
||||
K --> O["sort by freq[b] - freq[a] — most frequent first"]
|
||||
O --> R(["return slice(0, k)"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
/**
|
||||
* @param {number[]} nums
|
||||
* @param {number} k
|
||||
* @return {number[]}
|
||||
*/
|
||||
var topKFrequent = function (nums, k) {
|
||||
const freq = {};
|
||||
for (let n of nums) freq[n] = (freq[n] || 0) + 1;
|
||||
|
||||
return Object.keys(freq)
|
||||
.map(Number)
|
||||
.sort((a, b) => freq[b] - freq[a])
|
||||
.slice(0, k);
|
||||
};
|
||||
```
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: '387. First Unique Character in a String'
|
||||
description: Given a string s, find the first non-repeating character in it and return its index. If it does not exist, return -1
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "String", slug: "string" }
|
||||
- { name: "Queue", slug: "queue" }
|
||||
- { name: "Counting", slug: "counting" }
|
||||
video: "rBENYgWy3xU"
|
||||
companies:
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 48.9 }
|
||||
- { name: "Yandex", period: "Three Months", frequency: 76.1 }
|
||||
- { name: "tcs", period: "Three Months", frequency: 73.7 }
|
||||
- { name: "Bloomberg", period: "Three Months", frequency: 46.8 }
|
||||
- { name: "Meta", period: "Three Months", frequency: 43.3 }
|
||||
- { name: "Google", period: "Three Months", frequency: 23.9 }
|
||||
- { name: "EPAM Systems", period: "Six Months", frequency: 76.2 }
|
||||
- { name: "Goldman Sachs", period: "Six Months", frequency: 59 }
|
||||
- { name: "Microsoft", period: "Six Months", frequency: 32 }
|
||||
sidebar:
|
||||
label: 'First Unique Character in a String'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "leetcode"`
|
||||
- Output: `0`
|
||||
- Explanation: The character `'l'` at index `0` is the first character that does not occur at any other index.
|
||||
|
||||
### Example 2:
|
||||
- Input: `s = "loveleetcode"`
|
||||
- Output: `2`
|
||||
|
||||
### Example 3:
|
||||
- Input: `s = "aabb"`
|
||||
- Output: `-1`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= s.length <= 10^5`
|
||||
- `s` consists of only lowercase English letters.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["firstUniqChar(s)"]) --> F["Pass 1 — freq: count every char of s"]
|
||||
F --> L{"Pass 2 — more i in 0..len-1?"}
|
||||
L -- no --> E(["return -1"])
|
||||
L -- yes --> Q{"freq[s[i]] == 1?"}
|
||||
Q -- yes --> R(["return i — leftmost wins because the scan is in order"])
|
||||
Q -- no --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
/**
|
||||
* @param {string} s
|
||||
* @return {number}
|
||||
*/
|
||||
var firstUniqChar = function (s) {
|
||||
const freq = {};
|
||||
for (let c of s) freq[c] = (freq[c] || 0) + 1;
|
||||
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (freq[s[i]] === 1) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
```
|
||||
+27
-2
@@ -1,13 +1,26 @@
|
||||
---
|
||||
title: '451. Sort Characters By Frequency'
|
||||
description: Given a string s, sort it in decreasing order based on the frequency of the characters. The frequency of a character is the number of times it appears in the string
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "String", slug: "string" }
|
||||
- { name: "Sorting", slug: "sorting" }
|
||||
- { name: "Heap (Priority Queue)", slug: "heap-priority-queue" }
|
||||
- { name: "Bucket Sort", slug: "bucket-sort" }
|
||||
- { name: "Counting", slug: "counting" }
|
||||
video: "OXdXc9HTrIg"
|
||||
companies:
|
||||
- { name: "Meta", period: "Three Months", frequency: 49.8 }
|
||||
- { name: "Microsoft", period: "Three Months", frequency: 48.3 }
|
||||
- { name: "Bloomberg", period: "Three Months", frequency: 46.8 }
|
||||
- { name: "Google", period: "Three Months", frequency: 17.3 }
|
||||
- { name: "Amazon", period: "Six Months", frequency: 20.9 }
|
||||
sidebar:
|
||||
label: 'Sort Characters By Frequency'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "tree"`
|
||||
- Output: `"eert"`
|
||||
@@ -28,6 +41,18 @@ sidebar:
|
||||
- `1 <= s.length <= 5 * 10^5`
|
||||
- `s` consists of uppercase and lowercase English letters and digits.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["frequencySort(s)"]) --> F["freq: count every char of s"]
|
||||
F --> C["split s and sort with a two-key comparator"]
|
||||
C --> K1["primary: freq[b] - freq[a] — most frequent first"]
|
||||
C --> K2["tie-break: a.localeCompare(b) — alphabetical"]
|
||||
K1 --> R(["join and return"])
|
||||
K2 --> R
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
title: '49. Group Anagrams'
|
||||
description: Given an array of strings strs, group the anagrams together. You can return the answer in any order
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "String", slug: "string" }
|
||||
- { name: "Sorting", slug: "sorting" }
|
||||
video: "vzdNOK2oB2E"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/30"
|
||||
companies:
|
||||
- { name: "Apple", period: "Thirty Days", frequency: 100 }
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 78.4 }
|
||||
- { name: "Ola Cabs", period: "Thirty Days", frequency: 76.7 }
|
||||
- { name: "Bloomberg", period: "Thirty Days", frequency: 69.8 }
|
||||
- { name: "tcs", period: "Thirty Days", frequency: 58.2 }
|
||||
- { name: "Meta", period: "Thirty Days", frequency: 53.8 }
|
||||
- { name: "Google", period: "Thirty Days", frequency: 50.2 }
|
||||
- { name: "CrowdStrike", period: "Three Months", frequency: 100 }
|
||||
- { name: "Deloitte", period: "Three Months", frequency: 100 }
|
||||
- { name: "HashedIn", period: "Three Months", frequency: 100 }
|
||||
- { name: "Smartsheet", period: "Three Months", frequency: 100 }
|
||||
- { name: "Walmart Labs", period: "Three Months", frequency: 100 }
|
||||
- { name: "Microsoft", period: "Three Months", frequency: 33.4 }
|
||||
- { name: "Expedia", period: "Six Months", frequency: 100 }
|
||||
- { name: "Wipro", period: "Six Months", frequency: 100 }
|
||||
- { name: "Adobe", period: "Six Months", frequency: 93.7 }
|
||||
- { name: "Zoho", period: "Six Months", frequency: 79.6 }
|
||||
- { name: "Nvidia", period: "Six Months", frequency: 78.7 }
|
||||
- { name: "Oracle", period: "Six Months", frequency: 78.7 }
|
||||
- { name: "EPAM Systems", period: "Six Months", frequency: 76.2 }
|
||||
- { name: "Palo Alto Networks", period: "Six Months", frequency: 75.9 }
|
||||
- { name: "Visa", period: "Six Months", frequency: 69.7 }
|
||||
- { name: "Infosys", period: "Six Months", frequency: 67.9 }
|
||||
- { name: "Goldman Sachs", period: "Six Months", frequency: 53.1 }
|
||||
sidebar:
|
||||
label: 'Group Anagrams'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `strs = ["eat","tea","tan","ate","nat","bat"]`
|
||||
- Output: `[["bat"],["nat","tan"],["ate","eat","tea"]]`
|
||||
- Explanation: There is no string in `strs` that can be rearranged to form `"bat"`. The strings `"nat"` and `"tan"` are anagrams as they can be rearranged to form each other. The strings `"ate"`, `"eat"`, and `"tea"` are anagrams as they can be rearranged to form each other.
|
||||
|
||||
### Example 2:
|
||||
- Input: `strs = [""]`
|
||||
- Output: `[[""]]`
|
||||
|
||||
### Example 3:
|
||||
- Input: `strs = ["a"]`
|
||||
- Output: `[["a"]]`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= strs.length <= 10^4`
|
||||
- `0 <= strs[i].length <= 100`
|
||||
- `strs[i]` consists of lowercase English letters.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["groupAnagrams(strs)"]) --> K["sorted[i] = letters of strs[i], sorted — the anagram key"]
|
||||
K --> I["anagrams = {}"]
|
||||
I --> L{"more i in 0..n-1?"}
|
||||
L -- no --> E(["return Object.values(anagrams)"])
|
||||
L -- yes --> Q{"anagrams has sorted[i]?"}
|
||||
Q -- no --> N["anagrams[sorted[i]] = [strs[i]]"]
|
||||
Q -- yes --> P["anagrams[sorted[i]].push(strs[i])"]
|
||||
N --> L
|
||||
P --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
/**
|
||||
* @param {string[]} strs
|
||||
* @return {string[][]}
|
||||
*/
|
||||
var groupAnagrams = function(strs) {
|
||||
let sorted = strs.map(str => str.split("").sort().join(""));
|
||||
let anagrams = {};
|
||||
|
||||
for (let i = 0; i < strs.length; i++){
|
||||
if(!anagrams[sorted[i]]){
|
||||
anagrams[sorted[i]] = [strs[i]]
|
||||
}else{
|
||||
anagrams[sorted[i]].push(strs[i])
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(anagrams);
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Hash Table
|
||||
description: 'A key maps to a value in constant time, which removes the need for a full scan.'
|
||||
hidden: true
|
||||
---
|
||||
|
||||
Sometimes when programming, we need a data structure that maps an arbitrary data type to another arbitrary data type, like an array with non-integer indices. For example, a phone book that allows lookup by a person's name requires such data type.
|
||||
|
||||
A **hash table** (or a hash map) is one way of representing this data structure. The idea of hash table is this:
|
||||
|
||||
Data is stored in a fix-sized array, where each data value has an unique index. When we add a new `(key, value)` pair to the array, we use a hash function to generate a hash value within the range of the array with the `key`. We then store the data `value` at the index indicated by the generated hash value. Later, when we retrieve the data with the `key` from the hash table, we hash the `key` again and look up the data in the array with the corresponding index. Since the hash function always returns the same hash values with the same input, we will always find the correct item in that index.
|
||||
|
||||
|
||||
<Frame caption="Hash Table Operations">
|
||||
|
||||

|
||||
|
||||
</Frame>
|
||||
|
||||
|
||||
## Efficiency
|
||||
|
||||
Assuming the hash function uses constant time, the average time complexity is `O(n / k)` for each insertion/access operation on the table, where `n` is the number of entries in the hash table and `k` is the array size of the table. This assumes the hash function distributes keys uniformly across the `k` buckets, so each bucket holds about `n / k` entries on average.
|
||||
|
||||
### Frequency Counting
|
||||
|
||||
```python
|
||||
def get_counter(arr: list[int]) -> dict[int, int]:
|
||||
# initialize a hash map to store each number and its count
|
||||
counter: dict[int, int] = {}
|
||||
for num in arr:
|
||||
# check if num is a key in the hash map
|
||||
if num in counter:
|
||||
# update the count of num to increase by 1
|
||||
counter[num] += 1
|
||||
else:
|
||||
# set the count of num to 1
|
||||
counter[num] = 1
|
||||
return counter
|
||||
```
|
||||
|
||||
:::tip
|
||||
Python provides a built-in [`collections.Counter`](https://docs.python.org/3/library/collections.html#collections.Counter) class that does this for us.
|
||||
:::
|
||||
|
||||
```python
|
||||
from collections import Counter
|
||||
|
||||
fq = Counter(arr)
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Hash Table", order: 1, icon: "hash", display: "group" });
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Linear Structures", order: 1 });
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: '496. Next Greater Element I'
|
||||
description: The next greater element of some element x in an array is the first greater element that is to the right of x in the same array
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Hash Table", slug: "hash-table" }
|
||||
- { name: "Stack", slug: "stack" }
|
||||
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
||||
issue: "https://github.com/prdlk/leetcode/issues/74"
|
||||
companies:
|
||||
- { name: "Google", period: "Thirty Days", frequency: 46.6 }
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||
- { name: "Bloomberg", period: "Three Months", frequency: 59.7 }
|
||||
- { name: "Meta", period: "Three Months", frequency: 43.3 }
|
||||
- { name: "Microsoft", period: "Three Months", frequency: 33.4 }
|
||||
- { name: "tcs", period: "Six Months", frequency: 43.5 }
|
||||
sidebar:
|
||||
label: 'Next Greater Element I'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
::::warning
|
||||
Could you find an O(nums1.length + nums2.length) solution?
|
||||
::::
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums1 = [4,1,2], nums2 = [1,3,4,2]`
|
||||
- Output: `[-1,3,-1]`
|
||||
- Explanation:
|
||||
- The next greater element for each value of `nums1` is as follows:
|
||||
- `4` is underlined in `nums2 = [1,3,4,2]`. There is no next greater element, so the answer is `-1`.
|
||||
- `1` is underlined in `nums2 = [1,3,4,2]`. The next greater element is `3`.
|
||||
- `2` is underlined in `nums2 = [1,3,4,2]`. There is no next greater element, so the answer is `-1`.
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums1 = [2,4], nums2 = [1,2,3,4]`
|
||||
- Output: `[3,-1]`
|
||||
- Explanation:
|
||||
- The next greater element for each value of `nums1` is as follows:
|
||||
- `2` is underlined in `nums2 = [1,2,3,4]`. The next greater element is `3`.
|
||||
- `4` is underlined in `nums2 = [1,2,3,4]`. There is no next greater element, so the answer is `-1`.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= nums1.length <= nums2.length <= 1000`
|
||||
- `0 <= nums1[i], nums2[i] <= 10^4`
|
||||
- All integers in `nums1` and `nums2` are unique.
|
||||
- All the integers of `nums1` also appear in nums2.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def nextGreaterElement(self, nums1: list[int], nums2: list[int]) -> list[int]:
|
||||
mono_stack = []
|
||||
next_greater = {}
|
||||
|
||||
for cur in nums2:
|
||||
while mono_stack and cur > mono_stack[-1]:
|
||||
val = mono_stack.pop()
|
||||
next_greater[val] = cur
|
||||
mono_stack.append(cur)
|
||||
|
||||
return [next_greater.get(x, -1) for x in nums1]
|
||||
```
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: '739. Daily Temperatures'
|
||||
description: Given an array of integers temperatures represents the daily temperatures, return an array answer such that answer[i] is the number of days you have to wait after the i^th day to get a warmer temperature. If there is no future day for which this is possible, keep answer[i] == 0 instead
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Stack", slug: "stack" }
|
||||
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
||||
video: "cTBiBSnjO3c"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/72"
|
||||
companies:
|
||||
- { name: "Google", period: "Thirty Days", frequency: 29 }
|
||||
- { name: "Chime", period: "Three Months", frequency: 100 }
|
||||
- { name: "Bloomberg", period: "Three Months", frequency: 59.7 }
|
||||
- { name: "Amazon", period: "Three Months", frequency: 42.8 }
|
||||
- { name: "Meta", period: "Three Months", frequency: 34.5 }
|
||||
- { name: "Anduril", period: "Six Months", frequency: 92.8 }
|
||||
- { name: "Goldman Sachs", period: "Six Months", frequency: 44.8 }
|
||||
- { name: "tcs", period: "Six Months", frequency: 43.5 }
|
||||
- { name: "Microsoft", period: "Six Months", frequency: 42.5 }
|
||||
sidebar:
|
||||
label: 'Daily Temperatures'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `temperatures = [73,74,75,71,69,72,76,73]`
|
||||
- Output: `[1,1,4,2,1,1,0,0]`
|
||||
|
||||
### Example 2:
|
||||
- Input: `temperatures = [30,40,50,60]`
|
||||
- Output: `[1,1,1,0]`
|
||||
|
||||
### Example 3:
|
||||
- Input: `temperatures = [30,60,90]`
|
||||
- Output: `[1,1,0]`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= temperatures.length <= 10^5`
|
||||
- `30 <= temperatures[i] <= 100`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["dailyTemperatures(temperatures)"]) --> I["ans = zeros(n), stack = [] — indices of days still waiting"]
|
||||
I --> L{"more (i, temp)?"}
|
||||
L -- no --> E(["return ans"])
|
||||
L -- yes --> W{"stack non-empty and temperatures[stack[-1]] < temp?"}
|
||||
W -- yes --> P["prev = stack.pop(); ans[prev] = i - prev — today resolves that day"]
|
||||
P --> W
|
||||
W -- no --> A["stack.append(i)"]
|
||||
A --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
|
||||
ans = [0] * len(temperatures)
|
||||
stack = []
|
||||
for i, temp in enumerate(temperatures):
|
||||
while stack and temperatures[stack[-1]] < temp:
|
||||
prev = stack.pop()
|
||||
ans[prev] = i - prev
|
||||
stack.append(i)
|
||||
return ans
|
||||
```
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: '84. Largest Rectangle in Histogram'
|
||||
description: Given an array of integers heights representing the histogram's bar height where the width of each bar is 1, return the area of the largest rectangle in the histogram
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Stack", slug: "stack" }
|
||||
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
||||
- { name: "Range Minimum/Maximum Query", slug: "range-minimum-maximum-query" }
|
||||
issue: "https://github.com/prdlk/leetcode/issues/76"
|
||||
companies:
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||
- { name: "Google", period: "Thirty Days", frequency: 29 }
|
||||
- { name: "Accenture", period: "Three Months", frequency: 88.6 }
|
||||
- { name: "tcs", period: "Three Months", frequency: 73.7 }
|
||||
- { name: "Microsoft", period: "Three Months", frequency: 42.1 }
|
||||
- { name: "Bloomberg", period: "Three Months", frequency: 36.8 }
|
||||
- { name: "Meta", period: "Three Months", frequency: 34.5 }
|
||||
sidebar:
|
||||
label: 'Largest Rectangle in Histogram'
|
||||
badge: 'Hard'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `heights = [2,1,5,6,2,3]`
|
||||
- Output: `10`
|
||||
- Explanation: The above is a histogram where width of each bar is `1`. The largest rectangle is shown in the red area, which has an area = `10` units.
|
||||
|
||||
### Example 2:
|
||||
- Input: `heights = [2,4]`
|
||||
- Output: `4`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= heights.length <= 10^5`
|
||||
- `0 <= heights[i] <= 10^4`
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def largestRectangleArea(self, heights: list[int]) -> int:
|
||||
maxArea = 0
|
||||
stack = []
|
||||
|
||||
for i, h in enumerate(heights):
|
||||
start = i
|
||||
while stack and stack[-1][1] > h:
|
||||
index, height = stack.pop()
|
||||
width = i - index
|
||||
maxArea = max(maxArea, height * width)
|
||||
start = index
|
||||
stack.append((start, h))
|
||||
|
||||
for i, h in stack:
|
||||
maxArea = max(maxArea, h * (len(heights) - i))
|
||||
|
||||
return maxArea
|
||||
```
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: '853. Car Fleet'
|
||||
description: There are n cars at given miles away from the starting mile 0, traveling to reach the mile target
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Stack", slug: "stack" }
|
||||
- { name: "Sorting", slug: "sorting" }
|
||||
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
||||
video: "Pr6T-3yB9RM"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/73"
|
||||
companies:
|
||||
- { name: "Google", period: "Three Months", frequency: 17.3 }
|
||||
- { name: "Amazon", period: "Six Months", frequency: 26 }
|
||||
sidebar:
|
||||
label: 'Car Fleet'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: `target = 12, position = [10,8,0,5,3], speed = [2,4,1,1,3]`
|
||||
- Output: `3`
|
||||
- Explanation: • The cars starting at `10 (speed 2`) and `8 (speed 4`) become a fleet, meeting each other at `12`. The fleet forms at `target`. • The car starting at `0 (speed 1`) does not catch up to any other car, so it is a fleet by itself. • The cars starting at `5 (speed 1`) and `3 (speed 3`) become a fleet, meeting each other at `6`. The fleet moves at `speed 1` until it reaches `target`.
|
||||
|
||||
### Example 2:
|
||||
- Input: `target = 10, position = [3], speed = [3]`
|
||||
- Output: `1`
|
||||
- Explanation: There is only one car, hence there is only one fleet.
|
||||
|
||||
### Example 3:
|
||||
- Input: `target = 100, position = [0,2,4], speed = [4,2,1]`
|
||||
- Output: `1`
|
||||
- Explanation: • The cars starting at `0 (speed 4`) and `2 (speed 2`) become a fleet, meeting each other at `4`. The car starting at `4 (speed 1`) travels to `5`. • Then, the fleet at `4 (speed 2`) and the car at `position 5 (speed 1`) become one fleet, meeting each other at `6`. The fleet moves at `speed 1` until it reaches `target`.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `n == position.length == speed.length`
|
||||
- `1 <= n <= 10^5`
|
||||
- `0 < target <= 10^6`
|
||||
- `0 <= position[i] < target`
|
||||
- All the values of `position` are unique.
|
||||
- `0 < speed[i] <= 10^6`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["carFleet(target, position, speed)"]) --> O["zip and sort by position, descending — nearest the target first"]
|
||||
O --> I["stack = [] — arrival time of each fleet's leader"]
|
||||
I --> L{"more (pos, spd)?"}
|
||||
L -- no --> E(["return len(stack) — one entry per fleet"])
|
||||
L -- yes --> T["time = (target - pos) / spd"]
|
||||
T --> Q{"stack empty or time > stack[-1]?"}
|
||||
Q -- yes --> A["stack.append(time) — slower, so it starts a new fleet"]
|
||||
Q -- no --> B["discard — it catches the car ahead and joins that fleet"]
|
||||
A --> L
|
||||
B --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def carFleet(self, target: int, position: List[int], speed: List[int]) -> int:
|
||||
stack = []
|
||||
for pos, spd in sorted(zip(position, speed), reverse=True):
|
||||
time = (target - pos) / spd
|
||||
if not stack or time > stack[-1]:
|
||||
stack.append(time)
|
||||
|
||||
return len(stack)
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: '901. Online Stock Span'
|
||||
description: Design an algorithm that collects daily price quotes for some stock and returns the span of that stock's price for the current day
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Stack", slug: "stack" }
|
||||
- { name: "Design", slug: "design" }
|
||||
- { name: "Monotonic Stack", slug: "monotonic-stack" }
|
||||
- { name: "Data Stream", slug: "data-stream" }
|
||||
issue: "https://github.com/prdlk/leetcode/issues/75"
|
||||
companies:
|
||||
- { name: "Meta", period: "Three Months", frequency: 34.5 }
|
||||
- { name: "Google", period: "Three Months", frequency: 17.3 }
|
||||
- { name: "Amazon", period: "Six Months", frequency: 26 }
|
||||
- { name: "Bloomberg", period: "Six Months", frequency: 25.5 }
|
||||
sidebar:
|
||||
label: 'Online Stock Span'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
### Example 1:
|
||||
- Input: ``
|
||||
- Output: ``
|
||||
- Explanation: Input ["StockSpanner", "next", "next", "next", "next", "next", "next", "next"] `[[]`, `[100]`, `[80]`, `[60]`, `[70]`, `[60]`, `[75]`, [85]] Output [null, `1`, `1`, `1`, `2`, `1`, `4`, 6] Explanation StockSpanner stockSpanner = new StockSpanner(); stockSpanner.next(100); // return `1` stockSpanner.next(80); // return `1` stockSpanner.next(60); // return `1` stockSpanner.next(70); // return `2` stockSpanner.next(60); // return `1` stockSpanner.next(75); // return `4`, because the last `4` prices (including today's `price` of `75`) were less than or equal to today's `price`. stockSpanner.next(85); // return `6`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= price <= 10^5`
|
||||
- At most 10^4 calls will be made to next.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class StockSpanner:
|
||||
def __init__(self):
|
||||
self.stack = []
|
||||
|
||||
def next(self, price: int) -> int:
|
||||
ans = 1
|
||||
while self.stack and self.stack[-1][0] <= price:
|
||||
ans += self.stack.pop()[1]
|
||||
|
||||
self.stack.append([price, ans])
|
||||
return ans
|
||||
|
||||
|
||||
# Your StockSpanner object will be instantiated and called as such:
|
||||
# obj = StockSpanner()
|
||||
# param_1 = obj.next(price)
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Monotonic Stack
|
||||
description: 'A stack keeps its items in sorted order, which finds the next larger or smaller item in one pass.'
|
||||
hidden: true
|
||||
---
|
||||
|
||||
|
||||
<YouTube id="Dq_ObZwTY_Q" title="Monotonic Stack" />
|
||||
|
||||
A monotonic stack keeps its values in sorted order as you push elements. The order can be increasing or decreasing, depending on the problem.
|
||||
|
||||
A decreasing monotonic stack is the most common version. Before pushing a new value `x`, we pop from the top while the top is smaller than or equal to `x`. After those pops, pushing `x` keeps the stack decreasing from bottom to top.
|
||||
|
||||
This push rule is the whole idea. A monotonic deque uses the same rule, but a deque also lets you remove old elements from the front, which is useful for sliding window problems.
|
||||
|
||||
## Example Implementation
|
||||
|
||||
This implementation maintains a decreasing monotonic stack.
|
||||
|
||||
```python
|
||||
def mono_stack(insert_entries):
|
||||
stack = []
|
||||
for entry in insert_entries:
|
||||
# Pop smaller (or equal) values so stack stays decreasing.
|
||||
while stack and stack[-1] <= entry:
|
||||
stack.pop()
|
||||
# Do something with the popped item here
|
||||
stack.append(entry)
|
||||
```
|
||||
|
||||
:::tip
|
||||
In interview problems, the stack usually stores indices instead of values. You can read the value from the original array when needed, and the index gives you distance information such as **"how many steps away is the next greater element."**
|
||||
:::
|
||||
|
||||
For an array `nums`, if index `j` pops when you process index `i`, then `i` is the first index to the right that satisfies the stack condition for `j`. In a next-greater-element problem, that means `nums[i]` is the next greater value for `nums[j]`.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Monotonic Stack", order: 7, icon: "trending-up", display: "group" });
|
||||
+25
-2
@@ -1,13 +1,25 @@
|
||||
---
|
||||
title: '238. Product of Array Except Self'
|
||||
description: Given an integer array nums, return an array answer such that answer[i] is equal to the product of all the elements of nums except nums[i]
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Prefix Sum", slug: "prefix-sum" }
|
||||
video: "bNvIQI2wAjk"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/37"
|
||||
companies:
|
||||
- { name: "Meta", period: "Thirty Days", frequency: 70.8 }
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 61.3 }
|
||||
- { name: "Microsoft", period: "Thirty Days", frequency: 57.2 }
|
||||
- { name: "Google", period: "Thirty Days", frequency: 42.3 }
|
||||
- { name: "Bloomberg", period: "Three Months", frequency: 59.7 }
|
||||
- { name: "Asana", period: "Six Months", frequency: 100 }
|
||||
- { name: "tcs", period: "Six Months", frequency: 43.5 }
|
||||
sidebar:
|
||||
label: 'Product of Array Except Self'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Prefix Sum</Badge>
|
||||
|
||||
::::warning
|
||||
You must write an algorithm that runs in O(n) time and without using the division operation.
|
||||
Can you solve the problem in O(1) extra space complexity? (The output array does not count as extra space for space complexity analysis.)
|
||||
@@ -27,6 +39,17 @@ Can you solve the problem in O(1) extra space complexity? (The output array does
|
||||
- `-30 <= nums[i] <= 30`
|
||||
- The input is generated such that `answer[i]` is guaranteed to fit in a 32-bit integer.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["productExceptSelf(nums)"]) --> I["answer = ones(n), rightArr = ones(n)"]
|
||||
I --> P1["Pass 1 — left to right: answer[i] = nums[i-1] * answer[i-1]"]
|
||||
P1 --> P2["Pass 2 — right to left: rightArr[i] = nums[i+1] * rightArr[i+1]"]
|
||||
P2 --> P3["Pass 3: answer[i] *= rightArr[i]"]
|
||||
P3 --> E(["return answer — no division needed"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
+27
-2
@@ -1,13 +1,23 @@
|
||||
---
|
||||
title: '303. Range Sum Query - Immutable'
|
||||
description: Given an integer array nums, handle multiple queries of the following type
|
||||
icon: dot
|
||||
topics:
|
||||
- { name: "Array", slug: "array" }
|
||||
- { name: "Design", slug: "design" }
|
||||
- { name: "Prefix Sum", slug: "prefix-sum" }
|
||||
video: "2pndAmo_sMA"
|
||||
issue: "https://github.com/prdlk/leetcode/issues/36"
|
||||
companies:
|
||||
- { name: "Google", period: "Thirty Days", frequency: 42.3 }
|
||||
- { name: "Amazon", period: "Thirty Days", frequency: 39.1 }
|
||||
- { name: "Meta", period: "Six Months", frequency: 24.5 }
|
||||
- { name: "Microsoft", period: "Six Months", frequency: 24.1 }
|
||||
sidebar:
|
||||
label: 'Range Sum Query - Immutable'
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Prefix Sum</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `["NumArray", "sumRange", "sumRange", "sumRange"]` `[[[-2, 0, 3, -5, 2, -1]], [0, 2], [2, 5], [0, 5]]`
|
||||
- Output: `[null, 1, -1, -3]`
|
||||
@@ -20,6 +30,21 @@ sidebar:
|
||||
- `0 <= left <= right < nums.length`
|
||||
- At most `10^4` calls will be made to `sumRange`.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph build["constructor(nums) — O(n) once"]
|
||||
A["prefix = [0]"] --> B{"more n in nums?"}
|
||||
B -- yes --> C["prefix.push(last + n)"]
|
||||
C --> B
|
||||
end
|
||||
build --> Q
|
||||
subgraph Q["sumRange(left, right) — O(1) per call"]
|
||||
D(["return prefix[right + 1] - prefix[left]"])
|
||||
end
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
Loaded 100 of 434 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user