Compare commits

...
Author SHA1 Message Date
Prad Nukala 00f44a1c47 docs(docs): add MDX page for Merge k Sorted Lists problem 2026-09-14 12:50:08 -04:00
Prad Nukala 24312437eb feat(work): add solution for LeetCode 23 Merge k Sorted Lists 2026-09-14 12:49:55 -04:00
Prad Nukala 4c9c8f39ae feat(work): add solution for LeetCode 973 K Closest Points to Origin 2026-09-14 12:37:15 -04:00
Prad Nukala 88ff63f5c4 docs(docs): add MDX page for LeetCode 973 K Closest Points to Origin 2026-09-14 12:37:11 -04:00
Prad Nukala 2066856d15 feat(work): add solution for LeetCode 215 Kth Largest Element in an Array 2026-09-14 12:21:55 -04:00
Prad Nukala 393d72c9fe docs(docs): add MDX page for LeetCode 215 Kth Largest Element in an Array 2026-09-14 12:21:51 -04:00
Prad Nukala 6c18301aa6 feat(work): add Lowest Common Ancestor of BST solution 2026-09-05 16:33:03 -04:00
Prad Nukala 02f6a20cc1 docs(docs): add documentation for 235 Lowest Common Ancestor of a Binary Search Tree 2026-09-05 16:32:59 -04:00
Prad Nukala 9288aaa152 feat(work): add Kth Smallest Element in a BST solution 2026-09-05 16:28:21 -04:00
Prad Nukala d5ea71dbc1 docs(docs): add MDX page for “230. Kth Smallest Element in a BST” 2026-09-05 16:28:18 -04:00
Prad Nukala 3e1a66eb37 feat(work): add LeetCode solutions for 217, 271, and 98 2026-09-05 13:37:53 -04:00
Prad Nukala 9a2b80949b docs(docs): add 271 Encode/Decode Strings and 98 Validate BST pages 2026-09-05 13:37:42 -04:00
Prad Nukala 883dc69dba docs(docs): update index title from “Progress” to “Overview” 2026-09-04 16:49:23 -04:00
Prad Nukala e3f0b5420a docs(docs): replace index with live progress dashboard and delete redundant progress page 2026-09-04 16:46:50 -04:00
Prad Nukala 987eb7ab5b feat(work): add solution for LeetCode 1448 Count Good Nodes in Binary Tree 2026-09-04 16:44:07 -04:00
Prad Nukala e4118fccd3 docs(docs): add MDX page for LeetCode 1448 Count Good Nodes in Binary Tree problem 2026-09-04 16:44:04 -04:00
Prad Nukala 95db4ca46c feat(work): add solution for Binary Tree Right Side View problem 2026-09-04 11:48:03 -04:00
Prad Nukala 0cc7ca607e docs(docs): add documentation for Binary Tree Right Side View problem 2026-09-04 11:47:59 -04:00
Prad Nukala 71ec39fe34 feat(work): add solution for Binary Tree Level Order Traversal (LC 102) 2026-09-04 11:33:20 -04:00
Prad Nukala 51decd23bd docs(docs): add documentation for Binary Tree Level Order Traversal (LeetCode 102) 2026-09-04 11:33:15 -04:00
Prad Nukala 90082518a6 feat(work): add solution for Lowest Common Ancestor of a Binary Tree 2026-09-03 14:17:54 -04:00
Prad Nukala e10a913546 docs(docs): add 236 Lowest Common Ancestor of a Binary Tree documentation 2026-09-03 14:17:51 -04:00
Prad Nukala fec94ed636 feat(work): add solution for Invert Binary Tree (LC 226) 2026-09-03 14:01:06 -04:00
Prad Nukala 30273d0030 docs(docs): add Invert Binary Tree documentation 2026-09-03 14:01:02 -04:00
Prad Nukala 50299defef feat(work): add Maximum Depth of Binary Tree solution 2026-09-03 13:52:57 -04:00
Prad Nukala c843be855e feat(docs): add Maximum Depth of Binary Tree documentation 2026-09-03 13:52:54 -04:00
Prad Nukala 1e0e03daaa feat(work): add Set Matrix Zeroes solution 2026-09-02 16:52:29 -04:00
Prad Nukala 7b182206dd docs(docs): add documentation for Set Matrix Zeroes problem 2026-09-02 16:52:26 -04:00
Prad Nukala faaaf52f85 feat(work): add Rotate Image and Spiral Matrix solutions 2026-09-02 16:49:41 -04:00
Prad Nukala 090f3d6719 docs(docs): add new problem pages for Rotate Image, Spiral Matrix, and Add Two Numbers 2026-09-02 16:49:39 -04:00
Prad Nukala a3ae16ad96 feat(work): add Group Anagrams solution 2026-09-01 15:56:02 -04:00
Prad Nukala 9faa0b0337 feat(work): implement correct addTwoNumbers algorithm for linked list addition 2026-09-01 14:39:45 -04:00
Prad Nukala cc29304903 docs(agents): clarify sync.ts contract description for solution section ownership 2026-09-01 14:22:01 -04:00
Prad Nukala c50b600103 feat(work): add Add Two Numbers solution 2026-09-01 14:21:57 -04:00
Prad Nukala a86d72c7db docs(docs): add Approach and Explanation sections with mermaid diagrams to problem pages 2026-09-01 14:21:54 -04:00
Prad Nukala e59d78e571 feat(cli): preserve custom sections after solution during docs sync 2026-09-01 14:21:50 -04:00
Prad Nukala a93bb068b6 fix(work): correct node insertion and eviction order in LRUCache 2026-09-01 13:59:03 -04:00
Prad Nukala 53b2d5ec8c docs(docs): clarify LRUCache insertion order in example MDX 2026-09-01 13:59:01 -04:00
Prad Nukala 5dda64e41e feat(work): add LRU Cache solution 2026-09-01 13:31:34 -04:00
Prad Nukala d798871fbc docs(docs): add LRU Cache problem documentation 2026-09-01 13:31:31 -04:00
Prad Nukala bb7324fe8c docs(AGENTS): add terminal dashboard info and expand documentation for stats, work layout, and tooling preferences 2026-08-31 18:56:57 -04:00
Prad Nukala d13859d31e refactor(docs): rename recent stats to heat and update dashboard rendering 2026-08-31 18:56:52 -04:00
Prad Nukala 664ea534ed feat(config): add stats script to package.json 2026-08-31 18:56:46 -04:00
Prad Nukala b86f36daf1 feat(cli): add terminal stats dashboard and solution detection utilities 2026-08-31 18:56:42 -04:00
Prad Nukala 7bae7b2920 feat(api): add concept temperature bands and expose in stats 2026-08-31 18:56:40 -04:00
Prad Nukala 64173857e7 feat(work): add new LeetCode solution files for 1426, 1832, 344, and 15 2026-08-31 16:50:30 -04:00
Prad Nukala 002c2c9c97 feat(work): add solutions for LeetCode 1365 and 268 problems 2026-08-31 16:36:29 -04:00
Prad Nukala fa8deac963 feat(work): add solution for LeetCode 387 First Unique Character in a String 2026-08-31 16:21:47 -04:00
Prad Nukala ef8d2b41e1 docs(todos): add review backlog planning markdown 2026-08-31 14:57:38 -04:00
Prad Nukala 51ce47a49a feat(work): add Reverse Linked List solution 2026-08-31 12:12:03 -04:00
Prad Nukala 4650b33364 docs(docs): add Reverse Linked List documentation 2026-08-31 12:12:00 -04:00
Prad Nukala 804ad1379e feat(work): add solution for LeetCode 141 Linked List Cycle 2026-08-31 12:08:29 -04:00
Prad Nukala 0d845838c2 docs(docs): add documentation for LeetCode 141 Linked List Cycle 2026-08-31 12:08:26 -04:00
Prad Nukala b7041b1440 docs(linked-list): add Merge Two Sorted Lists documentation page 2026-08-31 11:45:48 -04:00
Prad Nukala 5655cede21 feat(work): add solution for LeetCode 21 Merge Two Sorted Lists 2026-08-31 11:45:44 -04:00
Prad Nukala 025ce82041 docs(api): clarify /admin/solved payload and add /admin/due endpoint documentation 2026-08-31 10:49:55 -04:00
Prad Nukala a3657732f6 feat(cli): send bucket info to SRS API and render due list from worker 2026-08-31 10:49:50 -04:00
Prad Nukala 1622bf39b7 ci(ci): add SRS_ADMIN_KEY env var to spaced-repetition workflow 2026-08-31 10:48:36 -04:00
Prad Nukala 5ea123e46d feat(api): add dueToday endpoint, shared DUE_WHERE constant, and commit rung handling 2026-08-31 10:48:24 -04:00
Prad Nukala 38fbb18f32 feat(srs): switch ladder to +3/+7 and enforce rest‑day logic 2026-08-31 10:41:45 -04:00
Prad Nukala 7e46fceb1c refactor(work): restructure solution directories with bucket level and add .gitkeep placeholders 2026-08-31 10:41:40 -04:00
Prad Nukala b21ab75e0e test(tests): add comprehensive rest day and ladder tests 2026-08-31 10:41:36 -04:00
Prad Nukala d784575c87 feat(migrations): rebuild ladder stages and shift Sunday dates 2026-08-31 10:41:33 -04:00
Prad Nukala 70f88122e0 docs(docs): align stage ladder with API ladder constants 2026-08-31 10:41:29 -04:00
Prad Nukala 47c2b20646 chore(config): ignore *.log and .crush files in .gitignore 2026-08-31 10:41:25 -04:00
Prad Nukala e8b0d4dc30 feat(api): migrate SRS ladder to +3/+7 and adjust rest‑day logic 2026-08-31 10:41:21 -04:00
Prad Nukala bd9afc6ea1 docs(agents): add spaced-repetition workflow details and CLI usage to documentation 2026-08-29 17:26:11 -04:00
Prad Nukala 8396c4a7ed feat(config): add spaced-repetition script 2026-08-29 17:26:07 -04:00
Prad Nukala bf4a1f4c7a feat(cli): add spaced-repetition script 2026-08-29 17:26:05 -04:00
Prad Nukala e23a68c91c ci(ci): add spaced‑repetition GitHub Actions workflow 2026-08-29 17:26:01 -04:00
Prad Nukala 7384a33f3a feat: Add car fleet 2026-08-28 15:52:39 -04:00
Prad Nukala 0064d15844 feat(work): add Daily Temperatures solution 2026-08-28 15:05:53 -04:00
Prad Nukala e061aca54c docs(docs): add Daily Temperatures solution documentation 2026-08-28 15:05:50 -04:00
Prad Nukala 1eb85ee2fb docs(campaign-issues): remove difficulty/set from issue titles and update documentation accordingly 2026-08-27 16:04:18 -04:00
Prad Nukala cac2f744e4 feat(api): extract difficulty and set from issue labels in catalog reconciliation 2026-08-27 16:04:14 -04:00
Prad Nukala 9e90cfb729 feat(skills): add campaign-issues and project-fields skill docs 2026-08-27 15:46:34 -04:00
Prad Nukala dc15237381 docs(agents): detail Project field sync and Target Date mirroring in workflow documentation 2026-08-27 15:46:28 -04:00
Prad Nukala af7063b71f chore(config): add sync-project-fields script 2026-08-27 15:46:20 -04:00
Prad Nukala c5f3c95fcc feat(cli): add GraphQL support and sync-project-fields script for Project field reconciliation 2026-08-27 15:46:17 -04:00
Prad Nukala fe2a7bb4e9 ci(ci): add project set/difficulty reconciliation step to close-solved workflow 2026-08-27 15:46:14 -04:00
Prad Nukala 8689075ac9 refactor(api): limit project mirror to Target Date only 2026-08-27 15:46:12 -04:00
Prad Nukala 9b5879b6eb feat(work): add solutions for 150.evaluate-reverse-polish-notation and 155.min-stack, remove 42.trapping-rain-water 2026-08-27 15:21:16 -04:00
Prad Nukala 53136b076c docs(docs): delete obsolete array problem pages 2026-08-27 15:21:13 -04:00
Prad Nukala 95f10980d4 docs(docs): add MDX documentation pages for problems 150 (Evaluate Reverse Polish Notation) and 155 (Min Stack) 2026-08-27 15:19:48 -04:00
Prad Nukala c6ecb85507 feat(work): add valid parentheses solution 2026-08-27 14:31:36 -04:00
Prad Nukala 2a69f36e6a docs(docs): add Valid Parentheses solution page 2026-08-27 14:31:32 -04:00
Prad Nukala 5e4628b20f docs(docs): revamp progress dashboard UI and add week field comment 2026-08-27 11:05:05 -04:00
Prad Nukala 72a8399e6d fix(api): adjust gate week handling to use campaign weeks 2026-08-27 11:05:03 -04:00
Prad Nukala 6ebca68ff8 docs(agents): clarify test and submit command behavior in AGENTS.md 2026-08-26 15:51:07 -04:00
Prad Nukala 957ddf9849 feat(work): add solution for problem 35 search-insert-position 2026-08-26 15:51:04 -04:00
Prad Nukala e67cc79f39 docs(docs): add MDX documentation for problems 153 and 35 2026-08-26 15:51:01 -04:00
Prad Nukala f086c2b39a feat(cli): add solution activity tracking and ordering for test/submit commands 2026-08-26 15:50:58 -04:00
Prad Nukala 1b5bdc4ceb chore(logs): add backend shutdown debug log file 2026-08-26 15:25:48 -04:00
Prad Nukala 22ebb82e55 docs(agents): enhance bun run pick description and add bun run submit command 2026-08-26 15:25:44 -04:00
Prad Nukala 180134597d feat(work): add solution for Find Minimum in Rotated Sorted Array 2026-08-26 15:25:41 -04:00
Prad Nukala b669e97c3f feat(config): add submit script command 2026-08-26 15:25:38 -04:00
Prad Nukala d57d75efcc feat(cli): add SQLite cache, shared solution runner, and submit command 2026-08-26 15:25:35 -04:00
Prad Nukala f1445a071f feat(work): add solution for LeetCode 74 Search a 2D Matrix problem 2026-08-26 14:29:14 -04:00
Prad Nukala bb8aa00175 docs(docs): add documentation page for 74. Search a 2D Matrix problem 2026-08-26 14:29:11 -04:00
Prad Nukala 6df1e0e620 docs(agents): add overload‑leveling details to SRS description in AGENTS.md 2026-08-26 10:34:29 -04:00
Prad Nukala 1b4ad8ca5b feat(api): enforce REVIEW_CAP, level overflow reviews, and display difficulty in digest 2026-08-26 10:34:24 -04:00
Prad Nukala f697a986b8 docs(readme): clarify close-solved solved posting and live chart updates 2026-08-25 15:45:20 -04:00
Prad Nukala de5101b4d1 feat(cli): report solved set to SRS worker and log sync status 2026-08-25 15:44:46 -04:00
Prad Nukala 3068b4e4aa ci(ci): add SRS_ADMIN_KEY env var and documentation to CI workflows 2026-08-25 15:44:43 -04:00
Prad Nukala e4de74c935 mise ~/.config/mise/config.toml tools: crush@0.91.0
feat(api): add /admin/solved endpoint and commit source handling
2026-08-25 15:43:21 -04:00
179 changed files with 9019 additions and 749 deletions

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
---
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`
(day → topic issue) — not guessed.
## 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`).
+108
View File
@@ -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.
+29
View File
@@ -28,7 +28,36 @@ jobs:
- uses: oven-sh/setup-bun@v2
# No install step: the script only uses Bun builtins + fetch.
#
# SRS_ADMIN_KEY lets the script report the solved set to the Worker
# (POST /admin/solved). Closing an issue is invisible to D1 — the
# catalog reconcile never reads issue state, and only logAttempt() moves
# the ladder — so without this a pushed solution would never reach the
# charts, the digest's solved ticks, or the drill/gate pools.
- run: bun apps/cli/close-solved.ts
env:
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
+5
View File
@@ -1,5 +1,10 @@
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.
on:
# Close Solved closes problem issues with GITHUB_TOKEN, and token-driven
# closes never fire `issues` events — so chain off its run instead.
+54
View File
@@ -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 }}
+2
View File
@@ -1,6 +1,8 @@
# dependencies (bun install)
node_modules
tmp/
*.log
.crush
# output
out
+49 -18
View File
@@ -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 (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
## Architecture & Data Flow
@@ -12,18 +12,27 @@ Three owners, one direction of truth:
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]
GH[GitHub issues = catalog] -->|nightly reconcile| D1[(D1 = SRS state)]
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.
- **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.
- **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`, 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`). `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.
- **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.
@@ -32,24 +41,45 @@ flowchart LR
| 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, close-topics, sync-d1 (issue edits → `/admin/reconcile`) |
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (day → topic issue, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
| `.github/workflows/` | deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
## Development Commands
All from the repo root (a Bun workspace over `apps/*`):
```sh
bun run pick # scaffold a solution via leetcode-cli (fuzzy picker)
bun run test # run ONE solution against LeetCode's judge (not a test suite)
bun run sync # work/ → apps/docs/content/ pages
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 stats # terminal dashboard over GET /api/stats — four panes (Overview,
# Ladder, Concepts, Activity); Tab/1-4 switches, ↑↓ scrolls,
# r refetches, q quits. Outside a TTY it prints every pane once.
# SRS_API points it at a local Worker like close-solved does.
bun run test # run ONE solution against LeetCode's judge (not a test suite;
# most recently tested first, stamped in the db on exit 0)
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
# hidden; last_submitted stamped in the db on exit 0)
bun run sync # work/1 → apps/docs/content/ pages (re-solves are never published)
bun run 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 api:dev # wrangler dev on :8787 (local D1); api:test = DST guard tests
# 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
# 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
@@ -63,24 +93,25 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
- **Dry-run everything:** reconcilers take `--dry-run`/`DRY_RUN=1`; Worker admin routes take `?dry=1&force=1&date=` (the `SRS_TODAY` equivalent) and `wrangler dev` runs against local D1 — exercise logic there before touching production state.
- **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).
- `apps/api/src/srs.ts` — SRS domain: the ladder (`new → +3 → +7 → retired`; fail resets to `+3`; first-ever log enters at `+3`), `WINDOWS`/`STAGES`/`TEMPERATURES`/`DIFFICULTIES`/`SETS` (the one vocabulary shared by the charts, the `work/<n>` buckets, the review issues and the stats dashboard), ET date math, `workingDay()` (no scheduled date is ever a Sunday), `temperatureOf()` (days since a concept was last exercised → `hot`/`fresh`/`fading`/`cold`, bands derived from `WINDOWS` so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`.
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/56) LeetCode Daily Digest`.
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
- `apps/cli/sync.ts` — work→docs contract: only `## Solution` onward is script-owned on existing pages; human prose is never touched; never hand-write solution pages or edit inside `## Solution`.
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the `## Solution` section itself is script-owned, bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it (`## Approach`, `## Explanation`) are never touched. Never hand-write solution pages or edit inside `## Solution`.
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, per-topic temperature, gates, the 14-day due queue, and `heat` (every campaign day, zeros and future days included — the docs page windows the tail off it rather than asking a second question). Two readers consume it, the docs island and `bun run stats`, so its shape is both their contract: change all three together. `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the docs `/progress` page fetches `/api/stats` client-side (`apps/docs/islands/ProgressDashboard.tsx`).
- `apps/docs/blume.config.ts` — site base `/leetcode`; `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`).
- Old `.github/srs/` JSON state and the `srs-*` Actions are RETIRED — do not resurrect; `apps/api/scripts/import-srs.ts` documents the migration.
- Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
## Runtime/Tooling Preferences
- **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. The **one** exception is the digest email: `apps/api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. Do not extend this exception to any other module.
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
## Testing & QA
+4 -2
View File
@@ -10,7 +10,9 @@
<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>
Charts are live from the SRS Worker — the daily digest email drives the schedule.</sub>
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>
<table align="center">
@@ -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>
+49 -15
View File
@@ -16,16 +16,33 @@ Live at `https://srs-api.prdlk.workers.dev`.
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 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
@@ -40,6 +57,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>` | `{"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)
@@ -59,9 +78,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.
The `sync-d1.yml` Actions workflow additionally pushes curriculum issue
edits into D1 immediately (`POST /admin/reconcile` with the
`SRS_ADMIN_KEY` repo secret); the morning cron is the backstop.
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
@@ -72,13 +105,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
+4 -2
View File
@@ -18,12 +18,14 @@ CREATE TABLE attempts (
date TEXT NOT NULL,
kind TEXT NOT NULL, -- first|review|drill|gate
result TEXT NOT NULL, -- pass|fail
source TEXT NOT NULL -- email|webhook|import
source TEXT NOT NULL -- email|webhook|commit|import
);
-- One-tap email links must be idempotent (same link twice = no-op), but
-- /done webhook corrections (pass then fail, same day) must stay legal —
-- so uniqueness applies to email-sourced attempts only.
-- so uniqueness applies to email-sourced attempts only. Commit-sourced
-- attempts need no index: logAttempt() only accepts them for a stage-'new'
-- problem, so a re-push cannot insert a second one.
CREATE UNIQUE INDEX attempts_email_once
ON attempts (lc_number, date, kind) WHERE source = 'email';
CREATE INDEX attempts_by_date ON attempts (date);
+31
View File
@@ -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';
-94
View File
@@ -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));
+37 -9
View File
@@ -4,13 +4,18 @@
* 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.
*/
import { addDays } from "./srs.ts";
import { DIFFICULTIES, SETS, workingDay } from "./srs.ts";
import type { GitHub } from "./github.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 {
@@ -18,6 +23,18 @@ export interface ReconcileReport {
problems: number;
}
/** 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> {
interface TopicIssue {
number: number;
@@ -62,13 +79,24 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
if (!raw || typeof raw !== "object") continue;
if (!("number" in raw) || typeof raw.number !== "number") continue;
if (!("title" in raw) || typeof raw.title !== "string") continue;
const labels = labelNames(raw);
if (!labels.includes("problem")) continue; // non-curriculum sub-issue
const m = raw.title.match(TITLE_RE);
if (!m) continue; // non-curriculum sub-issue
if (!m) {
console.warn(`catalog: #${raw.number} is a problem but not titled "LC <n> · <name>"`);
continue;
}
const difficulty = DIFFICULTIES.find((d) => labels.includes(`diff:${d}`));
const set = SETS.find((s) => labels.includes(`set:${s}`));
if (!difficulty || !set) {
console.warn(`catalog: #${raw.number} is missing a diff:/set: label — skipped`);
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 +106,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, raw.number, t.number, m[2]!, difficulty, set, defer),
);
problems++;
}
+4 -2
View File
@@ -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`)
+21 -6
View File
@@ -8,22 +8,28 @@
* 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. Reviews + drills ≤ 6, reviews first, overflow
* simply stays due (oldest tomorrow). Sunday is a two-line rest note.
* 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.
*
* 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
* seeded by the date).
* seeded by the date). Levelling keeps that: it runs once, before the send,
* and a re-run finds nothing left over the cap.
*/
import {
CAMPAIGN_DAYS,
type ProblemRow,
REVIEW_CAP,
SCHEDULE,
addDays,
campaignDay,
campaignWeek,
dueReviews,
isoWeek,
levelReviews,
pickDrills,
prettyDate,
streak,
@@ -32,6 +38,7 @@ import {
import { type DigestData, type DigestRow, renderDigest } from "./email.tsx";
import { signLink } from "./links.ts";
/** Total daily ceiling: drills only fill what the whole due queue leaves. */
const DAILY_CAP = 6;
export interface Digest {
@@ -61,10 +68,12 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
const db = env.DB;
const rest = weekdayOf(date) === 0;
// Reviews take the cap first; drills fill whatever is left.
// 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.
const due = rest ? [] : await dueReviews(db, date);
const capped = due.slice(0, DAILY_CAP);
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - capped.length);
const capped = due.slice(0, REVIEW_CAP);
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
const data: DigestData = {
day: prettyDate(date),
@@ -96,6 +105,7 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
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",
})),
@@ -164,6 +174,11 @@ export async function sendDigest(
.first();
if (already && !opts.force) return { sent: false, reason: "already sent today", digest };
// The digest above was built against the un-levelled queue (so `carried`
// reports the real backlog); now give everything past the cap its future
// date. Dry runs never reach here, and a forced re-send finds nothing left.
await levelReviews(env.DB, date);
await env.EMAIL.send({
to: env.TO_EMAIL,
from: { email: env.FROM_EMAIL, name: "SRS" },
+69 -25
View File
@@ -35,6 +35,7 @@ import {
} from "@react-email/components";
import { render } from "@react-email/render";
import type { CSSProperties } from "react";
import { REVIEW_CAP } from "./srs.ts";
// ── palette (GitHub dark) ────────────────────────────────────────
const CANVAS = "#010409";
@@ -61,12 +62,12 @@ 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",
};
@@ -82,6 +83,14 @@ export interface DigestRow {
failUrl: string;
}
/** A learning-day core problem — the only rows allowed a link. */
export interface TopicRow {
lc: number;
difficulty: string;
url: string;
solved: boolean;
}
export interface DigestData {
/** "Tuesday, August 25" */
day: string;
@@ -89,9 +98,9 @@ export interface DigestData {
progress: string;
streak: number;
rest: boolean;
topic?: { name: string; core: { lc: number; url: string; solved: boolean }[] };
topic?: { name: string; core: TopicRow[] };
reviews: DigestRow[];
/** Reviews past the daily cap; they simply stay due. */
/** Reviews past the daily cap; levelled onto the coming days. */
carried: number;
drills: DigestRow[];
gate?: { week: number; url: string };
@@ -196,6 +205,48 @@ function ProblemTable({ rows, staged }: { rows: DigestRow[]; staged: boolean })
);
}
/**
* The learning day's core list as the same table shape as reviews — this is
* the one section allowed to link problems, and ticks replace tap buttons.
*/
function TopicTable({ rows }: { rows: TopicRow[] }) {
return (
<table
width="100%"
border={0}
cellPadding={0}
cellSpacing={0}
style={{ borderCollapse: "collapse", width: "100%" }}
>
<thead>
<tr>
<th style={th}>Problem</th>
<th style={th}>Level</th>
<th style={{ ...th, textAlign: "right" }}>Solved</th>
</tr>
</thead>
<tbody>
{rows.map((p) => {
const difficulty = DIFFICULTY[p.difficulty];
return (
<tr key={p.lc}>
<td style={{ ...td, fontWeight: 600 }}>
<Link href={p.url} style={{ color: p.solved ? MUTED : LINK, textDecoration: "none" }}>
LC {p.lc}
</Link>
</td>
<td style={{ ...td, color: difficulty?.color ?? INK }}>{difficulty?.label ?? p.difficulty}</td>
<td style={{ ...td, color: p.solved ? GREEN : MUTED, textAlign: "right" }}>
{p.solved ? "✓" : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
function Block({
title,
note,
@@ -251,7 +302,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.`}
@@ -265,18 +316,8 @@ function DigestEmail({ data }: { data: DigestData }) {
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
{data.topic.name}
</Text>
<Text style={{ ...hint, margin: 0 }}>
Work through these — ticked ones you have already solved:{" "}
{data.topic.core.map((p, i) => (
<span key={p.lc}>
{i > 0 ? " · " : ""}
<Link href={p.url} style={{ color: p.solved ? MUTED : LINK, textDecoration: "none" }}>
LC {p.lc}
</Link>
{p.solved ? <span style={{ color: GREEN }}> ✓</span> : null}
</span>
))}
</Text>
<Text style={hint}>Work through these from the top — a tick means it is already solved.</Text>
<TopicTable rows={data.topic.core} />
</Section>
) : null}
@@ -289,8 +330,8 @@ function DigestEmail({ data }: { data: DigestData }) {
/>
{data.carried > 0 ? (
<Text style={{ ...hint, margin: "12px 0 0" }}>
{data.carried} more {data.carried === 1 ? "is" : "are"} waiting — they come back
tomorrow, oldest first.
{data.carried} more {data.carried === 1 ? "is" : "are"} waiting — spread over the
coming days, oldest first, never more than {REVIEW_CAP} a day.
</Text>
) : null}
@@ -362,7 +403,7 @@ 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.`),
@@ -373,8 +414,11 @@ function plainDigest(data: DigestData): string {
out.push(
"",
`SOMETHING NEW TODAY — ${data.topic.name}`,
"Work through these; the ones marked done you have already solved.",
...data.topic.core.map((p) => ` LC ${p.lc}${p.solved ? " (done)" : ""} — ${p.url}`),
"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}`,
),
);
}
@@ -411,7 +455,7 @@ function plainDigest(data: DigestData): string {
if (data.carried > 0) {
out.push(
"",
`${data.carried} more ${data.carried === 1 ? "is" : "are"} waiting — they come back tomorrow, oldest first.`,
`${data.carried} more ${data.carried === 1 ? "is" : "are"} waiting — spread over the coming days, oldest first, never more than ${REVIEW_CAP} a day.`,
);
}
if (data.gate) {
+1 -1
View File
@@ -5,7 +5,7 @@
* 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,
* 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
+76 -6
View File
@@ -19,9 +19,12 @@ import { type Mirror, projectMirror } from "./mirror.ts";
import {
CAMPAIGN_START,
type LogOutcome,
type Stage,
daysBetween,
dueToday,
etDate,
etHour,
getProblem,
logAttempt,
weekdayOf,
} from "./srs.ts";
@@ -30,22 +33,20 @@ import { buildStats } from "./stats.ts";
// ── shared side effects after a state write ──────────────────────
/**
* 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);
const mirror: Mirror = projectMirror(gh, env.REPO.split("/")[0]!);
await mirror.setStage(outcome.issue, outcome.stage);
const mirror = projectMirror(gh, env.REPO.split("/")[0]!);
await mirror.setTargetDate(outcome.issue, outcome.next_review);
if (outcome.first) await mirror.setFirstAttempt(outcome.issue, outcome.result);
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}`);
@@ -97,7 +98,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),
);
}
@@ -200,6 +201,68 @@ async function adminAuthorized(request: Request, env: Env): Promise<boolean> {
return timingSafeEqual(header.slice(7), env.LINK_KEY);
}
/**
* 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, 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" && "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) {
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 });
}
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, 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 !== 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", rung });
if (outcome.error) skipped.push(outcome.error);
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 mirror.setTargetDate(outcome.issue, outcome.next_review);
}
}
return Response.json({ date, logged, skipped, warnings: mirror?.warnings ?? [] });
}
async function handleAdmin(request: Request, env: Env, path: string): Promise<Response> {
if (!(await adminAuthorized(request, env))) return new Response("unauthorized", { status: 401 });
const url = new URL(request.url);
@@ -209,6 +272,7 @@ async function handleAdmin(request: Request, env: Env, path: string): Promise<Re
if (path === "/admin/reconcile") {
return Response.json(await reconcileCatalog(env.DB, gh()));
}
if (path === "/admin/solved") return await handleSolved(request, env, date);
if (path === "/admin/digest") {
const report = await sendDigest(env, date, {
dry: url.searchParams.get("dry") === "1",
@@ -222,6 +286,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 });
}
+10 -39
View File
@@ -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 });
}),
};
}
+113 -4
View File
@@ -1,11 +1,32 @@
/**
* 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,
STAGES,
WINDOWS,
addDays,
campaignDay,
campaignWeek,
etDate,
etHour,
isRestDay,
isoWeek,
rng,
sample,
TEMPERATURES,
temperatureOf,
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 +71,94 @@ 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 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];
+245 -24
View File
@@ -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.
@@ -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;
}
@@ -90,14 +128,20 @@ export function prettyDate(date: string): string {
export const SCHEDULE: Record<string, number> = scheduleJson;
/** Week in which a topic was (or will be) taught. */
export function topicWeek(topic: number): number | undefined {
/** Date the schedule teaches a topic on, if it teaches it at all. */
export function topicDate(topic: number): string | undefined {
for (const [date, t] of Object.entries(SCHEDULE)) {
if (t === topic) return campaignWeek(date);
if (t === 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);
}
// ── deterministic sampling ───────────────────────────────────────
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
@@ -126,10 +170,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 +239,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,20 +248,111 @@ export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
}
/**
* What "due" means, in one place: not retired, scheduled on/before ?1, and
* past its deferral if it has one. Every caller that asks "what is open on
* this day" — the digest, the review issue, the docs queue chart — shares
* this clause so they cannot answer differently.
*/
export const DUE_WHERE = `stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
AND (defer_until IS NULL OR defer_until <= ?1)`;
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
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 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. 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);
if (overflow.length === 0) return 0;
await db.batch(
overflow.map((p, i) =>
db
.prepare("UPDATE problems SET next_review = ? WHERE lc_number = ?")
.bind(workingDay(addDays(date, 1), Math.floor(i / REVIEW_CAP)), p.lc_number),
),
);
return overflow.length;
}
/**
* Blind drills: unsolved optional problems from topics ALREADY LEARNED —
* scheduled in an earlier week (the current week's optional pool is reserved
@@ -242,16 +430,32 @@ export interface LogOutcome {
/**
* Record an attempt and move the ladder. Ladder semantics live here and only
* here — email one-taps, webhook /done lines, and gate scoring all converge.
* here — email one-taps, webhook /done lines, gate scoring, and solutions
* landing in work/ all converge.
*
* 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 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,
opts: { lc: number; date: string; result: Result; source: "email" | "webhook"; gate?: boolean },
opts: {
lc: number;
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> {
const p = await getProblem(db, opts.lc);
const nothing: LogOutcome = {
@@ -266,6 +470,21 @@ export async function logAttempt(
duplicate: false,
};
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
// 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,
stage: p.stage,
next_review: p.next_review,
issue: p.issue,
duplicate: true,
};
}
if (p.stage === "retired") {
return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` };
}
@@ -288,16 +507,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(
+157 -34
View File
@@ -1,16 +1,29 @@
/**
* 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.
*/
import {
CAMPAIGN_DAYS,
CAMPAIGN_START,
DIFFICULTIES,
DUE_WHERE,
addDays,
campaignDay,
campaignWeek,
daysBetween,
isoWeek,
STAGES,
streak,
temperatureOf,
topicDate,
type Temperature,
} from "./srs.ts";
const PHASE_NAMES: Record<number, string> = {
@@ -21,6 +34,91 @@ 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;
}
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,50 +156,75 @@ 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
.prepare(
`SELECT COUNT(*) AS attempts,
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes
FROM attempts WHERE date = ?`,
)
.bind(date)
.first<{ attempts: number; passes: number | null }>();
recent.push({ date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 });
}
// Attempts per campaign day, every day present. This is the heatmap's data
// model — the docs page reads the tail of it for "last 7 days" rather than
// asking a second question that could answer differently.
const end = addDays(CAMPAIGN_START, CAMPAIGN_DAYS - 1);
const { results: heatRows } = await db
.prepare(
`SELECT date,
COUNT(*) AS attempts,
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes
FROM attempts WHERE date BETWEEN ?1 AND ?2 GROUP BY date`,
)
.bind(CAMPAIGN_START, end)
.all<{ date: string; attempts: number; passes: number | null }>();
const byDate = new Map(heatRows.map((r) => [r.date, r]));
const heat = Array.from({ length: CAMPAIGN_DAYS }, (_, i) => {
const date = addDays(CAMPAIGN_START, i);
const row = byDate.get(date);
return { date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 };
});
return {
generated: new Date().toISOString(),
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
phases: [...phases.values()],
ladder,
difficulty,
topics: await buildTopics(db, today),
gates,
streak: await streak(db, today),
queue,
recent,
heat,
};
}
+1 -1
View File
@@ -15,7 +15,7 @@
{ "binding": "DB", "database_name": "srs", "database_id": "2ee2c34a-c2e2-4ebb-934e-c9e7dc8aa4f1" }
],
// Sender domain prdlk.com is onboarded to Email Sending; the binding is
// Sender domain prdlk.com is onboarded to Email Service; the binding is
// restricted to the one verified destination inbox.
"send_email": [
{ "name": "EMAIL", "allowed_destination_addresses": ["prnk28@gmail.com"] }
+87
View File
@@ -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;
}
+89 -73
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bun
/**
* Close the GitHub issue for every LeetCode problem actually solved under work/.
* Close the GitHub issue for every LeetCode problem actually solved under work/,
* then report the solved set to the SRS Worker so D1 agrees.
*
* Reconciles state instead of reacting to a push diff: any `problem`-labelled
* issue whose LC number has an *implemented* solution file in work/ gets
@@ -9,75 +10,28 @@
*
* `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
* bun apps/cli/close-solved.ts # close matches, log them in D1
* bun apps/cli/close-solved.ts --dry-run # report only, touch nothing
*
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
* 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();
@@ -86,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) {
entry.files.push(`work/${rel}`);
entry.implemented ||= implemented;
} else {
work.set(num, { files: [`work/${rel}`], implemented });
}
const entry = work.get(file.lc) ?? { files: [], implemented: false, solved: new Set<Bucket>() };
entry.files.push(`work/${rel}`);
entry.implemented ||= implemented;
if (implemented) entry.solved.add(file.bucket);
work.set(file.lc, entry);
}
// ── open problem issues ──────────────────────────────────────────
@@ -193,24 +147,86 @@ for (const num of [...work.keys()].sort((a, b) => a - b)) {
closed++;
}
// ── push the solved set into D1 ───────────────────────────────────
// Closing an issue is invisible to the Worker: its catalog reconcile reads
// GitHub for titles, labels and milestones and never looks at issue state,
// and D1's SRS columns only ever move through logAttempt(). So a solution
// pushed here — rather than tapped in the digest email — has to be reported,
// or the charts, the digest's "already solved" ticks, and the drill/gate
// pools all keep treating it as untouched.
//
// 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()]
.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;
if (!process.env.SRS_ADMIN_KEY) {
syncNote = "SRS_ADMIN_KEY unset — D1 not touched";
} else if (solved.length === 0) {
syncNote = "nothing implemented — D1 not touched";
} else {
try {
const res = await fetch(`${SRS_API}/admin/solved${DRY ? "?dry=1" : ""}`, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.SRS_ADMIN_KEY}`,
"content-type": "application/json",
},
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
// read as "logged nothing", not throw inside the reconciler.
const payload: unknown = await res.json();
const fields = payload && typeof payload === "object" ? payload : {};
const strings = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : []);
const logged = strings("logged" in fields ? fields.logged : null);
const skipped = strings("skipped" in fields ? fields.skipped : null);
syncNote =
`${DRY ? "would log" : "logged"} ${logged.length} of ${solved.length} ` +
`(${skipped.length} already on the ladder or off-curriculum)`;
for (const line of logged) console.log(` d1: ${line}`);
} catch (err) {
// The closes above already landed; surface the failure instead of
// letting D1 drift silently until the next push.
syncFailed = true;
syncNote = `D1 push FAILED: ${err instanceof Error ? err.message : String(err)}`;
}
}
// ── report ───────────────────────────────────────────────────────
const rows = [["lc", "issue", "set", "status"], ...report];
printTable(rows);
const implemented = [...work.values()].filter((e) => e.implemented).length;
const actionable = report.filter((r) => r[3] !== "no open issue");
console.log(
`\n${work.size} in work/ · ${implemented} 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}`}`,
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}` +
`\nd1: ${syncNote}`,
);
await writeStepSummary(
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
`${work.size} files in \`work/\`, ${implemented} 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`
: "Nothing to close.\n"),
: "Nothing to close.\n") +
`\nD1: ${syncNote}\n`,
);
// Non-zero only for the D1 push: the closes are already reported above.
if (syncFailed) process.exitCode = 1;
+235
View File
@@ -0,0 +1,235 @@
/**
* Local LeetCode problem cache backed by SQLite at
* ~/.local/share/leetcode/data.db (XDG_DATA_HOME honored).
*
* One `problems` table, two freshness rules:
* - the full problem index (id / title / slug / difficulty / paid flag) is
* refreshed from leetcode.com at most once per 24h (`meta.index_fetched_at`);
* - descriptions (HTML from GraphQL `question.content`) are fetched once per
* slug and kept forever. Only the campaign problems linked from README.md
* 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.
*/
import { Database } from "bun:sqlite";
import { mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
export interface Problem {
id: number;
title: string;
slug: string;
difficulty: "Easy" | "Medium" | "Hard";
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;
export const DB_PATH = join(
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
"leetcode",
"data.db",
);
// ── README parsing ───────────────────────────────────────────────
/** Campaign problem slugs linked from README.md, in order, deduplicated. */
export function readmeSlugs(markdown: string): string[] {
const slugs = new Set<string>();
for (const m of markdown.matchAll(/https:\/\/leetcode\.com\/problems\/([\w-]+)\//g)) {
slugs.add(m[1]!);
}
return [...slugs];
}
// ── remote fetches ───────────────────────────────────────────────
async function fetchIndex(): Promise<Problem[]> {
const res = await fetch("https://leetcode.com/api/problems/all/", {
headers: { "user-agent": "Mozilla/5.0" },
});
if (!res.ok) throw new Error(`GET /api/problems/all -> ${res.status} ${res.statusText}`);
const data = (await res.json()) as {
stat_status_pairs: Array<{
stat: {
frontend_question_id: number;
question__title: string;
question__title_slug: string;
};
difficulty: { level: 1 | 2 | 3 };
paid_only: boolean;
}>;
};
return data.stat_status_pairs
.map((p) => ({
id: p.stat.frontend_question_id,
title: p.stat.question__title,
slug: p.stat.question__title_slug,
difficulty: DIFFICULTY[p.difficulty.level] as Problem["difficulty"],
paidOnly: p.paid_only,
}))
.sort((a, b) => a.id - b.id);
}
/** HTML problem statement, or null when LeetCode withholds it (paid-only). */
export async function fetchDescription(slug: string): Promise<string | null> {
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/${slug}/`,
},
body: JSON.stringify({
query: "query questionContent($titleSlug: String!) { question(titleSlug: $titleSlug) { content } }",
variables: { titleSlug: slug },
}),
});
if (!res.ok) throw new Error(`POST /graphql (${slug}) -> ${res.status} ${res.statusText}`);
const data = (await res.json()) as { data?: { question?: { content?: string | null } | null } };
return data.data?.question?.content ?? null;
}
// ── the cache ────────────────────────────────────────────────────
export function problemDb(path = DB_PATH) {
mkdirSync(dirname(path), { recursive: true });
const db = new Database(path);
db.exec(`
CREATE TABLE IF NOT EXISTS problems (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
difficulty TEXT NOT NULL,
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
);
`);
const getMeta = db.query<{ value: string }, [string]>("SELECT value FROM meta WHERE key = ?1");
const setMeta = db.query<never, [string, string]>(
"INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
);
const upsert = db.query<never, [number, string, string, string, number]>(
`INSERT INTO problems (id, title, slug, difficulty, paid_only)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title, slug = excluded.slug,
difficulty = excluded.difficulty, paid_only = excluded.paid_only`,
);
const descState = db.query<{ paid_only: number; described: number }, [string]>(
"SELECT paid_only, description IS NOT NULL AS described FROM problems WHERE slug = ?1",
);
return {
path,
/** True when the index has never been fetched or is older than 24h. */
indexStale(): boolean {
const row = getMeta.get("index_fetched_at");
return !row || Date.now() - Number(row.value) > INDEX_TTL_MS;
},
/** Pull the full index from leetcode.com and upsert it. Returns the count. */
async refreshIndex(): Promise<number> {
const problems = await fetchIndex();
db.transaction(() => {
for (const p of problems) upsert.run(p.id, p.title, p.slug, p.difficulty, p.paidOnly ? 1 : 0);
setMeta.run("index_fetched_at", String(Date.now()));
})();
return problems.length;
},
/** Every cached problem, ordered by LC number. */
all(): Problem[] {
const rows = db
.query<{ id: number; title: string; slug: string; difficulty: string; paid_only: number }, []>(
"SELECT id, title, slug, difficulty, paid_only FROM problems ORDER BY id",
)
.all();
return rows.map((r) => ({
id: r.id,
title: r.title,
slug: r.slug,
difficulty: r.difficulty as Problem["difficulty"],
paidOnly: r.paid_only === 1,
}));
},
/** Free (non-premium) slugs among `slugs` with no cached description yet. */
missingDescriptions(slugs: string[]): string[] {
return slugs.filter((slug) => {
const row = descState.get(slug);
return row !== null && !row.paid_only && !row.described;
});
},
saveDescription(slug: string, html: string): void {
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
View File
@@ -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 };
}
+2
View File
@@ -4,7 +4,9 @@
"type": "module",
"scripts": {
"pick": "bun ./pick.ts",
"stats": "bun ./stats.ts",
"test": "bun ./test.ts",
"submit": "bun ./submit.ts",
"sync": "bun ./sync.ts",
"close-solved": "bun ./close-solved.ts",
"close-topics": "bun ./close-topics.ts"
+227 -69
View File
@@ -1,80 +1,171 @@
#!/usr/bin/env bun
/**
* Fuzzy-pick a LeetCode problem and scaffold it via leetcode-cli.
* Problem index is fetched from leetcode.com and cached for 24h in .cache/.
* 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. 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 { mkdir } from "node:fs/promises";
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 ROOT = join(import.meta.dir, "..", "..");
const CACHE_FILE = join(ROOT, ".cache", "leetcode-problems.json");
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const db = problemDb();
interface Problem {
id: number;
title: string;
slug: string;
difficulty: "Easy" | "Medium" | "Hard";
paidOnly: boolean;
}
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
async function loadProblems(): Promise<Problem[]> {
const file = Bun.file(CACHE_FILE);
if (await file.exists()) {
const stale = Date.now() - file.lastModified > CACHE_TTL_MS;
if (!stale) return file.json();
}
// ── problem index (24h TTL) ──────────────────────────────────────
if (db.indexStale()) {
const s = spinner();
s.start("Fetching problem index from leetcode.com");
const res = await fetch("https://leetcode.com/api/problems/all/", {
headers: { "user-agent": "Mozilla/5.0" },
});
if (!res.ok) {
s.error(`Fetch failed: ${res.status} ${res.statusText}`);
process.exit(1);
try {
s.stop(`Indexed ${await db.refreshIndex()} problems`);
} catch (err) {
if (db.all().length === 0) {
s.error(`Fetch failed: ${err}`);
process.exit(1);
}
s.stop(`Index refresh failed (${err}) — using cached copy`);
}
const data = (await res.json()) as {
stat_status_pairs: Array<{
stat: {
frontend_question_id: number;
question__title: string;
question__title_slug: string;
};
difficulty: { level: 1 | 2 | 3 };
paid_only: boolean;
}>;
};
const problems: Problem[] = data.stat_status_pairs
.map((p) => ({
id: p.stat.frontend_question_id,
title: p.stat.question__title,
slug: p.stat.question__title_slug,
difficulty: DIFFICULTY[p.difficulty.level] as Problem["difficulty"],
paidOnly: p.paid_only,
}))
.sort((a, b) => a.id - b.id);
s.stop(`Loaded ${problems.length} problems`);
await mkdir(join(ROOT, ".cache"), { recursive: true });
await Bun.write(CACHE_FILE, JSON.stringify(problems));
return problems;
}
const problems = await loadProblems();
// ── description backfill for the campaign problems in README.md ──
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,
label: `${p.id}. ${p.title}`,
hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty,
const missing = db.missingDescriptions(readmeSlugs(await Bun.file(join(ROOT, "README.md")).text()));
if (missing.length > 0) {
const s = spinner();
s.start(`Caching ${missing.length} problem descriptions`);
let fetched = 0;
let failed = 0;
await Promise.all(
Array.from({ length: 6 }, async () => {
for (let slug = missing.shift(); slug; slug = missing.shift()) {
try {
const html = await fetchDescription(slug);
if (html !== null) db.saveDescription(slug, html);
s.message(`Caching problem descriptions (${++fetched})`);
} catch {
failed++; // left NULL — retried on the next run
}
}
}),
);
s.stop(failed ? `Cached ${fetched} descriptions (${failed} failed, will retry next run)` : `Cached ${fetched} problem descriptions`);
}
// ── the three sections ───────────────────────────────────────────
/** What Enter scaffolds: this problem, into this bucket. */
interface Choice {
bucket: Bucket;
lc: number;
slug: string;
}
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 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
@@ -88,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(
// 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);
// ── 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"] },
);
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);
+115
View File
@@ -0,0 +1,115 @@
/**
* Shared work/ solution picker for the test and submit entries: fuzzy-select
* 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. Ordering is 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 { statSync } from "node:fs";
import { basename, join, relative } from "node:path";
import { problemDb } from "./db";
import { ROOT, WORK as WORK_DIR, parseWorkPath } from "./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;
}
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
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 files = scanned
.sort((a, b) => {
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)}/`);
process.exit(1);
}
const picked = await autocomplete<string>({
message,
placeholder: "Type to search...",
maxItems: 12,
options: files.map((f) => {
const parsed = parseWorkPath(f);
const stage = parsed?.bucket === 1 ? "first solve" : `${parsed?.bucket}-day review`;
return {
value: f,
label: basename(f),
hint: parsed ? `${stage} · ${parsed.difficulty} · ${parsed.category}` : f,
};
}),
filter: (search, option) => {
const haystack = option.value.toLowerCase();
return search
.toLowerCase()
.split(/\s+/)
.every((token) => haystack.includes(token));
},
});
if (isCancel(picked)) {
cancel("Nothing selected.");
process.exit(0);
}
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"),
command,
join(WORK_DIR, picked),
],
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
);
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);
}
+61
View File
@@ -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;
}
+384
View File
@@ -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"),
);
+458
View File
@@ -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();
}
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bun
/**
* Fuzzy-select a solution file from work/ and submit it to LeetCode's judge.
*/
import { runOnSolution } from "./solutions";
await runOnSolution("submit", "Submit which solution?");
+252
View File
@@ -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));
+28 -4
View File
@@ -1,6 +1,6 @@
#!/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,
* constraints, solution) in the gold-standard format.
@@ -9,12 +9,16 @@
* - 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.
*
* Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves
* of problems whose page already exists (see work.ts), and a fresh scaffold
* there would otherwise wipe the page's `## Solution`.
*/
import { mkdir, rename } from "node:fs/promises";
import { basename, join } from "node:path";
const ROOT = join(import.meta.dir, "..", "..");
const WORK = join(ROOT, "work");
const WORK = join(ROOT, "work", "1");
const DOCS = join(ROOT, "apps", "docs", "content");
type Lang = "js" | "py";
@@ -323,7 +327,7 @@ 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]);
@@ -355,6 +359,24 @@ function ensureLabel(page: string, h: Header): string {
return page.replace(/^sidebar:$/m, `sidebar:\n${label}`);
}
/**
* Offset just past the script-owned `## Solution` section: the next top-level
* heading that is not inside a code fence, or end of file. Sections a human
* appends after the solution (`## Explanation`, …) survive regeneration.
*/
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()) {
@@ -382,7 +404,9 @@ 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 = ensureLabel(body, sol.header);
const actions: string[] = [];
if (existing !== name) {
await rename(join(DOCS, existing), join(DOCS, name));
+2 -50
View File
@@ -2,54 +2,6 @@
/**
* Fuzzy-select a solution file from work/ and run leetcode-cli tests on it.
*/
import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
import { join, relative } from "node:path";
import { runOnSolution } from "./solutions";
const ROOT = join(import.meta.dir, "..", "..");
const WORK_DIR = join(ROOT, "work");
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 }));
if (files.length === 0) {
log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`);
process.exit(1);
}
const picked = await autocomplete<string>({
message: "Test which solution?",
placeholder: "Type to search...",
maxItems: 12,
options: files.map((f) => {
// work layout: Difficulty/Category/<id>.<slug>.<ext>
const [difficulty, category, name] = f.split("/");
return {
value: f,
label: name ?? f,
hint: category ? `${difficulty} · ${category}` : difficulty,
};
}),
filter: (search, option) => {
const haystack = option.value.toLowerCase();
return search
.toLowerCase()
.split(/\s+/)
.every((token) => haystack.includes(token));
},
});
if (isCancel(picked)) {
cancel("Nothing selected.");
process.exit(0);
}
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"),
"test",
join(WORK_DIR, picked),
],
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
);
process.exit(await proc.exited);
await runOnSolution("test", "Test which solution?");
+214
View File
@@ -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);
},
};
}
+197
View File
@@ -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;
}
+18
View File
@@ -32,6 +32,20 @@ Can you come up with an algorithm that is less than O(n^2) time complexity?
- `-10^9 <= target <= 10^9`
- Only one valid answer exists.
## Approach
```mermaid
flowchart TD
S(["twoSum(nums, target)"]) --> I["seen = {} — value to index"]
I --> L{"more (i, num) in nums?"}
L -- no --> E(["return []"])
L -- yes --> C["complement = target - num"]
C --> H{"complement in seen?"}
H -- yes --> R(["return [seen[complement], i]"])
H -- no --> W["seen[num] = i"]
W --> L
```
## Solution
```py
@@ -45,3 +59,7 @@ class Solution:
seen[num] = i
return []
```
## Explanation
<YouTube id="KLlXCFG5TnA" title="Two Sum - Leetcode 1 - HashMap - Python" />
@@ -27,6 +27,22 @@ Notice that you may not slant the container.
- `2 <= n <= 10^5`
- `0 <= height[i] <= 10^4`
## Approach
```mermaid
flowchart TD
S(["maxArea(height)"]) --> I["res = 0, left = 0, right = len - 1"]
I --> W{"left < right?"}
W -- no --> E(["return res"])
W -- yes --> A["area = (right - left) * min(height[left], height[right])"]
A --> M["res = max(res, area)"]
M --> C{"height[left] < height[right]?"}
C -- yes --> L["left += 1 — drop the shorter wall"]
C -- no --> R["right -= 1 — drop the shorter wall"]
L --> W
R --> W
```
## Solution
```py
@@ -46,3 +62,7 @@ class Solution:
return res
```
## Explanation
<YouTube id="UuiTKBwPgAo" title="Container with Most Water - Leetcode 11 - Python" />
@@ -23,6 +23,24 @@ sidebar:
- `1 <= prices.length <= 10^5`
- `0 <= prices[i] <= 10^4`
## Approach
::::warning
The diagram traces the code below literally. That code does not run: `curr` and `ans` are never initialised, so the first `curr > left` test raises `NameError`, and `left` holds a price rather than an index, so `prices[left]` indexes by value.
::::
```mermaid
flowchart TD
S(["maxProfit(prices)"]) --> I["left = min(prices)"]
I --> F{"more right in range(len(prices))?"}
F -- no --> E(["return ans"])
F -- yes --> W{"curr > left?"}
W -- yes --> P["curr -= prices[left]; left += 1"]
P --> W
W -- no --> M["ans = max(ans, curr)"]
M --> F
```
## Solution
```py
@@ -36,3 +54,7 @@ class Solution:
ans = max(ans, curr)
return ans
```
## Explanation
<YouTube id="1pkOgXD63yU" title="Sliding Window: Best Time to Buy and Sell Stock - Leetcode 121 - Python" />
@@ -26,6 +26,20 @@ sidebar:
- `2 <= nums.length <= 500`
- `0 <= nums[i] <= 100`
## Approach
```mermaid
flowchart TD
S(["smallerNumbersThanCurrent(nums)"]) --> F["freq: count every value in nums"]
F --> O["sorted = keys of freq, ascending"]
O --> I["count = 0, smaller = {}"]
I --> L{"more num in sorted?"}
L -- yes --> A["smaller[num] = count — everything already passed is smaller"]
A --> B["count += freq[num]"]
B --> L
L -- no --> M(["return nums.map(n => smaller[n])"])
```
## Solution
```js
@@ -37,6 +37,18 @@ startValue = 4 | startValue = 5 | nums
- `1 <= nums.length <= 100`
- `-100 <= nums[i] <= 100`
## Approach
```mermaid
flowchart TD
S(["minStartValue(nums)"]) --> I["prefix = [nums[0]]"]
I --> L{"more i in 1..n-1?"}
L -- yes --> P["prefix.push(prefix[i-1] + nums[i])"]
P --> L
L -- no --> M["min = smallest running total"]
M --> R(["return max(1, 1 - min) — lift the dip to 1"])
```
## Solution
```js
@@ -23,6 +23,19 @@ sidebar:
- `1 <= arr.length <= 1000`
- `0 <= arr[i] <= 1000`
## Approach
```mermaid
flowchart TD
S(["countElements(arr)"]) --> I["arrSet = Set(arr), count = 0"]
I --> L{"more n in arr?"}
L -- no --> E(["return count"])
L -- yes --> H{"arrSet has n + 1?"}
H -- yes --> C["count++"]
C --> L
H -- no --> L
```
## Solution
```js
@@ -27,6 +27,17 @@ sidebar:
- `1 <= nums.length <= 1000`
- `-10^6 <= nums[i] <= 10^6`
## Approach
```mermaid
flowchart TD
S(["runningSum(nums)"]) --> I["prefix = [nums[0]]"]
I --> L{"more i in 1..n-1?"}
L -- yes --> P["prefix.push(prefix[i-1] + nums[i])"]
P --> L
L -- no --> E(["return prefix"])
```
## Solution
```js
+26
View File
@@ -32,6 +32,28 @@ Notice that the solution set must not contain duplicate triplets.
- `3 <= nums.length <= 3000`
- `-10^5 <= nums[i] <= 10^5`
## Approach
```mermaid
flowchart TD
S(["threeSum(nums)"]) --> O["nums.sort() — duplicates become adjacent"]
O --> F{"more i in 0..n-1?"}
F -- no --> E(["return result"])
F -- yes --> D{"nums[i] == nums[i-1]?"}
D -- yes --> F
D -- no --> P["left = i+1, right = n-1, target = -nums[i]"]
P --> W{"left < right?"}
W -- no --> F
W -- yes --> C["current = nums[left] + nums[right]"]
C --> Q{"current vs target"}
Q -- equal --> A["append triplet, skip equal neighbours, left += 1, right -= 1"]
A --> W
Q -- "current < target" --> L["left += 1 — need a bigger sum"]
L --> W
Q -- "current > target" --> R["right -= 1 — need a smaller sum"]
R --> W
```
## Solution
```py
@@ -76,3 +98,7 @@ class Solution:
return result
```
## Explanation
<YouTube id="jzZsG8n2R9A" title="3Sum - Leetcode 15 - Python" />
@@ -0,0 +1,74 @@
---
title: '150. Evaluate Reverse Polish Notation'
description: You are given an array of strings tokens that represents an arithmetic expression in a Reverse Polish Notation
sidebar:
label: 'Evaluate Reverse Polish Notation'
badge: 'Medium'
---
<Badge variant="accent">Stack</Badge>
### Example 1:
- Input: `tokens = ["2","1","+","3","*"]`
- Output: `9`
- Explanation: `((2 + 1`) * `3`) = `9`
### Example 2:
- Input: `tokens = ["4","13","5","/","+"]`
- Output: `6`
- Explanation: `(4 + (13 / 5`)) = `6`
### Example 3:
- Input: `tokens = ["10","6","9","3","+","-11","*","/","*","17","+","5","+"]`
- Output: `22`
- Explanation: `((10 * (6 / ((9 + 3`) * `-11`))) + `17`) + `5 = ((10 * (6 / (12 * -11`))) + `17`) + `5 = ((10 * (6 / -132`)) + `17`) + `5 = ((10 * 0`) + `17`) + `5 = (0 + 17`) + `5 = 17 + 5 = 22`
### Constraints:
- `1 <= tokens.length <= 10^4`
- `tokens[i]` is either an operator: "+", "-", "*", or "/", or an integer in the range [-200, 200].
## Approach
```mermaid
flowchart TD
S(["evalRPN(tokens)"]) --> I["stack = [], operations = + - * /"]
I --> L{"more token c?"}
L -- no --> E(["return stack[0]"])
L -- yes --> Q{"c is an operator?"}
Q -- no --> N["stack.append(int(c))"]
N --> L
Q -- yes --> P["y = stack.pop(), x = stack.pop() — order matters"]
P --> C["stack.append(operations[c](x, y))"]
C --> L
```
## Solution
```py
class Solution:
def evalRPN(self, tokens: List[str]) -> int:
stack = []
operations = {
"+": lambda x, y: int(x + y),
"-": lambda x, y: int(x - y),
"*": lambda x, y: int(x * y),
"/": lambda x, y: int(x / y),
}
for c in tokens:
if c in operations:
y = stack.pop()
x = stack.pop()
calc_func = operations[c]
result = calc_func(x, y)
stack.append(result)
else:
stack.append(int(c))
return stack[0]
```
## Explanation
<YouTube id="iu0082c4HDE" title="Evaluate Reverse Polish Notation - Leetcode 150 - Python" />
@@ -0,0 +1,74 @@
---
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:'
sidebar:
label: 'Find Minimum in Rotated Sorted Array'
badge: 'Medium'
---
<Badge variant="accent">Binary Search</Badge>
::::warning
Notice that rotating an array [a[0], a[1], a[2], ..., a[n-1]] 1 time results in the array [a[n-1], a[0], a[1], a[2], ..., a[n-2]].
::::
### 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]
```
## Explanation
<YouTube id="nIVW4P8b1VA" title="Find Minimum in Rotated Sorted Array - Binary Search - Leetcode 153 - Python" />
@@ -27,6 +27,18 @@ sidebar:
- `1 <= nums.length <= 100`
- `-100 <= nums[i] <= 100`
## Approach
```mermaid
flowchart TD
S(["frequencySort(nums)"]) --> F["freq: count every value in nums"]
F --> C["sort nums with a two-key comparator"]
C --> K1["primary: freq[a] - freq[b] — rarer first"]
C --> K2["tie-break: b - a — larger value first"]
K1 --> R(["return nums"])
K2 --> R
```
## Solution
```js
@@ -40,3 +52,7 @@ var frequencySort = function (nums) {
return nums.sort((a, b) => freq[a] - freq[b] || b - a);
};
```
## Explanation
<YouTube id="Evq1SfUbhBg" title="Sort Array by Increasing Frequency - Leetcode 1636 - Python" />
@@ -35,6 +35,22 @@ Your solution must use only constant extra space.
- `-1000 <= target <= 1000`
- The tests are generated such that there is exactly one solution.
## Approach
```mermaid
flowchart TD
S(["twoSum(numbers, target)"]) --> I["i = 0, j = len - 1"]
I --> W{"i < j?"}
W -- no --> E(["return []"])
W -- yes --> C["c = numbers[i] + numbers[j]"]
C --> Q{"c vs target"}
Q -- equal --> R(["return [i+1, j+1] — 1-indexed"])
Q -- "c < target" --> L["i += 1 — grow the sum"]
L --> W
Q -- "c > target" --> H["j -= 1 — shrink the sum"]
H --> W
```
## Solution
```py
@@ -53,3 +69,7 @@ class Solution:
j-=1
return []
```
## Explanation
<YouTube id="cQ1Oz4ckceM" title="TWO SUM II - Amazon Coding Interview Question - Leetcode 167 - Python" />
@@ -1,42 +0,0 @@
---
title: '189. Rotate Array'
description: Given an integer array nums, rotate the array to the right by k steps, where k is non-negative
sidebar:
label: 'Rotate Array'
badge: 'Medium'
---
<Badge variant="accent">Two Pointers</Badge>
::::warning
Try to come up with as many solutions as you can. There are at least three different ways to solve this problem. Could you do it in-place with `O(1)` extra space?
::::
### Example 1:
- Input: `nums = [1,2,3,4,5,6,7], k = 3`
- Output: `[5,6,7,1,2,3,4]`
- Explanation: rotate 1 steps to the right: `[7,1,2,3,4,5,6]` rotate 2 steps to the right: `[6,7,1,2,3,4,5]` rotate 3 steps to the right: `[5,6,7,1,2,3,4]`
### Example 2:
- Input: `nums = [-1,-100,3,99], k = 2`
- Output: `[3,99,-1,-100]`
- Explanation: rotate 1 steps to the right: `[99,-1,-100,3]` rotate 2 steps to the right: `[3,99,-1,-100]`
### Constraints:
- `1 <= nums.length <= 10^5`
- `-2^31 <= nums[i] <= 2^31 - 1`
- `0 <= k <= 10^5`
## Solution
```js
/**
* @param {number[]} nums
* @param {number} k
* @return {void} Do not return anything, modify nums in-place instead.
*/
var rotate = function(nums, k) {
};
```
@@ -1,50 +0,0 @@
---
title: '2090. K Radius Subarray Averages'
description: You are given a 0-indexed array nums of n integers, and an integer k
sidebar:
label: 'K Radius Subarray Averages'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `nums = [7,4,3,9,1,8,5,2,6], k = 3`
- Output: `[-1,-1,-1,5,4,4,-1,-1,-1]`
- Explanation:
- `avg[0]`, `avg[1]`, and `avg[2]` are `-1` because there are less than `k` elements before each index.
- The sum of the subarray centered at index `3` with radius `3` is: `7 + 4 + 3 + 9 + 1 + 8 + 5 = 37`. Using integer division, `avg[3] = 37 / 7 = 5`.
- For the subarray centered at index `4`, `avg[4] = (4 + 3 + 9 + 1 + 8 + 5 + 2) / 7 = 4`.
- For the subarray centered at index `5`, `avg[5] = (3 + 9 + 1 + 8 + 5 + 2 + 6) / 7 = 4`.
- `avg[6]`, `avg[7]`, and `avg[8]` are `-1` because there are less than `k` elements after each index.
### Example 2:
- Input: `nums = [100000], k = 0`
- Output: `[100000]`
- Explanation:
- The sum of the subarray centered at index `0` with radius `0` is: `100000`. `avg[0] = 100000 / 1 = 100000`.
### Example 3:
- Input: `nums = [8], k = 100000`
- Output: `[-1]`
- Explanation:
- `avg[0]` is `-1` because there are less than `k` elements before and after index `0`.
### Constraints:
- `n == nums.length`
- `1 <= n <= 10^5`
- `0 <= nums[i], k <= 10^5`
## Solution
```js
/**
* @param {number[]} nums
* @param {number} k
* @return {number[]}
*/
var getAverages = function(nums, k) {
};
```
@@ -0,0 +1,41 @@
---
title: '215. Kth Largest Element in an Array'
description: Given an integer array nums and an integer k, return the k^th largest element in the array
sidebar:
label: 'Kth Largest Element in an Array'
badge: 'Medium'
---
<Badge variant="accent">Heap / Priority Queue</Badge>
### Example 1:
- Input: `nums = [3,2,1,5,6,4], k = 2`
- Output: `5`
### Example 2:
- Input: `nums = [3,2,3,1,2,4,5,5,6], k = 4`
- Output: `4`
### Constraints:
- `1 <= k <= nums.length <= 10^5`
- `-10^4 <= nums[i] <= 10^4`
## Solution
```py
import heapq
class Solution:
def findKthLargest(self, nums: List[int], k: int) -> int:
for i in range(len(nums)):
nums[i] = -nums[i]
heapq.heapify(nums)
for _ in range(k - 1):
heapq.heappop(nums)
return -heapq.heappop(nums)
```
@@ -27,9 +27,29 @@ sidebar:
- `1 <= nums.length <= 10^5`
- `-10^9 <= nums[i] <= 10^9`
## Approach
```mermaid
flowchart TD
S(["containsDuplicate(nums)"]) --> I["freq = {}"]
I --> L{"more n in nums?"}
L -- no --> E(["return false"])
L -- yes --> C["freq[n] = (freq[n] or 0) + 1"]
C --> Q{"freq[n] >= 2?"}
Q -- yes --> R(["return true"])
Q -- no --> L
```
## Solution
```js
<CodeGroup>
```py Python
class Solution:
def containsDuplicate(self, nums: List[int]) -> bool:
```
```js JavaScript
/**
* @param {number[]} nums
* @return {boolean}
@@ -45,3 +65,9 @@ var containsDuplicate = function(nums) {
return false;
};
```
</CodeGroup>
## Explanation
<YouTube id="3OamzN90kPg" title="Contains Duplicate - Leetcode 217 - Python" />
@@ -27,6 +27,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
@@ -51,3 +62,7 @@ var productExceptSelf = function(nums) {
return answer;
};
```
## Explanation
<YouTube id="bNvIQI2wAjk" title="Product of Array Except Self - Leetcode 238 - Python" />
@@ -34,6 +34,18 @@ Could you implement a solution using only O(1) extra space complexity and O(n) r
- `0 <= nums[i] <= n`
- All the numbers of `nums` are unique.
## Approach
```mermaid
flowchart TD
S(["missingNumber(nums)"]) --> I["numSet = Set(nums), expectedCount = n + 1"]
I --> L{"more i in 0..expectedCount-1?"}
L -- yes --> Q{"numSet has i?"}
Q -- no --> R(["return i"])
Q -- yes --> L
L -- no --> E(["return -1"])
```
## Solution
```js
@@ -53,3 +65,7 @@ var missingNumber = function(nums) {
return -1;
};
```
## Explanation
<YouTube id="WnPLSRLSANE" title="Missing Number - Blind 75 - Leetcode 268 - Python" />
@@ -0,0 +1,48 @@
---
title: '271. Encode and Decode Strings'
description: Design an algorithm to encode a list of strings to a string. The encoded string is then sent over the network and is decoded back to the original list of strings
sidebar:
label: 'Encode and Decode Strings'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
::::warning
Could you write a generalized algorithm to work on any possible set of characters?
::::
### Example 1:
- Input: `dummy_input = ["Hello","World"]`
- Output: `["Hello","World"]`
- Explanation: Machine `1`: Codec encoder = new Codec(); String msg = encoder.encode(strs); Machine `1` ---msg---> Machine `2` Machine `2`: Codec decoder = new Codec(); `String[] strs` = decoder.decode(msg);
### Example 2:
- Input: `dummy_input = [""]`
- Output: `[""]`
### Constraints:
- `1 <= strs.length <= 200`
- `0 <= strs[i].length <= 200`
- `strs[i]` contains any possible characters out of 256 valid ASCII characters.
## Solution
```py
class Codec:
def encode(self, strs: List[str]) -> str:
"""Encodes a list of strings to a single string.
"""
def decode(self, s: str) -> List[str]:
"""Decodes a single string to a list of strings.
"""
# Your Codec object will be instantiated and called as such:
# codec = Codec()
# codec.decode(codec.encode(strs))
```
@@ -20,6 +20,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
@@ -50,3 +65,7 @@ class NumArray {
* var param_1 = obj.sumRange(left,right)
*/
```
## Explanation
<YouTube id="2pndAmo_sMA" title="Range Sum Query Immutable - Leetcode 303 - Python" />
@@ -32,6 +32,29 @@ You must write an algorithm with O(log n) runtime complexity.
- `nums` is an ascending array that is possibly rotated.
- `-10^4 <= target <= 10^4`
## Approach
```mermaid
flowchart TD
S(["search(nums, target)"]) --> I["l = 0, r = n - 1"]
I --> W{"l <= r?"}
W -- no --> E(["return -1"])
W -- yes --> M["mid = floor((l + r) / 2)"]
M --> F{"nums[mid] == target?"}
F -- yes --> R(["return mid"])
F -- no --> H{"nums[l] <= nums[mid]? — left half sorted"}
H -- yes --> A{"target outside [nums[l], nums[mid]]?"}
A -- yes --> A1["l = mid + 1 — search the right half"]
A -- no --> A2["r = mid - 1"]
H -- no --> B{"target outside [nums[mid], nums[r]]?"}
B -- yes --> B1["r = mid - 1 — search the left half"]
B -- no --> B2["l = mid + 1"]
A1 --> W
A2 --> W
B1 --> W
B2 --> W
```
## Solution
```js
@@ -69,3 +92,7 @@ var search = function(nums, target) {
return -1;
};
```
## Explanation
<YouTube id="U8XENwh8Oy8" title="Search in rotated sorted array - Leetcode 33 - Python" />
@@ -31,6 +31,16 @@ Your algorithm's time complexity must be better than O(n log n), where n is the
- `k` is in the range `[1, the number of unique elements in the array]`.
- It is guaranteed that the answer is unique.
## Approach
```mermaid
flowchart TD
S(["topKFrequent(nums, k)"]) --> F["freq: count every value in nums"]
F --> K["keys of freq, cast to Number"]
K --> O["sort by freq[b] - freq[a] — most frequent first"]
O --> R(["return slice(0, k)"])
```
## Solution
```js
@@ -49,3 +59,7 @@ var topKFrequent = function (nums, k) {
.slice(0, k);
};
```
## Explanation
<YouTube id="YPTqKIgVk-k" title="Top K Frequent Elements - Bucket Sort - Leetcode 347 - Python" />
@@ -0,0 +1,71 @@
---
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
sidebar:
label: 'Search Insert Position'
badge: 'Easy'
---
<Badge variant="accent">Binary Search</Badge>
::::warning
You must write an algorithm with O(log n) runtime complexity.
::::
### 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
```
## Explanation
<YouTube id="K-RYzDZkzCI" title="Search Insert Position - Binary Search - Leetcode 35 - Python" />
@@ -1,36 +0,0 @@
---
title: '42. Trapping Rain Water'
description: Given n non-negative integers representing an elevation map where the width of each bar is 1, compute how much water it can trap after raining
sidebar:
label: 'Trapping Rain Water'
badge: 'Hard'
---
<Badge variant="accent">Two Pointers</Badge>
### Example 1:
- Input: `height = [0,1,0,2,1,0,1,3,2,1,2,1]`
- Output: `6`
- Explanation: The above elevation map (black section) is represented by array `[0,1,0,2,1,0,1,3,2,1,2,1]`. In this case, `6` units of rain water (blue section) are being trapped.
### Example 2:
- Input: `height = [4,2,0,3,2,5]`
- Output: `9`
### Constraints:
- `n == height.length`
- `1 <= n <= 2 * 10^4`
- `0 <= height[i] <= 10^5`
## Solution
```js
/**
* @param {number[]} height
* @return {number}
*/
var trap = function(height) {
};
```
@@ -0,0 +1,37 @@
---
title: '48. Rotate Image'
description: You are given an n x n 2D matrix representing an image, rotate the image by 90 degrees (clockwise)
sidebar:
label: 'Rotate Image'
badge: 'Medium'
---
<Badge variant="accent">Matrix Index Math</Badge>
### Example 1:
- Input: `matrix = [[1,2,3],[4,5,6],[7,8,9]]`
- Output: `[[7,4,1],[8,5,2],[9,6,3]]`
### Example 2:
- Input: `matrix = [[5,1,9,11],[2,4,8,10],[13,3,6,7],[15,14,12,16]]`
- Output: `[[15,13,2,5],[14,3,4,1],[12,6,8,9],[16,7,10,11]]`
### Constraints:
- `n == matrix.length == matrix[i].length`
- `1 <= n <= 20`
- `-1000 <= matrix[i][j] <= 1000`
## Solution
```py
class Solution:
def rotate(self, matrix: List[List[int]]) -> None:
n = len(matrix)
for r in range(n):
for c in range(r + 1, n):
matrix[r][c], matrix[c][r] = matrix[c][r], matrix[r][c]
for r in range(n):
matrix[r].reverse()
```
@@ -27,6 +27,21 @@ sidebar:
- `0 <= strs[i].length <= 100`
- `strs[i]` consists of lowercase English letters.
## Approach
```mermaid
flowchart TD
S(["groupAnagrams(strs)"]) --> K["sorted[i] = letters of strs[i], sorted — the anagram key"]
K --> I["anagrams = {}"]
I --> L{"more i in 0..n-1?"}
L -- no --> E(["return Object.values(anagrams)"])
L -- yes --> Q{"anagrams has sorted[i]?"}
Q -- no --> N["anagrams[sorted[i]] = [strs[i]]"]
Q -- yes --> P["anagrams[sorted[i]].push(strs[i])"]
N --> L
P --> L
```
## Solution
```js
@@ -49,3 +64,7 @@ var groupAnagrams = function(strs) {
return Object.values(anagrams);
};
```
## Explanation
<YouTube id="vzdNOK2oB2E" title="Group Anagrams - Categorize Strings by Count - Leetcode 49" />
@@ -0,0 +1,59 @@
---
title: '54. Spiral Matrix'
description: Given an m x n matrix, return all elements of the matrix in spiral order
sidebar:
label: 'Spiral Matrix'
badge: 'Medium'
---
<Badge variant="accent">Matrix Index Math</Badge>
### Example 1:
- Input: `matrix = [[1,2,3],[4,5,6],[7,8,9]]`
- Output: `[1,2,3,6,9,8,7,4,5]`
### Example 2:
- Input: `matrix = [[1,2,3,4],[5,6,7,8],[9,10,11,12]]`
- Output: `[1,2,3,4,8,12,11,10,9,5,6,7]`
### Constraints:
- `m == matrix.length`
- `n == matrix[i].length`
- `1 <= m, n <= 10`
- `-100 <= matrix[i][j] <= 100`
## Solution
```py
class Solution:
def spiralOrder(self, matrix: List[List[int]]) -> List[int]:
res = []
top, bottom = 0, len(matrix) - 1
left, right = 0, len(matrix[0]) - 1
while top <= bottom and left <= right:
# 1. Top Row
for c in range(left, right + 1):
res.append(matrix[top][c])
top += 1
# 2. Right Column
for r in range(top, bottom + 1):
res.append(matrix[r][right])
right -= 1
# 3. Bottom Row
if top <= bottom:
for c in range(right, left - 1, -1):
res.append(matrix[bottom][c])
bottom -= 1
# 4. Left Column
if left <= right:
for r in range(bottom, top - 1, -1):
res.append(matrix[r][left])
left += 1
return res
```
@@ -22,6 +22,21 @@ sidebar:
- `-1000 <= nums[i] <= 1000`
- `-10^7 <= k <= 10^7`
## Approach
```mermaid
flowchart TD
S(["subarraySum(nums, k)"]) --> I["map = {0: 1} — the empty prefix; sum = 0, count = 0"]
I --> L{"more n in nums?"}
L -- no --> E(["return count"])
L -- yes --> A["sum += n"]
A --> Q{"map has sum - k?"}
Q -- yes --> C["count += map.get(sum - k) — every earlier prefix that closes a window"]
Q -- no --> W["map.set(sum, count of sum + 1)"]
C --> W
W --> L
```
## Solution
```js
@@ -49,3 +64,7 @@ var subarraySum = function(nums, k) {
return count
};
```
## Explanation
<YouTube id="fFVZt-6sgyo" title="Subarray Sum Equals K - Prefix Sums - Leetcode 560 - Python" />
@@ -29,6 +29,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
@@ -54,3 +70,7 @@ var search = function(nums, target) {
return -1;
};
```
## Explanation
<YouTube id="s4DPM8ct1pI" title="Binary Search - Leetcode 704 - Python" />
@@ -0,0 +1,67 @@
---
title: '73. Set Matrix Zeroes'
description: Given an m x n integer matrix matrix, if an element is 0, set its entire row and column to 0's
sidebar:
label: 'Set Matrix Zeroes'
badge: 'Medium'
---
<Badge variant="accent">Matrix Index Math</Badge>
::::warning
You must do it in place.
::::
### Example 1:
- Input: `matrix = [[1,1,1],[1,0,1],[1,1,1]]`
- Output: `[[1,0,1],[0,0,0],[1,0,1]]`
### Example 2:
- Input: `matrix = [[0,1,2,0],[3,4,5,2],[1,3,1,5]]`
- Output: `[[0,0,0,0],[0,4,5,0],[0,3,1,0]]`
### Constraints:
- `m == matrix.length`
- `n == matrix[0].length`
- `1 <= m, n <= 200`
- `-2^31 <= matrix[i][j] <= 2^31 - 1`
- A straightforward solution using O(mn) space is probably a bad idea.
- A simple improvement uses O(m + n) space, but still not the best solution.
- Could you devise a constant space solution?
## Solution
```py
class Solution:
def setZeroes(self, matrix: List[List[int]]) -> None:
"""
Do not return anything, modify matrix in-place instead.
"""
m, n = len(matrix), len(matrix[0])
# 1. remember if row 0 or column 0 have their own zeros
row0_zero = any(matrix[0][c] == 0 for c in range(n))
col0_zero = any(matrix[r][0] == 0 for r in range(m))
# 2. use row 0 and column 0 as marker lists
for r in range(1, m):
for c in range(1, n):
if matrix[r][c] == 0:
matrix[r][0] = 0
matrix[0][c] = 0
# 3. zero the inner cells from the markers
for r in range(1, m):
for c in range(1, n):
if matrix[r][0] == 0 or matrix[0][c] == 0:
matrix[r][c] = 0
# 4. handle row 0 and column 0 last
if row0_zero:
for c in range(n):
matrix[0][c] = 0
if col0_zero:
for r in range(m):
matrix[r][0] = 0
```
@@ -0,0 +1,59 @@
---
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
sidebar:
label: 'Daily Temperatures'
badge: 'Medium'
---
<Badge variant="accent">Monotonic Stack</Badge>
### Example 1:
- Input: `temperatures = [73,74,75,71,69,72,76,73]`
- Output: `[1,1,4,2,1,1,0,0]`
### 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
```
## Explanation
<YouTube id="cTBiBSnjO3c" title="Daily Temperatures - Monotonic Stack - Leetcode 739 - Python" />
@@ -0,0 +1,91 @@
---
title: '74. Search a 2D Matrix'
description: 'You are given an m x n integer matrix matrix with the following two properties:'
sidebar:
label: 'Search a 2D Matrix'
badge: 'Medium'
---
<Badge variant="accent">Binary Search</Badge>
::::warning
You must write a solution in O(log(m * n)) time complexity.
::::
### 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
```
## Explanation
<YouTube id="Ber2pi2C0j0" title="Search a 2D Matrix - Leetcode 74 - Python" />
@@ -0,0 +1,67 @@
---
title: '853. Car Fleet'
description: There are n cars at given miles away from the starting mile 0, traveling to reach the mile target
sidebar:
label: 'Car Fleet'
badge: 'Medium'
---
<Badge variant="accent">Monotonic Stack</Badge>
### Example 1:
- Input: `target = 12, position = [10,8,0,5,3], speed = [2,4,1,1,3]`
- Output: `3`
- 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)
```
## Explanation
<YouTube id="Pr6T-3yB9RM" title="Car Fleet - Leetcode 853 - Python" />
@@ -26,6 +26,22 @@ sidebar:
- `piles.length <= h <= 10^9`
- `1 <= piles[i] <= 10^9`
## Approach
```mermaid
flowchart TD
S(["minEatingSpeed(piles, h)"]) --> I["l = 1, r = max(piles)"]
I --> W{"l < r?"}
W -- no --> E(["return l — the smallest workable speed"])
W -- yes --> M["mid = floor((l + r) / 2)"]
M --> K["kWorks(mid): hours = sum of ceil(p / mid)"]
K --> Q{"hours <= h?"}
Q -- yes --> A["r = mid — keep mid, try slower"]
Q -- no --> B["l = mid + 1 — too slow, speed up"]
A --> W
B --> W
```
## Solution
```js
@@ -60,3 +76,7 @@ var minEatingSpeed = function(piles, h) {
return l;
};
```
## Explanation
<YouTube id="U2SozAs9RzA" title="Koko Eating Bananas - Binary Search - Leetcode 875 - Python" />
@@ -0,0 +1,48 @@
---
title: '973. K Closest Points to Origin'
description: Given an array of points where points[i] = [xi, yi] represents a point on the X-Y plane and an integer k, return the k closest points to the origin (0, 0)
sidebar:
label: 'K Closest Points to Origin'
badge: 'Medium'
---
<Badge variant="accent">Heap / Priority Queue</Badge>
### Example 1:
- Input: `points = [[1,3],[-2,2]], k = 1`
- Output: `[[-2,2]]`
- Explanation: The distance between `(1`, `3`) and the origin is sqrt(10). The distance between `(-2`, `2`) and the origin is sqrt(8). Since sqrt(8) < sqrt(10), `(-2`, `2`) is closer to the origin. We only want the closest `k = 1 points` from the origin, so the answer is just [[-2,2]].
### Example 2:
- Input: `points = [[3,3],[5,-1],[-2,4]], k = 2`
- Output: `[[3,3],[-2,4]]`
- Explanation: The answer [[-2,4],[3,3]] would also be accepted.
### Constraints:
- `1 <= k <= points.length <= 10^4`
- `-10^4 <= xi, yi <= 10^4`
## Solution
```py
import heapq
class Solution:
def kClosest(self, points: List[List[int]], k: int) -> List[List[int]]:
minHeap = []
for x, y in points:
dist = (x**2) + (y**2)
minHeap.append([dist, x, y])
heapq.heapify(minHeap)
res = []
while k > 0:
dist, x, y = heapq.heappop(minHeap)
res.append([x, y])
k -= 1
return res
```
@@ -27,6 +27,21 @@ Squaring each element and sorting the new array is very trivial, could you find
- `-10^4 <= nums[i] <= 10^4`
- `nums` is sorted in non-decreasing order.
## Approach
```mermaid
flowchart TD
S(["sortedSquares(nums)"]) --> I["ans = Array(n), left = 0, right = n - 1"]
I --> L{"more i from n-1 down to 0?"}
L -- no --> E(["return ans"])
L -- yes --> C{"abs(nums[left]) < abs(nums[right])?"}
C -- yes --> R["square = nums[right]; right--"]
C -- no --> F["square = nums[left]; left++"]
R --> W["ans[i] = square * square — fill from the back, largest first"]
F --> W
W --> L
```
## Solution
```js
@@ -53,3 +68,7 @@ var sortedSquares = function(nums) {
return ans;
};
```
## Explanation
<YouTube id="FPCZsG_AkUg" title="Squares of a Sorted Array - Leetcode 977 - Python" />
@@ -0,0 +1,74 @@
---
title: '141. Linked List Cycle'
description: Given head, the head of a linked list, determine if the linked list has a cycle in it
sidebar:
label: 'Linked List Cycle'
badge: 'Easy'
---
<Badge variant="accent">Linked List</Badge>
::::warning
Can you solve it using O(1) (i.e. constant) memory?
::::
### Example 1:
- Input: `head = [3,2,0,-4], pos = 1`
- Output: `true`
- Explanation: There is a cycle in the linked list, where the tail connects to the 1st node (0-indexed).
### Example 2:
- Input: `head = [1,2], pos = 0`
- Output: `true`
- Explanation: There is a cycle in the linked list, where the tail connects to the 0th node.
### Example 3:
- Input: `head = [1], pos = -1`
- Output: `false`
- Explanation: There is no cycle in the linked list.
### Constraints:
- The number of the nodes in the list is in the range [0, 10^4].
- `-10^5 <= Node.val <= 10^5`
- pos is -1 or a valid index in the linked-list.
## Approach
```mermaid
flowchart TD
S(["hasCycle(head)"]) --> I["fast = slow = head"]
I --> W{"fast and fast.next?"}
W -- no --> E(["return False — ran off the end, no cycle"])
W -- yes --> A["fast = fast.next.next — two steps"]
A --> B["slow = slow.next — one step"]
B --> Q{"fast is slow?"}
Q -- yes --> R(["return True — the gap closed, so it loops"])
Q -- no --> W
```
## Solution
```py
# Definition for singly-linked list.
# class ListNode:
# def __init__(self, x):
# self.val = x
# self.next = None
class Solution:
def hasCycle(self, head: Optional[ListNode]) -> bool:
fast = slow = head
while fast and fast.next:
fast = fast.next.next
slow = slow.next
if fast is slow:
return True
return False
```
## Explanation
<YouTube id="gBTe7lFR3vc" title="Linked List Cycle - Floyd's Tortoise and Hare - Leetcode 141 - Python" />
@@ -0,0 +1,115 @@
---
title: '146. LRU Cache'
description: Design a data structure that follows the constraints of a Least Recently Used (LRU) cache
sidebar:
label: 'LRU Cache'
badge: 'Medium'
---
<Badge variant="accent">Hybrid Structures</Badge>
### Example 1:
- Input: ``
- Output: ``
- Explanation: Input ["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"] `[[2]`, [1, 1], [2, 2], `[1]`, [3, 3], `[2]`, [4, 4], `[1]`, `[3]`, [4]] Output [null, null, null, `1`, null, `-1`, null, `-1`, `3`, 4] Explanation LRUCache lRUCache = new LRUCache(2); lRUCache.put(1, `1`); // cache is {1=1} lRUCache.put(2, `2`); // cache is {1=1, 2=2} lRUCache.get(1); // return `1` lRUCache.put(3, `3`); // LRU `key` was `2`, evicts `key 2`, cache is {1=1, 3=3} lRUCache.get(2); // returns `-1` (not found) lRUCache.put(4, `4`); // LRU `key` was `1`, evicts `key 1`, cache is {4=4, 3=3} lRUCache.get(1); // return `-1` (not found) lRUCache.get(3); // return `3` lRUCache.get(4); // return `4`
### Constraints:
- `1 <= capacity <= 3000`
- `0 <= key <= 10^4`
- `0 <= value <= 10^5`
- At most 2 * 10^5 calls will be made to get and put.
## Approach
```mermaid
flowchart TD
subgraph state["state — dict for O(1) lookup, doubly linked list for O(1) reorder"]
H["head — least recent"] --- N["... nodes ..."] --- T["tail — most recent"]
end
subgraph get["get(key)"]
G1{"key in cache?"} -- no --> G2(["return -1"])
G1 -- yes --> G3["_remove_node then _add_node — move it beside the tail"]
G3 --> G4(["return node.value"])
end
subgraph put["put(key, value)"]
P1{"key in cache?"} -- yes --> P2["update value, _remove_node then _add_node"]
P1 -- no --> P3["new Node, cache[key] = node, _add_node"]
P3 --> P4{"len(cache) > capacity?"}
P4 -- yes --> P5["evict head.next — the least recent — and del cache[lru.key]"]
P4 -- no --> P6(["done"])
P2 --> P6
P5 --> P6
end
state --> get
state --> put
```
## Solution
```py
class Node:
def __init__(self, key=0, value=0):
self.key = key
self.value = value
self.prev = None
self.next = None
class LRUCache:
def __init__(self, capacity: int):
self.capacity = capacity
self.cache = {}
self.head = Node()
self.tail = Node()
self.head.next = self.tail # pyright: ignore[reportAttributeAccessIssue]
self.tail.prev = self.head # pyright: ignore[reportAttributeAccessIssue]
def _remove_node(self, node):
prev_node = node.prev
next_node = node.next
prev_node.next = next_node
next_node.prev = prev_node
def _add_node(self, node):
# Insert just before the tail (most recent at end)
node.next = self.tail
node.prev = self.tail.prev
self.tail.prev.next = node # pyright: ignore[reportAttributeAccessIssue]
self.tail.prev = node
def get(self, key: int) -> int:
if key not in self.cache:
return -1
node = self.cache[key]
self._remove_node(node)
self._add_node(node)
return node.value
def put(self, key: int, value: int) -> None:
if key in self.cache:
node = self.cache[key]
node.value = value
self._remove_node(node)
self._add_node(node)
else:
new_node = Node(key, value)
self.cache[key] = new_node
self._add_node(new_node)
if len(self.cache) > self.capacity:
lru = self.head.next # Oldest node is next to the head
self._remove_node(lru)
del self.cache[lru.key] # pyright: ignore[reportAttributeAccessIssue]
# Your LRUCache object will be instantiated and called as such:
# obj = LRUCache(capacity)
# param_1 = obj.get(key)
# obj.put(key,value)
```
## Explanation
<YouTube id="7ABFKPK2hD4" title="LRU Cache - Twitch Interview Question - Leetcode 146" />
@@ -22,6 +22,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
@@ -25,6 +25,19 @@ What if the inputs contain Unicode characters? How would you adapt your solution
- `1 <= s.length, t.length <= 5 * 10^4`
- `s` and `t` consist of lowercase English letters.
## Approach
```mermaid
flowchart TD
S(["isAnagram(s, t)"]) --> I["freq: count every char of s"]
I --> L{"more char c in t?"}
L -- no --> E(["return true"])
L -- yes --> Q{"freq[c] missing or 0?"}
Q -- yes --> R(["return false — t has a char s cannot cover"])
Q -- no --> D["freq[c] -= 1"]
D --> L
```
## Solution
```js
@@ -46,3 +59,7 @@ var isAnagram = function(s, t) {
return true;
};
```
## Explanation
<YouTube id="9UtInBqnCgA" title="Valid Anagram - Leetcode 242 - Python" />
@@ -28,6 +28,21 @@ sidebar:
- `0 <= s.length <= 10^5`
- `s` consists of English letters, digits, symbols and spaces.
## Approach
```mermaid
flowchart TD
S(["lengthOfLongestSubstring(s)"]) --> I["left = 0, ans = 0, window = set()"]
I --> L{"more (right, c) in s?"}
L -- no --> E(["return ans"])
L -- yes --> W{"c already in window?"}
W -- yes --> P["window.remove(s[left]); left += 1 — shrink until c is free"]
P --> W
W -- no --> A["window.add(c)"]
A --> M["ans = max(ans, right - left + 1)"]
M --> L
```
## Solution
```py
@@ -45,3 +60,7 @@ class Solution:
return ans
```
## Explanation
<YouTube id="wiGpQwVHdE0" title="Longest Substring Without Repeating Characters - Leetcode 3 - Python" />
@@ -26,6 +26,18 @@ sidebar:
- `1 <= s.length <= 10^5`
- `s` consists of only lowercase English letters.
## Approach
```mermaid
flowchart TD
S(["firstUniqChar(s)"]) --> F["Pass 1 — freq: count every char of s"]
F --> L{"Pass 2 — more i in 0..len-1?"}
L -- no --> E(["return -1"])
L -- yes --> Q{"freq[s[i]] == 1?"}
Q -- yes --> R(["return i — leftmost wins because the scan is in order"])
Q -- no --> L
```
## Solution
```js
@@ -45,3 +57,7 @@ var firstUniqChar = function (s) {
return -1;
};
```
## Explanation
<YouTube id="rBENYgWy3xU" title="First Unique Character in a String - Leetcode 387 - Python" />
@@ -24,6 +24,21 @@ sidebar:
- `s` consists of only uppercase English letters.
- `0 <= k <= s.length`
## Approach
```mermaid
flowchart TD
S(["characterReplacement(s, k)"]) --> I["count = {}, res = 0, l = 0, maxF = 0"]
I --> L{"more (r, c) in s?"}
L -- no --> E(["return res"])
L -- yes --> A["count[c] += 1; maxF = max(maxF, count[c])"]
A --> W{"(r - l + 1) - maxF > k? — too many chars to replace"}
W -- yes --> P["count[s[l]] -= 1; l += 1 — shrink from the left"]
P --> W
W -- no --> M["res = max(res, r - l + 1)"]
M --> L
```
## Solution
```py
@@ -45,3 +60,7 @@ class Solution:
res = max(res, r - l + 1)
return res
```
## Explanation
<YouTube id="gqXU1UyA8pk" title="Longest Repeating Character Replacement - Leetcode 424 - Python" />
@@ -28,6 +28,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
@@ -47,3 +59,7 @@ var frequencySort = function (s) {
.join("");
};
```
## Explanation
<YouTube id="OXdXc9HTrIg" title="Sort Characters By Frequency - Leetcode 451 - Python" />
@@ -22,6 +22,24 @@ sidebar:
- `1 <= s1.length, s2.length <= 10^4`
- `s1` and `s2` consist of lowercase English letters.
## Approach
```mermaid
flowchart TD
S(["checkInclusion(s1, s2)"]) --> G{"k = len(s1) > len(s2)?"}
G -- yes --> X(["return False"])
G -- no --> I["need = Counter(s1), window = Counter()"]
I --> L{"more (right, c) in s2?"}
L -- no --> E(["return False"])
L -- yes --> A["window[c] += 1"]
A --> B{"right >= k? — window is now longer than k"}
B -- yes --> C["drop s2[right - k] from window, deleting the key at 0"]
B -- no --> Q
C --> Q{"window == need?"}
Q -- yes --> R(["return True"])
Q -- no --> L
```
## Solution
```py
@@ -49,3 +67,7 @@ class Solution:
return False
```
## Explanation
<YouTube id="UbyhOgBN834" title="Permutation in String - Leetcode 567 - Python" />
@@ -0,0 +1,57 @@
---
title: '2. Add Two Numbers'
description: You are given two non-empty linked lists representing two non-negative integers. The digits are stored in reverse order, and each of their nodes contains a single digit. Add the two numbers and return the sum as a linked list
sidebar:
label: 'Add Two Numbers'
badge: 'Medium'
---
<Badge variant="accent">Hybrid Structures</Badge>
### Example 1:
- Input: `l1 = [2,4,3], l2 = [5,6,4]`
- Output: `[7,0,8]`
- Explanation: `342 + 465 = 807`.
### Example 2:
- Input: `l1 = [0], l2 = [0]`
- Output: `[0]`
### Example 3:
- Input: `l1 = [9,9,9,9,9,9,9], l2 = [9,9,9,9]`
- Output: `[8,9,9,9,0,0,0,1]`
### Constraints:
- The number of nodes in each linked list is in the range [1, 100].
- `0 <= Node.val <= 9`
- It is guaranteed that the list represents a number that does not have leading zeros.
## Solution
```py
# Definition for singly-linked list.
# class ListNode:
# def __init__(self, val=0, next=None):
# self.val = val
# self.next = next
class Solution:
def addTwoNumbers(
self, l1: Optional[ListNode], l2: Optional[ListNode]
) -> Optional[ListNode]:
dummy = ListNode()
carry = 0
curr = dummy
while l1 or l2 or carry:
s = (l1.val if l1 else 0) + (l2.val if l2 else 0) + carry
carry, val = divmod(s, 10)
curr.next = ListNode(val)
curr = curr.next
l1 = l1.next if l1 else None
l2 = l2.next if l2 else None
return dummy.next
```
@@ -0,0 +1,67 @@
---
title: '206. Reverse Linked List'
description: Given the head of a singly linked list, reverse the list, and return the reversed list
sidebar:
label: 'Reverse Linked List'
badge: 'Easy'
---
<Badge variant="accent">Linked List</Badge>
::::warning
A linked list can be reversed either iteratively or recursively. Could you implement both?
::::
### Example 1:
- Input: `head = [1,2,3,4,5]`
- Output: `[5,4,3,2,1]`
### Example 2:
- Input: `head = [1,2]`
- Output: `[2,1]`
### Example 3:
- Input: `head = []`
- Output: `[]`
### Constraints:
- The number of nodes in the list is the range [0, 5000].
- `-5000 <= Node.val <= 5000`
## Approach
```mermaid
flowchart TD
S(["reverseList(head)"]) --> I["prev = None, curr = head"]
I --> W{"curr?"}
W -- no --> E(["return prev — the old tail is the new head"])
W -- yes --> A["next_ = curr.next — save it before overwriting"]
A --> B["curr.next = prev — flip the link"]
B --> C["prev = curr; curr = next_ — step forward"]
C --> W
```
## Solution
```py
# Definition for singly-linked list.
# class ListNode:
# def __init__(self, val=0, next=None):
# self.val = val
# self.next = next
class Solution:
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
prev = None
curr = head
while curr:
next_ = curr.next
curr.next = prev
prev = curr
curr = next_
return prev
```
## Explanation
<YouTube id="G0_I-ZF0S38" title="Reverse Linked List - Iterative AND Recursive - Leetcode 206 - Python" />
@@ -0,0 +1,87 @@
---
title: '21. Merge Two Sorted Lists'
description: You are given the heads of two sorted linked lists list1 and list2
sidebar:
label: 'Merge Two Sorted Lists'
badge: 'Easy'
---
<Badge variant="accent">Linked List</Badge>
### Example 1:
- Input: `list1 = [1,2,4], list2 = [1,3,4]`
- Output: `[1,1,2,3,4,4]`
### Example 2:
- Input: `list1 = [], list2 = []`
- Output: `[]`
### Example 3:
- Input: `list1 = [], list2 = [0]`
- Output: `[0]`
### Constraints:
- The number of nodes in both lists is in the range [0, 50].
- `-100 <= Node.val <= 100`
- Both list1 and list2 are sorted in non-decreasing order.
## Approach
```mermaid
flowchart TD
S(["mergeTwoLists(list1, list2)"]) --> G1{"list1 empty?"}
G1 -- yes --> X1(["return list2"])
G1 -- no --> G2{"list2 empty?"}
G2 -- yes --> X2(["return list1"])
G2 -- no --> H["head = the smaller first node; advance that list"]
H --> C["current = head"]
C --> W{"list1 and list2 both non-empty?"}
W -- yes --> P["current.next = the smaller node; advance that list; current = current.next"]
P --> W
W -- no --> T["current.next = list1 or list2 — append the leftover tail"]
T --> E(["return head"])
```
## Solution
```py
# Definition for singly-linked list.
# class ListNode:
# def __init__(self, val=0, next=None):
# self.val = val
# self.next = next
class Solution:
def mergeTwoLists(
self, list1: Optional[ListNode], list2: Optional[ListNode]
) -> Optional[ListNode]:
if not list1:
return list2
if not list2:
return list1
if list1.val < list2.val:
head = list1
list1 = list1.next
else:
head = list2
list2 = list2.next
current = head
while list1 and list2:
if list1.val < list2.val:
current.next = list1
list1 = list1.next
else:
current.next = list2
list2 = list2.next
current = current.next
current.next = list1 or list2
return head
```
## Explanation
<YouTube id="XIdigk956u0" title="Merge Two Sorted Lists - Leetcode 21 - Python" />
@@ -0,0 +1,69 @@
---
title: '23. Merge k Sorted Lists'
description: You are given an array of k linked-lists lists, each linked-list is sorted in ascending order
sidebar:
label: 'Merge k Sorted Lists'
badge: 'Hard'
---
<Badge variant="accent">Heap / Priority Queue</Badge>
### Example 1:
- Input: `lists = [[1,4,5],[1,3,4],[2,6]]`
- Output: `[1,1,2,3,4,4,5,6]`
- Explanation: The linked-lists are: [ 1->4->5, 1->3->4, 2->6 ] merging them into one sorted linked list: 1->1->2->3->4->4->5->6
### Example 2:
- Input: `lists = []`
- Output: `[]`
### Example 3:
- Input: `lists = [[]]`
- Output: `[]`
### Constraints:
- `k == lists.length`
- `0 <= k <= 10^4`
- `0 <= lists[i].length <= 500`
- `-10^4 <= lists[i][j] <= 10^4`
- `lists[i]` is sorted in ascending order.
- The sum of lists[i].length will not exceed 10^4.
## Solution
```py
# Definition for singly-linked list.
# class ListNode:
# def __init__(self, val=0, next=None):
# self.val = val
# self.next = next
import heapq
class Solution:
def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
heap = []
# K log K
for i, node in enumerate(lists):
if node:
heapq.heappush(heap, (node.val, i, node))
D = ListNode()
cur = D
# n log k
while heap:
val, i, node = heapq.heappop(heap)
cur.next = node
cur = node
node = node.next
if node:
heapq.heappush(heap, (node.val, i, node))
# Time: O(N log k)
# Space: O(n)
return D.next
```
@@ -0,0 +1,82 @@
---
title: '155. Min Stack'
description: Design a stack that supports push, pop, top, and retrieving the minimum element in constant time
sidebar:
label: 'Min Stack'
badge: 'Medium'
---
<Badge variant="accent">Stack</Badge>
::::warning
You must implement a solution with O(1) time complexity for each function.
::::
### Example 1:
- Input: ``
- Output: ``
- Explanation: Input `["MinStack","push","push","push","getMin","pop","top","getMin"]` [[],[-2],[0],[-3],[],[],[],[]] Output `[null,null,null,null,-3,null,0,-2]` Explanation MinStack minStack = new MinStack(); minStack.push(-2); minStack.push(0); minStack.push(-3); minStack.getMin(); // return `-3` minStack.pop(); minStack.top(); // return `0` minStack.getMin(); // return `-2`
### Constraints:
- `-2^31 <= val <= 2^31 - 1`
- Methods pop, top and getMin operations will always be called on non-empty stacks.
- At most 3 * 10^4 calls will be made to push, pop, top, and getMin.
## Approach
```mermaid
flowchart TD
subgraph state["two parallel stacks — same depth, always"]
A["stack — the values"]
B["minStack — the minimum as of that depth"]
end
state --> P
subgraph P["push(value)"]
P1["stack.append(value)"] --> P2["minStack.append(min(value, minStack[-1]))"]
end
state --> O
subgraph O["pop()"]
O1["pop both stacks together"]
end
state --> R
subgraph R["top() / getMin() — O(1)"]
R1(["stack[-1] / minStack[-1]"])
end
```
## Solution
```py
class MinStack:
def __init__(self):
self.stack = []
self.minStack = []
def push(self, value: int) -> None:
self.stack.append(value)
value = min(value, self.minStack[-1] if self.minStack else value)
self.minStack.append(value)
def pop(self) -> None:
self.stack.pop()
self.minStack.pop()
def top(self) -> int:
return self.stack[-1]
def getMin(self) -> int:
return self.minStack[-1]
# Your MinStack object will be instantiated and called as such:
# obj = MinStack()
# obj.push(value)
# obj.pop()
# param_3 = obj.top()
# param_4 = obj.getMin()
```
## Explanation
<YouTube id="qkLl7nAwDPo" title="Design Min Stack - Amazon Interview Question - Leetcode 155 - Python" />
@@ -0,0 +1,76 @@
---
title: '20. Valid Parentheses'
description: Given a string s containing just the characters '(', ')', '{', '}', '[' and ']', determine if the input string is valid
sidebar:
label: 'Valid Parentheses'
badge: 'Easy'
---
<Badge variant="accent">Stack</Badge>
### Example 1:
- Input: `s = "()"`
- Output: `true`
### Example 2:
- Input: `s = "()[]{}"`
- Output: `true`
### Example 3:
- Input: `s = "(]"`
- Output: `false`
### Example 4:
- Input: `s = "([])"`
- Output: `true`
### Example 5:
- Input: `s = "([)]"`
- Output: `false`
### Constraints:
- `1 <= s.length <= 10^4`
- `s` consists of parentheses only '()[]{}'.
## Approach
```mermaid
flowchart TD
S(["isValid(s)"]) --> I["stack = [], pairs = closer to opener"]
I --> L{"more char c?"}
L -- no --> Z{"stack empty?"}
Z -- yes --> T(["return True"])
Z -- no --> U(["return False — unclosed openers left"])
L -- yes --> Q{"c is a closer?"}
Q -- no --> A["stack.append(c) — it is an opener"]
A --> L
Q -- yes --> M{"stack non-empty and pairs[c] == stack[-1]?"}
M -- yes --> P["stack.pop() — matched"]
P --> L
M -- no --> F(["return False — mismatch"])
```
## Solution
```py
class Solution:
def isValid(self, s: str) -> bool:
stack = []
pairs = {")": "(", "]": "[", "}": "{"}
for c in s:
if c in pairs:
if stack and pairs[c] == stack[-1]:
stack.pop()
else:
return False
else:
stack.append(c)
return True if not stack else False
```
## Explanation
<YouTube id="WTzjTskDFMg" title="Valid Parentheses - Stack - Leetcode 20 - Python" />
@@ -0,0 +1,61 @@
---
title: '102. Binary Tree Level Order Traversal'
description: Given the root of a binary tree, return the level order traversal of its nodes' values. (i.e., from left to right, level by level)
sidebar:
label: 'Binary Tree Level Order Traversal'
badge: 'Medium'
---
<Badge variant="accent">Tree BFS</Badge>
### Example 1:
- Input: `root = [3,9,20,null,null,15,7]`
- Output: `[[3],[9,20],[15,7]]`
### Example 2:
- Input: `root = [1]`
- Output: `[[1]]`
### Example 3:
- Input: `root = []`
- Output: `[]`
### Constraints:
- The number of nodes in the tree is in the range [0, 2000].
- `-1000 <= Node.val <= 1000`
## Solution
```py
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
from collections import deque
class Solution:
# T: O(n) S: O(n)
def levelOrder(self, root: Optional[TreeNode]) -> List[List[int]]:
res = []
q = deque()
q.append(root)
while q:
qLen = len(q)
level = []
for i in range(qLen):
node = q.popleft()
if node:
level.append(node.val)
q.append(node.left)
q.append(node.right)
if level:
res.append(level)
return res
```
@@ -0,0 +1,39 @@
---
title: '104. Maximum Depth of Binary Tree'
description: Given the root of a binary tree, return its maximum depth
sidebar:
label: 'Maximum Depth of Binary Tree'
badge: 'Easy'
---
<Badge variant="accent">Tree DFS</Badge>
### Example 1:
- Input: `root = [3,9,20,null,null,15,7]`
- Output: `3`
### Example 2:
- Input: `root = [1,null,2]`
- Output: `2`
### Constraints:
- The number of nodes in the tree is in the range [0, 10^4].
- `-100 <= Node.val <= 100`
## Solution
```py
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
class Solution:
def maxDepth(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
return 1 + max(self.maxDepth(root.left), self.maxDepth(root.right))
```
@@ -0,0 +1,52 @@
---
title: '1448. Count Good Nodes in Binary Tree'
description: Given a binary tree root, a node X in the tree is named good if in the path from root to X there are no nodes with a value greater than X
sidebar:
label: 'Count Good Nodes in Binary Tree'
badge: 'Medium'
---
<Badge variant="accent">Tree BFS</Badge>
### Example 1:
- Input: `root = [3,1,4,3,null,1,5]`
- Output: `4`
- Explanation: Nodes in blue are good. Root Node `(3`) is always a good node. Node `4` -> (3,4) is the maximum value in the path starting from the `root`. Node `5` -> (3,4,5) is the maximum value in the path Node `3` -> (3,1,3) is the maximum value in the path.
### Example 2:
- Input: `root = [3,3,null,4,2]`
- Output: `3`
- Explanation: Node `2` -> `(3`, `3`, `2`) is not good, because `"3`" is higher than it.
### Example 3:
- Input: `root = [1]`
- Output: `1`
- Explanation: Root is considered as good.
### Constraints:
- The number of nodes in the binary tree is in the range [1, 10^5].
- Each node's value is between [-10^4, 10^4].
## Solution
```py
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
class Solution:
def goodNodes(self, root: TreeNode) -> int:
def dfs(node, max_val):
if not node:
return 0
is_good = 1 if node.val >= max_val else 0
new_max = max(max_val, node.val)
return is_good + dfs(node.left, new_max) + dfs(node.right, new_max)
return dfs(root, root.val)
```
@@ -0,0 +1,64 @@
---
title: '199. Binary Tree Right Side View'
description: Given the root of a binary tree, imagine yourself standing on the right side of it, return the values of the nodes you can see ordered from top to bottom
sidebar:
label: 'Binary Tree Right Side View'
badge: 'Medium'
---
<Badge variant="accent">Tree BFS</Badge>
### Example 1:
- Input: `root = [1,2,3,null,5,null,4]`
- Output: `[1,3,4]`
### Example 2:
- Input: `root = [1,2,3,4,null,null,null,5]`
- Output: `[1,3,4,5]`
### Example 3:
- Input: `root = [1,null,3]`
- Output: `[1,3]`
### Example 4:
- Input: `root = []`
- Output: `[]`
### Constraints:
- The number of nodes in the tree is in the range [0, 100].
- `-100 <= Node.val <= 100`
## Solution
```py
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
from collections import deque
class Solution:
def rightSideView(self, root: Optional[TreeNode]) -> List[int]:
res = []
q = deque([root])
while q:
rightSide = None
qLen = len(q)
for i in range(qLen):
node = q.popleft()
if node:
rightSide = node
q.append(node.left)
q.append(node.right)
if rightSide:
res.append(rightSide.val)
return res
```
@@ -0,0 +1,49 @@
---
title: '226. Invert Binary Tree'
description: Given the root of a binary tree, invert the tree, and return its root
sidebar:
label: 'Invert Binary Tree'
badge: 'Easy'
---
<Badge variant="accent">Tree DFS</Badge>
### Example 1:
- Input: `root = [4,2,7,1,3,6,9]`
- Output: `[4,7,2,9,6,3,1]`
### Example 2:
- Input: `root = [2,1,3]`
- Output: `[2,3,1]`
### Example 3:
- Input: `root = []`
- Output: `[]`
### Constraints:
- The number of nodes in the tree is in the range [0, 100].
- `-100 <= Node.val <= 100`
## Solution
```py
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
class Solution:
def invertTree(self, root: Optional[TreeNode]) -> Optional[TreeNode]:
if not root:
return None
left = self.invertTree(root.left)
right = self.invertTree(root.right)
root.left = right
root.right = left
return root
```
@@ -0,0 +1,54 @@
---
title: '230. Kth Smallest Element in a BST'
description: Given the root of a binary search tree, and an integer k, return the k^th smallest value (1-indexed) of all the values of the nodes in the tree
sidebar:
label: 'Kth Smallest Element in a BST'
badge: 'Medium'
---
<Badge variant="accent">Binary Search Tree</Badge>
::::warning
If the BST is modified often (i.e., we can do insert and delete operations) and you need to find the kth smallest frequently, how would you optimize?
::::
### Example 1:
- Input: `root = [3,1,4,null,2], k = 1`
- Output: `1`
### Example 2:
- Input: `root = [5,3,6,2,4,null,null,1], k = 3`
- Output: `3`
### Constraints:
- The number of nodes in the tree is n.
- `1 <= k <= n <= 10^4`
- `0 <= Node.val <= 10^4`
## Solution
```py
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
class Solution:
def kthSmallest(self, root: Optional[TreeNode], k: int) -> int:
n = 0
stack = []
cur = root
while cur or stack:
while cur:
stack.append(cur)
cur = cur.left
cur = stack.pop()
n += 1
if n == k:
return cur.val
cur = cur.right
```
@@ -0,0 +1,56 @@
---
title: '235. Lowest Common Ancestor of a Binary Search Tree'
description: Given a binary search tree (BST), find the lowest common ancestor (LCA) node of two given nodes in the BST
sidebar:
label: 'Lowest Common Ancestor of a Binary Search Tree'
badge: 'Medium'
---
<Badge variant="accent">Binary Search Tree</Badge>
### Example 1:
- Input: `root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 8`
- Output: `6`
- Explanation: The LCA of nodes `2` and `8` is `6`.
### Example 2:
- Input: `root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 4`
- Output: `2`
- Explanation: The LCA of nodes `2` and `4` is `2`, since a node can be a descendant of itself according to the LCA definition.
### Example 3:
- Input: `root = [2,1], p = 2, q = 1`
- Output: `2`
### Constraints:
- The number of nodes in the tree is in the range [2, 10^5].
- `-10^9 <= Node.val <= 10^9`
- All Node.val are unique.
- `p != q`
- p and q will exist in the BST.
## Solution
```py
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
class Solution:
def lowestCommonAncestor(
self, root: "TreeNode", p: "TreeNode", q: "TreeNode"
) -> "TreeNode":
cur = root
while cur:
if p.val < cur.val and q.val < cur.val:
cur = cur.left
elif p.val > cur.val and q.val > cur.val:
cur = cur.right
else:
return cur
```
@@ -0,0 +1,62 @@
---
title: '236. Lowest Common Ancestor of a Binary Tree'
description: Given a binary tree, find the lowest common ancestor (LCA) of two given nodes in the tree
sidebar:
label: 'Lowest Common Ancestor of a Binary Tree'
badge: 'Medium'
---
<Badge variant="accent">Tree DFS</Badge>
### Example 1:
- Input: `root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1`
- Output: `3`
- Explanation: The LCA of nodes `5` and `1` is `3`.
### Example 2:
- Input: `root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4`
- Output: `5`
- Explanation: The LCA of nodes `5` and `4` is `5`, since a node can be a descendant of itself according to the LCA definition.
### Example 3:
- Input: `root = [1,2], p = 1, q = 2`
- Output: `1`
### Constraints:
- The number of nodes in the tree is in the range [2, 10^5].
- `-10^9 <= Node.val <= 10^9`
- All Node.val are unique.
- `p != q`
- p and q will exist in the tree.
## Solution
```py
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
class Solution:
def lowestCommonAncestor(
self, root: "TreeNode", p: "TreeNode", q: "TreeNode"
) -> "TreeNode":
if not root:
return None
if root == p or root == q:
return root
l = self.lowestCommonAncestor(root.left, p, q)
r = self.lowestCommonAncestor(root.right, p, q)
if l and r:
return root
else:
return l or r
```
@@ -0,0 +1,46 @@
---
title: '98. Validate Binary Search Tree'
description: Given the root of a binary tree, determine if it is a valid binary search tree (BST)
sidebar:
label: 'Validate Binary Search Tree'
badge: 'Medium'
---
<Badge variant="accent">Binary Search Tree</Badge>
### Example 1:
- Input: `root = [2,1,3]`
- Output: `true`
### Example 2:
- Input: `root = [5,1,4,null,null,3,6]`
- Output: `false`
- Explanation: The `root` node's value is `5` but its right child's value is `4`.
### Constraints:
- The number of nodes in the tree is in the range [1, 10^4].
- `-2^31 <= Node.val <= 2^31 - 1`
## Solution
```py
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
class Solution:
def isValidBST(self, root: Optional[TreeNode]) -> bool:
def valid(node, low, high):
if not node:
return True
if not (low < node.val < high):
return False
return valid(node.left, low, node.val) and valid(node.right, node.val, high)
return valid(root, float("-inf"), float("inf"))
```
@@ -28,6 +28,20 @@ sidebar:
- `1 <= s.length <= 2 * 10^5`
- `s` consists only of printable ASCII characters.
## Approach
```mermaid
flowchart TD
S(["isPalindrome(s)"]) --> N["normal = s stripped of non-alphanumerics, lowercased"]
N --> I["i = 0, j = normal.length - 1"]
I --> W{"i < j?"}
W -- no --> E(["return true — the pointers met in the middle"])
W -- yes --> Q{"normal[i] != normal[j]?"}
Q -- yes --> F(["return false"])
Q -- no --> A["i++; j-- — step both inward"]
A --> W
```
## Solution
```js
@@ -49,3 +63,7 @@ var isPalindrome = function(s) {
return true;
};
```
## Explanation
<YouTube id="jJXJ16kPFWg" title="Valid Palindrome - Leetcode 125 - Python" />
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more