mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
204e178fa4 | ||
|
|
61dc19fb29 | ||
|
|
5c644ca050 | ||
|
|
fb2d4c88c2 | ||
|
|
1fa0731ea7 | ||
|
|
86107d5ef7 | ||
|
|
00f44a1c47 | ||
|
|
24312437eb | ||
|
|
4c9c8f39ae | ||
|
|
88ff63f5c4 | ||
|
|
2066856d15 | ||
|
|
393d72c9fe | ||
|
|
6c18301aa6 | ||
|
|
02f6a20cc1 | ||
|
|
9288aaa152 | ||
|
|
d5ea71dbc1 | ||
|
|
3e1a66eb37 | ||
|
|
9a2b80949b | ||
|
|
883dc69dba | ||
|
|
e3f0b5420a | ||
|
|
987eb7ab5b | ||
|
|
e4118fccd3 | ||
|
|
95db4ca46c | ||
|
|
0cc7ca607e | ||
|
|
71ec39fe34 | ||
|
|
51decd23bd | ||
|
|
90082518a6 | ||
|
|
e10a913546 | ||
|
|
fec94ed636 | ||
|
|
30273d0030 | ||
|
|
50299defef | ||
|
|
c843be855e | ||
|
|
1e0e03daaa | ||
|
|
7b182206dd | ||
|
|
faaaf52f85 | ||
|
|
090f3d6719 | ||
|
|
a3ae16ad96 | ||
|
|
9faa0b0337 | ||
|
|
cc29304903 | ||
|
|
c50b600103 | ||
|
|
a86d72c7db | ||
|
|
e59d78e571 | ||
|
|
a93bb068b6 | ||
|
|
53b2d5ec8c | ||
|
|
5dda64e41e | ||
|
|
d798871fbc | ||
|
|
bb7324fe8c | ||
|
|
d13859d31e | ||
|
|
664ea534ed | ||
|
|
b86f36daf1 | ||
|
|
7bae7b2920 | ||
|
|
64173857e7 | ||
|
|
002c2c9c97 | ||
|
|
fa8deac963 | ||
|
|
ef8d2b41e1 | ||
|
|
51ce47a49a | ||
|
|
4650b33364 | ||
|
|
804ad1379e | ||
|
|
0d845838c2 | ||
|
|
b7041b1440 | ||
|
|
5655cede21 | ||
|
|
025ce82041 | ||
|
|
a3657732f6 | ||
|
|
1622bf39b7 | ||
|
|
5ea123e46d | ||
|
|
38fbb18f32 | ||
|
|
7e46fceb1c | ||
|
|
b21ab75e0e | ||
|
|
d784575c87 | ||
|
|
70f88122e0 | ||
|
|
47c2b20646 | ||
|
|
e8b0d4dc30 | ||
|
|
bd9afc6ea1 | ||
|
|
8396c4a7ed | ||
|
|
bf4a1f4c7a | ||
|
|
e23a68c91c | ||
|
|
7384a33f3a | ||
|
|
0064d15844 | ||
|
|
e061aca54c | ||
|
|
1eb85ee2fb | ||
|
|
cac2f744e4 | ||
|
|
9e90cfb729 | ||
|
|
dc15237381 | ||
|
|
af7063b71f | ||
|
|
c5f3c95fcc | ||
|
|
fe2a7bb4e9 | ||
|
|
8689075ac9 | ||
|
|
9b5879b6eb | ||
|
|
53136b076c | ||
|
|
95f10980d4 | ||
|
|
c6ecb85507 | ||
|
|
2a69f36e6a | ||
|
|
5e4628b20f | ||
|
|
72a8399e6d | ||
|
|
6ebca68ff8 | ||
|
|
957ddf9849 | ||
|
|
e67cc79f39 | ||
|
|
f086c2b39a |
No files matched your search
@@ -0,0 +1,180 @@
|
||||
---
|
||||
name: campaign-issues
|
||||
description: Create GitHub issues for this LeetCode campaign — individual problem issues and the topic issues that own them — using the repo's exact title, label, milestone, body, and sub-issue conventions. Use when asked to add/create an issue for a LeetCode problem or a topic, attach a problem to its topic, or backfill the issue catalog.
|
||||
---
|
||||
|
||||
# Campaign issues
|
||||
|
||||
The GitHub issue tracker **is the campaign catalog**: ~24 `topic` issues each own a
|
||||
set of `problem` sub-issues. The Worker reconciles this catalog into D1 nightly
|
||||
(`/admin/reconcile`), so a correctly-labelled issue is all that's needed — the SRS,
|
||||
digest, charts, and Project mirror pick it up automatically. Get the labels/body shape
|
||||
right; everything downstream is reconciliation.
|
||||
|
||||
All commands use `gh` against `prdlk/leetcode` and fall back to `gh auth token`.
|
||||
|
||||
## Problem issue
|
||||
|
||||
One issue per LeetCode problem, attached as a sub-issue of its topic.
|
||||
|
||||
- **Title:** `LC <num> · <Title>` — the LC number and the problem's name, nothing
|
||||
else. Separator is a middle dot `·` (U+00B7) with single spaces. Difficulty and
|
||||
set are **labels only**; never repeat them in the title (they used to be a `·
|
||||
Hard · core` suffix — that is gone, and `catalog.ts` no longer parses it).
|
||||
- **Labels** (four, all required — these are what the Worker reads):
|
||||
- `problem` — marks the sub-issue as curriculum; without it the reconcile skips it.
|
||||
- `diff:easy` | `diff:medium` | `diff:hard` — LeetCode's own rating, do not invent.
|
||||
- `set:core` | `set:optional` | `set:deferred` — see set semantics below.
|
||||
- `phase:N` — the **topic's** phase (same phase label the topic issue carries).
|
||||
- **Milestone:**
|
||||
- `core` / `optional` → the topic's phase milestone.
|
||||
- `deferred` → `Review — Transfer & Mocks` (deferred Hards are parked until week 8).
|
||||
- Pass the milestone **title**, not its number, to `--milestone`.
|
||||
- **Body** (exact shape; `<slug>` is the LeetCode URL slug, `<topic-issue>` the owning topic's number):
|
||||
|
||||
```
|
||||
https://leetcode.com/problems/<slug>/
|
||||
|
||||
- **Topic:** #<topic-issue>
|
||||
- **Set:** <core|optional|deferred>
|
||||
- **Language:** Python
|
||||
|
||||
### Close-out (paste before closing this issue)
|
||||
- Time: O(?)
|
||||
- Space: O(?)
|
||||
- Edge case traced:
|
||||
- First attempt passed: yes / no
|
||||
- Miss note:
|
||||
```
|
||||
|
||||
Create it:
|
||||
|
||||
```sh
|
||||
gh issue create --repo prdlk/leetcode \
|
||||
--title "LC 42 · Trapping Rain Water" \
|
||||
--label problem --label set:core --label diff:hard --label phase:1 \
|
||||
--milestone "Phase I — Linear Structures" \
|
||||
--body "https://leetcode.com/problems/trapping-rain-water/
|
||||
|
||||
- **Topic:** #9
|
||||
- **Set:** core
|
||||
- **Language:** Python
|
||||
|
||||
### Close-out (paste before closing this issue)
|
||||
- Time: O(?)
|
||||
- Space: O(?)
|
||||
- Edge case traced:
|
||||
- First attempt passed: yes / no
|
||||
- Miss note:"
|
||||
```
|
||||
|
||||
Then **attach it as a sub-issue of the topic** (this is a separate API call — creating
|
||||
the issue does not link it). The sub-issues API wants the child's internal **id**, not
|
||||
its issue number, and the field is integer-typed, so use `-F` (not `-f`):
|
||||
|
||||
```sh
|
||||
child_id=$(gh api repos/prdlk/leetcode/issues/211 --jq '.id')
|
||||
gh api --method POST repos/prdlk/leetcode/issues/9/sub_issues -F sub_issue_id=$child_id
|
||||
# verify
|
||||
gh api repos/prdlk/leetcode/issues/9/sub_issues --jq '.[] | "\(.number) \(.title)"'
|
||||
```
|
||||
|
||||
## Topic issue
|
||||
|
||||
One issue per curriculum topic; owns the problem sub-issues.
|
||||
|
||||
- **Title:** `Topic NN — <Name>` (zero-padded number, em dash, e.g. `Topic 07 — Monotonic Stack`).
|
||||
- **Labels:** `topic`, `phase:N`.
|
||||
- **Milestone:** that phase's milestone (title, not number).
|
||||
- **Body:**
|
||||
|
||||
```
|
||||
**Scheduled:** <Weekday Mon DD, YYYY>
|
||||
**Phase:** <Roman> — <Phase name> (Week N)
|
||||
**Builds on:** Topic <n>
|
||||
|
||||
### Trigger
|
||||
TODO
|
||||
|
||||
### Invariant
|
||||
TODO
|
||||
|
||||
### Trap
|
||||
TODO
|
||||
|
||||
### Close-out ritual
|
||||
Before every submit, out loud:
|
||||
1. State time and space complexity.
|
||||
2. Trace one edge case.
|
||||
|
||||
### Day rules
|
||||
- 90-minute coding cap. Stop at the cap, even mid-problem.
|
||||
- Core problems first. Optional only if the core is done.
|
||||
- Finished early? Do this topic's optional set. Do not start tomorrow.
|
||||
```
|
||||
|
||||
`Scheduled` should come from the calendar of record — `apps/api/data/schedule.json`
|
||||
(`date → { topics: [issue, …] }`; a doubled day lists two) — not guessed.
|
||||
|
||||
Gate problems (`gate: [...]` in the same file) must exist in the catalog before their
|
||||
Saturday, or the Worker refuses to create the review issue: create each as a
|
||||
`set:optional` problem sub-issue of its natural topic. Being named in the plan keeps it
|
||||
out of the drill pool, so it stays unseen until the gate.
|
||||
|
||||
## Reference tables
|
||||
|
||||
Set semantics (from the label descriptions):
|
||||
|
||||
| set | meaning | milestone |
|
||||
|---|---|---|
|
||||
| `core` | must solve on the topic day | topic's phase milestone |
|
||||
| `optional` | solve only if the clock allows | topic's phase milestone |
|
||||
| `deferred` | a Hard, parked until week 8 | `Review — Transfer & Mocks` |
|
||||
|
||||
Milestones (`--milestone` takes the **title**):
|
||||
|
||||
| # | title |
|
||||
|---|---|
|
||||
| 1 | Phase I — Linear Structures |
|
||||
| 2 | Phase II — Nodal & Grid |
|
||||
| 3 | Phase III — Hierarchical |
|
||||
| 4 | Phase IV — Relational |
|
||||
| 5 | Phase V — Decision Space |
|
||||
| 6 | Review — Transfer & Mocks |
|
||||
|
||||
Find a topic issue and confirm its phase before creating problems under it:
|
||||
|
||||
```sh
|
||||
gh issue list --repo prdlk/leetcode --label topic --state all --limit 30 \
|
||||
--json number,title,labels,milestone
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Check the LC number is not already in the curriculum BEFORE creating.** D1 keys
|
||||
`problems` on `lc_number` (`ON CONFLICT(lc_number)`), so two issues for one LC number
|
||||
silently fight over a single row — whichever the reconcile walks last wins, and the
|
||||
loser's issue number ends up orphaned in D1. A problem can legitimately move topic or
|
||||
set; that is a relabel/re-parent of the *existing* issue, never a second issue:
|
||||
|
||||
```sh
|
||||
gh issue list --repo prdlk/leetcode --label problem --state all --limit 300 \
|
||||
--search "LC 42" --json number,title,labels,state
|
||||
```
|
||||
|
||||
If a row already exists, change the existing issue instead: swap its `set:*` label,
|
||||
move the sub-issue to the new topic, and fix its milestone. Then reconcile the board
|
||||
(`project-fields` skill). If a duplicate did get created and one is deleted, the
|
||||
surviving issue number and set only reach D1 on the next catalog reconcile — SRS
|
||||
columns (`stage`, `next_review`) are preserved, since they are the real attempt history.
|
||||
|
||||
- **`-F` vs `-f`:** `sub_issue_id` must be an integer; `-f` sends a string and the API
|
||||
rejects it with `is not of type integer`.
|
||||
- **id, not number:** the sub-issue payload wants the child's `.id` (a large opaque
|
||||
integer), never its display `#number`.
|
||||
- **`--milestone` wants the title string**, not the numeric id (`'1' not found` otherwise).
|
||||
- **Reconcile, don't react:** don't edit D1 or the Project by hand — a correct issue is
|
||||
reconciled into SRS state on the nightly cron. Solutions landing in `work/` are what
|
||||
close problem issues (via `close-solved.yml`), which then roll up to close topics.
|
||||
- **Difficulty is LeetCode's**, `set` is our scheduling choice — they're independent. A
|
||||
Hard can be `core` if we deliberately schedule it (vs the usual `deferred`).
|
||||
@@ -0,0 +1,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.
|
||||
@@ -39,3 +39,25 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
SRS_ADMIN_KEY: ${{ secrets.SRS_ADMIN_KEY }}
|
||||
|
||||
# Project fields are a projection of the issue labels, so the same push
|
||||
# that adds a solution (and any issue filed since the last run) can leave
|
||||
# the board's Set/Difficulty empty. Reconciling here keeps the one
|
||||
# "solution landed" entry point responsible for the whole fan-out; the
|
||||
# script is idempotent, so a run with nothing to do writes nothing.
|
||||
#
|
||||
# Needs the `project` scope, which GITHUB_TOKEN does not have — hence
|
||||
# PROJECT_PAT. Unset = skip, matching SRS_ADMIN_KEY above. Best-effort by
|
||||
# design: D1 and the issues are the truth, so a Project failure must not
|
||||
# fail a run that already closed issues and logged the solve.
|
||||
- name: Reconcile Project Set/Difficulty
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PROJECT_PAT }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "PROJECT_PAT unset — skipping the Project field reconcile"
|
||||
exit 0
|
||||
fi
|
||||
bun apps/cli/sync-project-fields.ts
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Spaced Repetition
|
||||
|
||||
# Opens (or refreshes) today's `Spaced Repetition — <Month D, YYYY>` issue from
|
||||
# whatever the SRS ladder has due (`POST /admin/due`, the same rows the 8 AM
|
||||
# digest mails — so the two cannot disagree). The script stores nothing and
|
||||
# schedules nothing: it asks D1 what is standing today, keyed by the ET date in
|
||||
# the title, so a re-run rewrites one body instead of stacking duplicates.
|
||||
# Sunday is the rest day and the ladder never books it, so nothing is written.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 10:00 UTC = 06:00 ET while the campaign runs (EDT through Nov 1), 05:00 ET
|
||||
# after. No DST guard and no second cron: the issue is keyed by ET calendar
|
||||
# date, not by the hour it was created, so the drift is cosmetic.
|
||||
- cron: "0 10 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the issue body without creating or updating anything
|
||||
type: boolean
|
||||
default: false
|
||||
date:
|
||||
description: ET date to treat as today (YYYY-MM-DD) — backfill a missed day
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: spaced-repetition
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
drill:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
# No install step: the script only uses Bun builtins + fetch.
|
||||
#
|
||||
# SRS_ADMIN_KEY reads today's due list from the Worker. Unset is fatal
|
||||
# here, unlike in close-solved: without D1 there is no schedule to render,
|
||||
# and inventing one from issue close dates is exactly the second source of
|
||||
# truth this replaced.
|
||||
- run: bun apps/cli/spaced-repetition.ts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
SRS_TODAY: ${{ inputs.date }}
|
||||
SRS_ADMIN_KEY: ${{ secrets.SRS_ADMIN_KEY }}
|
||||
@@ -1,6 +1,8 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
tmp/
|
||||
*.log
|
||||
.crush
|
||||
|
||||
# output
|
||||
out
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues own ~161 `problem` sub-issues (`set:core|optional|deferred`, `diff:*`); milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
|
||||
LeetCode interview-prep campaign (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
|
||||
|
||||
@@ -18,15 +18,22 @@ flowchart LR
|
||||
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
|
||||
Mail -->|GET /log| D1
|
||||
PI -->|/done webhook| D1
|
||||
D1 -->|best-effort mirror| Proj[GitHub Project fields]
|
||||
D1 -->|best-effort mirror: Target Date only| Proj[GitHub Project fields]
|
||||
GH -->|close-solved.yml sync-project-fields| Proj
|
||||
D1 -->|Sat midnight ET| Review[Review — Week N issue]
|
||||
PI -->|"spaced-repetition.yml (6 AM ET cron, closed +3d/+7d)"| SR[Spaced Repetition — Month D issue]
|
||||
D1 --> Charts[SVG charts + /api/stats]
|
||||
Charts -->|bun run stats| TUI[terminal dashboard]
|
||||
```
|
||||
|
||||
- **Reconcile, don't react.** `close-solved.ts`, `close-topics.ts`, and the Worker's catalog sync recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional. `close-solved` posts its *whole* implemented set to `/admin/solved`, not just the issues it closed this run.
|
||||
- **Solving has three doors, one write path.** A digest one-tap, a `/done` comment, and a solution landing in `work/` all end in `logAttempt()`. Closing an issue is *not* one of them: the catalog reconcile never reads issue state, so a `work/` push that skipped the email would otherwise leave `stage='new'` — invisible to the charts, the digest's solved ticks, and the drill/gate pools. Hence `/admin/solved` (`source='commit'`, first solve only, no index needed for idempotency: it refuses any problem past stage `new`). `close-topics` needs no such call — D1 stores no topic completion, only problem rows.
|
||||
- **Solving has three doors, one write path.** A digest one-tap, a `/done` comment, and a solution landing in `work/` all end in `logAttempt()`. Closing an issue is *not* one of them: the catalog reconcile never reads issue state, so a `work/` push that skipped the email would otherwise leave `stage='new'` — invisible to the charts, the digest's solved ticks, and the drill/gate pools. Hence `/admin/solved`, which takes `{lc, bucket}` pairs (`source='commit'`): the bucket names the rung the file settles — `work/1` settles `new`, `work/3` settles `+3`, `work/7` settles `+7` — so a pushed re-solve advances the ladder instead of vanishing, and re-posting the whole implemented set writes nothing because the rung named has already been left behind. That stage check is the entire idempotency mechanism; no index, no dedupe table, no state file. `close-topics` needs no such call — D1 stores no topic completion, only problem rows.
|
||||
- **The rest day is structural, not cosmetic.** Sunday is never booked: topics run Mon–Fri (`apps/api/data/schedule.json`), the gate is Saturday, and the ladder's windows are 3 and 7 precisely because those return a solve to a working day. Every scheduled date — ladder review, levelled overflow, deferred-Hard release, review-issue window — comes out of `workingDay()` in `apps/api/src/srs.ts`, which slides the one exception (a Thursday solve's `+3`) forward to Monday. Forward, never back: a review may slip later than its interval, never shorten it. Never mint a scheduled date with bare `addDays()`, and never "fix" a Sunday landing downstream — the digest's Sunday rest branch is a courtesy, not the mechanism. `apps/api/src/srs.test.ts` sweeps the campaign calendar to keep this honest.
|
||||
- **One schedule, one answer to "what is open today".** D1's ladder IS the schedule; `dueToday()` (`POST /admin/due`) is its read side and `DUE_WHERE` is the single SQL definition of "due", shared by the digest, the review issue and the docs queue chart. `spaced-repetition.yml` renders that answer into today's `Spaced Repetition — <Month D, YYYY>` issue — grouped by rung, `WINDOW_CAP` (3) required per rung with the overflow listed as optional, keyed by the ET date in the title so a re-run rewrites one body. It schedules nothing, stores nothing, and writes nothing on Sunday. Without `SRS_ADMIN_KEY` it FAILS rather than inventing a second schedule from issue close dates — that reconstruction was the drift, and it is gone. NEVER add a JSON state file (`.github/problems.json` and friends) or a second "when is this due" rule: D1 answers it, with one writer (`logAttempt`) and git-free history in `attempts`.
|
||||
- **The picker owns the local review loop; the buckets are its state.** `work/` holds three buckets (`1` = first solve, `3`/`7` = blind re-solves, named after the ladder rungs) and `bun run pick` has three sections — Tab cycles them. Pools are reconciled from the filesystem plus one `git log` (`apps/cli/work.ts`): a first solve dates from the ET date of the commit that added its `work/1` file — the same push that closes the problem issue and starts the +3/+7 windows — and a window stops being owed the moment its bucket holds a file. Uncapped, unlike the review issue's `WINDOW_CAP`: that issue is one day's assignment, the picker is everything still owed. An empty scaffold under `work/1` owes nothing (it is unsolved), which is why `isImplemented()` lives in `apps/cli/source.ts` and is shared with `close-solved.ts`. leetcode-cli reads its output directory from `~/.leetcode/workspaces/<active>/config.json` and nowhere else — no flag, no env var — so `pick.ts` MERGES `workDir` into that file around the child (never replays saved bytes: the CLI owns the file too) and leaves it pointed at `work/1`, then treats the scaffold's existence as the acceptance test, since leetcode-cli exits 0 even when an expired session made it write nothing. The picker itself writes no state: pushing the re-solve is what moves the ladder, via `close-solved` → `/admin/solved` with that file's bucket.
|
||||
- **The README charts are live, never committed.** They are Worker endpoints reading D1 per request (`Cache-Control: max-age=300`, honored by GitHub Camo), so a solved push moves them within ~5 minutes with no commit and no workflow of their own.
|
||||
- **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). Project fields (`Target Date`, `SRS Stage`, `First Attempt`) are a best-effort mirror (`apps/api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written.
|
||||
- **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). `Target Date` is the *only* Project field the Worker mirrors (`apps/api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written. SRS stage and first-attempt result live **only** in D1 — do not re-add them as Project fields. The Project's `Set`/`Difficulty` single-selects are a projection of the issue labels, reconciled by `bun run sync-project-fields` — a best-effort final step of `close-solved.yml`, never the Worker.
|
||||
- **The plan names days; the samplers fill the gaps.** `apps/api/data/schedule.json` maps a date to a `ScheduledDay` (`apps/api/src/srs.ts`): `topics` (one or more topic issues — a doubled day lists two, and the digest renders each with its core list then the optional "if time" list), `problems` (a set day: fixed numbers, rendered like a topic), `gate` (Saturday's blind set, verbatim — `createReviewIssue` refuses to create the issue if any number is not a catalog row, so gate problems must exist as `set:optional` sub-issues of their topic first), or `sweep` (every problem whose last attempt failed, taps to re-log, no drills). Every number a `gate` or `problems` entry names is in `RESERVED`, and the drill/gate samplers never draw it — that is what keeps a planned gate problem unseen until its Saturday. Dates absent from the plan still get the ladder's reviews and drills; a Saturday without a `gate` entry falls back to sampling this week's optional pools.
|
||||
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
|
||||
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
|
||||
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
||||
@@ -35,30 +42,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 (issues + `/admin/solved`, needs `SRS_ADMIN_KEY`), close-topics, sync-d1 (issue edits → `/admin/reconcile`) |
|
||||
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
|
||||
| `.github/workflows/` | deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), 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; hides problems
|
||||
# already under work/, caches index+descriptions in
|
||||
bun run pick # scaffold a solution via leetcode-cli (fuzzy picker; Tab cycles the
|
||||
# sections: new problems → work/1, then the owed 3-day and 7-day
|
||||
# re-solves → work/3, work/7. Each section hides what its own bucket
|
||||
# already holds; index+descriptions cached in
|
||||
# ~/.local/share/leetcode/data.db — see apps/cli/db.ts)
|
||||
bun run test # run ONE solution against LeetCode's judge (not a test suite)
|
||||
bun run submit # submit ONE solution to LeetCode (same picker as test)
|
||||
bun run sync # work/ → apps/docs/content/ pages
|
||||
bun run stats # terminal dashboard over GET /api/stats — four panes (Overview,
|
||||
# Ladder, Concepts, Activity); Tab/1-4 switches, ↑↓ scrolls,
|
||||
# r refetches, q quits. Outside a TTY it prints every pane once.
|
||||
# SRS_API points it at a local Worker like close-solved does.
|
||||
bun run test # run ONE solution against LeetCode's judge (not a test suite;
|
||||
# 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
|
||||
# close-solved also pushes the solved set to the Worker; point it at a local
|
||||
# one and nothing production is touched:
|
||||
SRS_API=http://localhost:8787 SRS_ADMIN_KEY=$LINK_KEY bun run close-solved -- --dry-run
|
||||
bun run api:dev # wrangler dev on :8787 (local D1); api:test = DST guard tests
|
||||
# Project Set/Difficulty from the issue labels (needs a `project`-scoped token;
|
||||
# runs as the last step of close-solved.yml in CI):
|
||||
GH_TOKEN=$PROJECT_PAT bun run sync-project-fields -- --dry-run
|
||||
# today's review issue from the +3d/+7d closes (--date backfills a missed day;
|
||||
# a Sunday date writes nothing — the rest day has no assignment):
|
||||
bun run spaced-repetition -- --dry-run --date=2026-08-31
|
||||
bun run api:dev # wrangler dev on :8787 (local D1)
|
||||
bun run api:test # DST guards + the rest-day calendar sweep
|
||||
bun run api:deploy # deploy the Worker by hand (CI also deploys on main pushes)
|
||||
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
||||
"localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing
|
||||
@@ -72,24 +94,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), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future date, ≤ 3 per day, oldest first, instead of letting the due pile grow.
|
||||
- `apps/api/src/srs.ts` — SRS domain: the ladder (`new → +3 → +7 → retired`; fail resets to `+3`; first-ever log enters at `+3`), `WINDOWS`/`STAGES`/`TEMPERATURES`/`DIFFICULTIES`/`SETS` (the one vocabulary shared by the charts, the `work/<n>` buckets, the review issues and the stats dashboard), ET date math, `workingDay()` (no scheduled date is ever a Sunday), `temperatureOf()` (days since a concept was last exercised → `hot`/`fresh`/`fading`/`cold`, bands derived from `WINDOWS` so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
|
||||
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`.
|
||||
- `apps/api/src/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
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
<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>
|
||||
New topic Mon–Fri, gate on Saturday, Sunday off — every solve comes back at <strong>+3</strong> and <strong>+7</strong> days,
|
||||
the two windows that keep the rest day clear (a Thursday solve's +3 slides to Monday).<br>
|
||||
Charts are live from the SRS Worker, never committed: pushing a solution or tapping the daily digest moves them within ~5 min.</sub>
|
||||
</p>
|
||||
|
||||
@@ -18,7 +20,7 @@
|
||||
<td colspan="2" align="center"><img alt="phase progress" width="100%" src="https://srs-api.prdlk.workers.dev/chart/progress.svg" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="53%"><img alt="SRS ladder" width="100%" src="https://srs-api.prdlk.workers.dev/chart/ladder.svg" /></td>
|
||||
<td align="center" width="53%"><img alt="SRS ladder — new / +3 / +7 / retired" width="100%" src="https://srs-api.prdlk.workers.dev/chart/ladder.svg" /></td>
|
||||
<td align="center" width="47%"><img alt="attempt heatmap" width="100%" src="https://srs-api.prdlk.workers.dev/chart/heatmap.svg" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -210,7 +212,7 @@
|
||||
<tr><td align="center"><code>B</code> ⭐</td><td align="center">295</td><td><a href="https://leetcode.com/problems/find-median-from-data-stream/">Find Median from Data Stream</a></td></tr>
|
||||
<tr><td align="center"><code>B</code> ⭐</td><td align="center">502</td><td><a href="https://leetcode.com/problems/ipo/">IPO</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="3"><a href="https://github.com/prdlk/leetcode/issues/17"><strong>15. Trie</strong></a><br><sub>Wk 4 · Wed Sep 9</sub><br><sub>builds on 11</sub></td>
|
||||
<td rowspan="3"><a href="https://github.com/prdlk/leetcode/issues/17"><strong>15. Trie</strong></a><br><sub>Wk 6 · Mon Sep 21</sub><br><sub>builds on 11</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">208</td><td><a href="https://leetcode.com/problems/implement-trie-prefix-tree/">Implement Trie</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">211</td><td><a href="https://leetcode.com/problems/design-add-and-search-words-data-structure/">Add & Search Words</a></td></tr>
|
||||
@@ -232,7 +234,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="7"><a href="https://github.com/prdlk/leetcode/issues/18"><strong>16. Graph DFS</strong></a><br><sub>Wk 4 · Thu Sep 10</sub><br><sub>builds on 11 + visited set</sub></td>
|
||||
<td rowspan="7"><a href="https://github.com/prdlk/leetcode/issues/18"><strong>16. Graph DFS</strong></a><br><sub>Wk 6 · Mon Sep 21</sub><br><sub>builds on 11 + visited set</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">200</td><td><a href="https://leetcode.com/problems/number-of-islands/">Number of Islands</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">695</td><td><a href="https://leetcode.com/problems/max-area-of-island/">Max Area of Island</a></td></tr>
|
||||
@@ -242,7 +244,7 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">399</td><td><a href="https://leetcode.com/problems/evaluate-division/">Evaluate Division</a></td></tr>
|
||||
<tr><td align="center"><code>B</code></td><td align="center">261</td><td><a href="https://leetcode.com/problems/graph-valid-tree/">Graph Valid Tree</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/19"><strong>17. Graph BFS</strong></a><br><sub>Wk 4 · Fri Sep 11</sub><br><sub>builds on 12 + visited set — shortest path unweighted</sub></td>
|
||||
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/19"><strong>17. Graph BFS</strong></a><br><sub>Wk 6 · Tue Sep 22</sub><br><sub>builds on 12 + visited set — shortest path unweighted</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">994</td><td><a href="https://leetcode.com/problems/rotting-oranges/">Rotting Oranges</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">127</td><td><a href="https://leetcode.com/problems/word-ladder/">Word Ladder</a></td></tr>
|
||||
@@ -250,14 +252,14 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">433</td><td><a href="https://leetcode.com/problems/minimum-genetic-mutation/">Minimum Genetic Mutation</a></td></tr>
|
||||
<tr><td align="center"><code>B</code></td><td align="center">286</td><td><a href="https://leetcode.com/problems/walls-and-gates/">Walls and Gates</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="4"><a href="https://github.com/prdlk/leetcode/issues/20"><strong>18. Topological Sort</strong></a><br><sub>Wk 5 · Mon Sep 14</sub><br><sub>builds on 16</sub></td>
|
||||
<td rowspan="4"><a href="https://github.com/prdlk/leetcode/issues/20"><strong>18. Topological Sort</strong></a><br><sub>Wk 6 · Tue Sep 22</sub><br><sub>builds on 16</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">207</td><td><a href="https://leetcode.com/problems/course-schedule/">Course Schedule</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">210</td><td><a href="https://leetcode.com/problems/course-schedule-ii/">Course Schedule II</a></td></tr>
|
||||
<tr><td align="center"><code>B</code> ⭐</td><td align="center">269</td><td><a href="https://leetcode.com/problems/alien-dictionary/">Alien Dictionary</a></td></tr>
|
||||
<tr><td align="center"><code>B</code></td><td align="center">1462</td><td><a href="https://leetcode.com/problems/course-schedule-iv/">Course Schedule IV</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="4"><a href="https://github.com/prdlk/leetcode/issues/21"><strong>19. Union-Find</strong></a><br><sub>Wk 5 · Tue Sep 15</sub><br><sub>builds on 16's problem class</sub></td>
|
||||
<td rowspan="4"><a href="https://github.com/prdlk/leetcode/issues/21"><strong>19. Union-Find</strong></a><br><sub>Wk 6 · Wed Sep 23</sub><br><sub>builds on 16's problem class</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">684</td><td><a href="https://leetcode.com/problems/redundant-connection/">Redundant Connection</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">323</td><td><a href="https://leetcode.com/problems/number-of-connected-components-in-an-undirected-graph/">Number of Connected Components</a></td></tr>
|
||||
@@ -280,7 +282,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="8"><a href="https://github.com/prdlk/leetcode/issues/22"><strong>20. Backtracking</strong></a><br><sub>Wk 5 · Wed Sep 16</sub><br><sub>builds on 16 — then unlock LC 212 from topic 15</sub></td>
|
||||
<td rowspan="8"><a href="https://github.com/prdlk/leetcode/issues/22"><strong>20. Backtracking</strong></a><br><sub>Wk 6 · Thu Sep 24</sub><br><sub>builds on 16 — then unlock LC 212 from topic 15</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">78</td><td><a href="https://leetcode.com/problems/subsets/">Subsets</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">39</td><td><a href="https://leetcode.com/problems/combination-sum/">Combination Sum</a></td></tr>
|
||||
@@ -291,7 +293,7 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">22</td><td><a href="https://leetcode.com/problems/generate-parentheses/">Generate Parentheses</a></td></tr>
|
||||
<tr><td align="center"><code>B</code> ⭐</td><td align="center">52</td><td><a href="https://leetcode.com/problems/n-queens-ii/">N-Queens II</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="9"><a href="https://github.com/prdlk/leetcode/issues/23"><strong>21. 1-D Dynamic Programming</strong></a><br><sub>Wk 5 · Thu Sep 17</sub><br><sub>builds on 20</sub></td>
|
||||
<td rowspan="9"><a href="https://github.com/prdlk/leetcode/issues/23"><strong>21. 1-D Dynamic Programming</strong></a><br><sub>Wk 6 · Fri Sep 25</sub><br><sub>builds on 20</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">70</td><td><a href="https://leetcode.com/problems/climbing-stairs/">Climbing Stairs</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">746</td><td><a href="https://leetcode.com/problems/min-cost-climbing-stairs/">Min Cost Climbing Stairs</a></td></tr>
|
||||
@@ -303,7 +305,7 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">91</td><td><a href="https://leetcode.com/problems/decode-ways/">Decode Ways</a></td></tr>
|
||||
<tr><td align="center"><code>B</code></td><td align="center">647</td><td><a href="https://leetcode.com/problems/palindromic-substrings/">Palindromic Substrings</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="8"><a href="https://github.com/prdlk/leetcode/issues/24"><strong>22. Multi-D / Grid DP</strong></a><br><sub>Wk 5 · Fri Sep 18</sub><br><sub>builds on 21</sub></td>
|
||||
<td rowspan="8"><a href="https://github.com/prdlk/leetcode/issues/24"><strong>22. Multi-D / Grid DP</strong></a><br><sub>Wk 7 · Mon Sep 28</sub><br><sub>builds on 21</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">62</td><td><a href="https://leetcode.com/problems/unique-paths/">Unique Paths</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">64</td><td><a href="https://leetcode.com/problems/minimum-path-sum/">Minimum Path Sum</a></td></tr>
|
||||
@@ -314,7 +316,7 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">221</td><td><a href="https://leetcode.com/problems/maximal-square/">Maximal Square</a></td></tr>
|
||||
<tr><td align="center"><code>B</code> ⭐</td><td align="center">97</td><td><a href="https://leetcode.com/problems/interleaving-string/">Interleaving String</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="7"><a href="https://github.com/prdlk/leetcode/issues/25"><strong>23. Greedy</strong></a><br><sub>Wk 6 · Mon Sep 21</sub><br><sub>builds on 21 — includes Kadane's</sub></td>
|
||||
<td rowspan="7"><a href="https://github.com/prdlk/leetcode/issues/25"><strong>23. Greedy</strong></a><br><sub>Wk 7 · Tue Sep 29</sub><br><sub>builds on 21 — includes Kadane's</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">53</td><td><a href="https://leetcode.com/problems/maximum-subarray/">Maximum Subarray</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">55</td><td><a href="https://leetcode.com/problems/jump-game/">Jump Game</a></td></tr>
|
||||
@@ -324,7 +326,7 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">918</td><td><a href="https://leetcode.com/problems/maximum-sum-circular-subarray/">Maximum Sum Circular Subarray</a></td></tr>
|
||||
<tr><td align="center"><code>B</code></td><td align="center">763</td><td><a href="https://leetcode.com/problems/partition-labels/">Partition Labels</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="7"><a href="https://github.com/prdlk/leetcode/issues/26"><strong>24. Intervals</strong></a><br><sub>Wk 6 · Tue Sep 22</sub><br><sub>builds on 23</sub></td>
|
||||
<td rowspan="7"><a href="https://github.com/prdlk/leetcode/issues/26"><strong>24. Intervals</strong></a><br><sub>Wk 7 · Wed Sep 30</sub><br><sub>builds on 23</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">57</td><td><a href="https://leetcode.com/problems/insert-interval/">Insert Interval</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">56</td><td><a href="https://leetcode.com/problems/merge-intervals/">Merge Intervals</a></td></tr>
|
||||
|
||||
+59
-25
@@ -14,23 +14,47 @@ Live at `https://srs-api.prdlk.workers.dev`.
|
||||
labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via
|
||||
`/admin/reconcile`); it recomputes from scratch, never invents rows, and
|
||||
never overwrites SRS-owned columns (`stage`, `next_review`).
|
||||
- **The repo owns the schedule** — `data/schedule.json`, human-edited,
|
||||
bundled at deploy. Git history is its audit log.
|
||||
- The GitHub Project mirror (`Target Date`, `SRS Stage`, `First Attempt`)
|
||||
is best-effort: failures log warnings, never block a D1 write. Topic rows
|
||||
are never written.
|
||||
- **The repo owns the plan** — `data/schedule.json`, human-edited,
|
||||
bundled at deploy. Git history is its audit log. Each date is a
|
||||
`ScheduledDay`: `topics` (one or more topic issues), `problems` (a set day,
|
||||
fixed numbers), `gate` (Saturday's blind set, verbatim), or `sweep` (redo
|
||||
every miss, nothing new). Numbers a `gate` or `problems` entry names are
|
||||
`RESERVED`: the drill and gate samplers never draw them.
|
||||
- The GitHub Project mirror writes `Target Date` and nothing else: it is
|
||||
best-effort, so failures log warnings and never block a D1 write, and topic
|
||||
rows are never written. SRS stage and first-attempt result stay in D1 only —
|
||||
the charts, digest and `/api/stats` read them from there, so mirroring them
|
||||
into Project single-selects bought only drift. Do not re-add them.
|
||||
`Set`/`Difficulty` are a projection of the issue labels, reconciled by
|
||||
`bun run sync-project-fields` at the repo root, not by this Worker.
|
||||
|
||||
## The ladder
|
||||
|
||||
`new → +2 → +5 → +10 → retired`; pass advances, fail resets to `+2`.
|
||||
A problem's first-ever log enters at `+2` regardless of result. Stage names
|
||||
the NEXT review's interval. All dates are ET calendar strings anchored at
|
||||
noon UTC (`src/srs.ts`) — never raw `Date` math.
|
||||
`new → +3 → +7 → retired`; pass advances, fail resets to `+3`. A problem's
|
||||
first-ever log enters at `+3` regardless of result. Stage names the NEXT
|
||||
review's interval, and the two rungs are the same numbers as the `work/3` and
|
||||
`work/7` buckets `bun run pick` scaffolds re-solves into.
|
||||
|
||||
**Sunday is never booked.** Topics run Mon–Fri, the gate is Saturday, and 3/7
|
||||
are chosen so a solve returns on a working day: only a Thursday solve's `+3`
|
||||
would land on the rest day, and `workingDay()` in `src/srs.ts` slides it to
|
||||
Monday. Every scheduled date in the Worker — ladder reviews, levelled
|
||||
overflow, deferred-Hard release dates — is minted by that one function, so the
|
||||
rest day cannot be booked and then swallowed by the digest's Sunday branch.
|
||||
Reviews may slip later, never earlier: pulling one back to Saturday would
|
||||
shorten the interval it exists to test. `src/srs.test.ts` sweeps the whole
|
||||
campaign calendar to prove it.
|
||||
|
||||
All dates are ET calendar strings anchored at noon UTC (`src/srs.ts`) — never
|
||||
raw `Date` math.
|
||||
|
||||
Blind drills draw only from topics **already learned**: scheduled in an
|
||||
earlier week (the current week's optional pool is reserved for Saturday's
|
||||
gate) and with at least one core problem on the ladder — a skipped learning
|
||||
day never feeds drills just because its calendar week lapsed.
|
||||
day never feeds drills just because its calendar week lapsed. A Saturday
|
||||
whose plan fixes the `gate` set uses that list as-is; every number must be a
|
||||
catalog row (a `set:optional` sub-issue of its topic), or the review issue is
|
||||
not created — a `/done` on an uncatalogued number could never be logged.
|
||||
|
||||
## Routes
|
||||
|
||||
@@ -40,7 +64,8 @@ day never feeds drills just because its calendar week lapsed.
|
||||
| `POST /webhook/github` | HMAC (`WEBHOOK_SECRET`) | `/done <n> pass\|fail` comments (owner only, any issue); `review`-issue close → gate scoring |
|
||||
| `GET /chart/{progress,ladder,heatmap}.svg`, `GET /badge/gate.svg` | public | hand-rolled SVGs, `max-age=300` (GitHub Camo's freshness floor) |
|
||||
| `GET /api/stats` | public, CORS-pinned to the docs origin | one JSON document for `/progress` |
|
||||
| `POST /admin/solved` | `Authorization: Bearer <LINK_KEY>` | `{"lc":[…]}` — solutions committed under `work/`; logs each *first* solve (`source='commit'`), skips anything past stage `new`, takes `?dry=1&date=` |
|
||||
| `POST /admin/solved` | `Authorization: Bearer <LINK_KEY>` | `{"solved":[{"lc":1,"bucket":1},…]}` — solutions committed under `work/`; the bucket names the rung it settles (1 → `new`, 3 → `+3`, 7 → `+7`), so a first solve enters the ladder and a pushed re-solve advances it. Writes only when the problem stands on that rung, which is what makes re-posting the whole set a no-op. Takes `?dry=1&date=` |
|
||||
| `POST /admin/due` | `Authorization: Bearer <LINK_KEY>` | read-only: what the ladder has due on `?date=` (default today), oldest first, with the rung/bucket, how late it is, and when it was last seen. The one answer behind the daily `Spaced Repetition` issue |
|
||||
| `POST /admin/{digest,review,reconcile}` | `Authorization: Bearer <LINK_KEY>` | manual triggers; `digest` takes `?dry=1&force=1&date=` |
|
||||
|
||||
## Crons (DST-proof)
|
||||
@@ -60,15 +85,23 @@ routes). Bindings in `wrangler.jsonc`: `DB` (D1 `srs`), `EMAIL`
|
||||
(`send_email`, restricted to the verified destination). Sender domain
|
||||
`prdlk.com` is onboarded to Email Sending.
|
||||
|
||||
Two Actions workflows push into D1 with the `SRS_ADMIN_KEY` repo secret:
|
||||
`sync-d1.yml` sends curriculum issue edits to `POST /admin/reconcile`
|
||||
(the morning cron is the backstop), and `close-solved.yml` sends the whole
|
||||
implemented `work/` set to `POST /admin/solved` on every push to `main`.
|
||||
That second call is what keeps a committed solution from being invisible
|
||||
here: closing its issue is not a state change the Worker can see — the
|
||||
catalog reconcile ignores issue state, and only `logAttempt()` moves the
|
||||
ladder. `close-solved` still owns the issue close itself (it knows the files
|
||||
and the commit), so this path only writes D1 and mirrors Project fields.
|
||||
Three Actions workflows talk to this Worker with the `SRS_ADMIN_KEY` repo
|
||||
secret:
|
||||
|
||||
- `sync-d1.yml` sends curriculum issue edits to `POST /admin/reconcile` (the
|
||||
morning cron is the backstop).
|
||||
- `close-solved.yml` sends the whole implemented `work/` set — each entry
|
||||
tagged with its bucket — to `POST /admin/solved` on every push to `main`.
|
||||
That call is what keeps a committed solution from being invisible here:
|
||||
closing an issue is not a state change the Worker can see (the catalog
|
||||
reconcile ignores issue state, and only `logAttempt()` moves the ladder), and
|
||||
the bucket is what lets a pushed re-solve advance `+3 → +7` instead of
|
||||
being read as a stale first solve. `close-solved` still owns the issue close
|
||||
itself (it knows the files and the commit), so this path only writes D1 and
|
||||
mirrors Project fields.
|
||||
- `spaced-repetition.yml` reads `POST /admin/due` at 06:00 ET and renders
|
||||
today's `Spaced Repetition` issue. Read-only, and the only schedule involved
|
||||
is this Worker's ladder.
|
||||
|
||||
## Local dev
|
||||
|
||||
@@ -79,13 +112,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
|
||||
|
||||
|
||||
+26
-24
@@ -1,26 +1,28 @@
|
||||
{
|
||||
"2026-08-17": 3,
|
||||
"2026-08-18": 4,
|
||||
"2026-08-24": 5,
|
||||
"2026-08-25": 6,
|
||||
"2026-08-26": 7,
|
||||
"2026-08-27": 8,
|
||||
"2026-08-28": 9,
|
||||
"2026-08-31": 10,
|
||||
"2026-09-01": 11,
|
||||
"2026-09-02": 12,
|
||||
"2026-09-03": 13,
|
||||
"2026-09-04": 14,
|
||||
"2026-09-07": 15,
|
||||
"2026-09-08": 16,
|
||||
"2026-09-09": 17,
|
||||
"2026-09-10": 18,
|
||||
"2026-09-11": 19,
|
||||
"2026-09-14": 20,
|
||||
"2026-09-15": 21,
|
||||
"2026-09-16": 22,
|
||||
"2026-09-17": 23,
|
||||
"2026-09-18": 24,
|
||||
"2026-09-21": 25,
|
||||
"2026-09-22": 26
|
||||
"2026-08-17": { "topics": [3] },
|
||||
"2026-08-18": { "topics": [4] },
|
||||
"2026-08-24": { "topics": [5] },
|
||||
"2026-08-25": { "topics": [6] },
|
||||
"2026-08-26": { "topics": [7] },
|
||||
"2026-08-27": { "topics": [8] },
|
||||
"2026-08-28": { "topics": [9] },
|
||||
"2026-08-31": { "topics": [10] },
|
||||
"2026-09-01": { "topics": [11] },
|
||||
"2026-09-02": { "topics": [12] },
|
||||
"2026-09-03": { "topics": [13] },
|
||||
"2026-09-04": { "topics": [14] },
|
||||
"2026-09-07": { "topics": [15] },
|
||||
"2026-09-08": { "topics": [16] },
|
||||
"2026-09-21": { "topics": [17, 18] },
|
||||
"2026-09-22": { "topics": [19, 20] },
|
||||
"2026-09-23": { "topics": [21] },
|
||||
"2026-09-24": { "topics": [22] },
|
||||
"2026-09-25": { "topics": [23] },
|
||||
"2026-09-26": { "gate": [648, 1091, 802, 547, 90] },
|
||||
"2026-09-28": { "topics": [24] },
|
||||
"2026-09-29": { "topics": [25] },
|
||||
"2026-09-30": { "topics": [26] },
|
||||
"2026-10-01": { "problems": [399, 261, 909, 77, 52] },
|
||||
"2026-10-02": { "sweep": true },
|
||||
"2026-10-03": { "gate": [347, 1254, 1971, 131, 494, 986] }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Ladder rebuild: +2/+5/+10 → +3/+7, and no scheduled date on a Sunday.
|
||||
--
|
||||
-- The old three rungs scattered reviews across every weekday, so roughly one
|
||||
-- review in seven came due on the rest day: the digest hid it (Sunday builds
|
||||
-- an empty body) and Monday inherited the pile. The campaign's windows are 3
|
||||
-- and 7 days — the same numbers as the work/3 and work/7 buckets and the
|
||||
-- Spaced Repetition issue windows — and srs.ts now mints every date through
|
||||
-- workingDay(), which never returns a Sunday.
|
||||
--
|
||||
-- Stage remap keeps earned progress: a problem at +5 has passed one review, so
|
||||
-- it owes the 7-day one; a problem at +10 has passed two and is done. Existing
|
||||
-- next_review dates keep their day (recomputing them would move work the
|
||||
-- reader has already been told about) — only Sunday landings shift, forward to
|
||||
-- Monday, because a review may come later than its interval but never sooner.
|
||||
--
|
||||
-- problems.stage is now: new | +3 | +7 | retired
|
||||
|
||||
UPDATE problems SET stage = '+3' WHERE stage = '+2';
|
||||
UPDATE problems SET stage = '+7' WHERE stage = '+5';
|
||||
UPDATE problems SET stage = 'retired', next_review = NULL WHERE stage = '+10';
|
||||
|
||||
UPDATE problems
|
||||
SET next_review = date(next_review, '+1 day')
|
||||
WHERE next_review IS NOT NULL AND strftime('%w', next_review) = '0';
|
||||
|
||||
-- defer_until is the deferred Hards' release date; catalog.ts spreads them two
|
||||
-- per working day, so the same rule applies. next_review tracks it for a row
|
||||
-- that is still `new`, and the shift above kept the two in step.
|
||||
UPDATE problems
|
||||
SET defer_until = date(defer_until, '+1 day')
|
||||
WHERE defer_until IS NOT NULL AND strftime('%w', defer_until) = '0';
|
||||
@@ -1,94 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* One-shot migration: .github/srs/srs.json → D1.
|
||||
*
|
||||
* Run AFTER the catalog reconcile has filled `problems` (the import only
|
||||
* overlays SRS-owned fields — stage, next_review, defer_until — and inserts
|
||||
* attempts/topic counters). Re-runnable without duplicates: import-sourced
|
||||
* attempts are wiped and re-inserted, everything else upserts.
|
||||
*
|
||||
* bun apps/api/scripts/import-srs.ts # local D1 (wrangler dev state)
|
||||
* bun apps/api/scripts/import-srs.ts --remote # production D1
|
||||
*
|
||||
* Prints row counts; verify them against srs.json before deleting anything.
|
||||
*/
|
||||
import { $ } from "bun";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..", "..");
|
||||
const API = join(ROOT, "api");
|
||||
const REMOTE = process.argv.includes("--remote");
|
||||
|
||||
interface Attempt {
|
||||
date: string;
|
||||
kind: string;
|
||||
result: string;
|
||||
}
|
||||
interface Problem {
|
||||
issue: number;
|
||||
topic: number;
|
||||
difficulty: string;
|
||||
set: string;
|
||||
solved_on?: string;
|
||||
stage: string;
|
||||
next_review?: string;
|
||||
defer_until?: string;
|
||||
history: Attempt[];
|
||||
}
|
||||
interface State {
|
||||
problems: Record<string, Problem>;
|
||||
topics: Record<string, { misses: number; boost: boolean }>;
|
||||
drill_pool_used: number[];
|
||||
}
|
||||
|
||||
const state: State = JSON.parse(
|
||||
await Bun.file(join(ROOT, ".github", "srs", "srs.json")).text(),
|
||||
);
|
||||
|
||||
const q = (v: string | null | undefined) => (v == null ? "NULL" : `'${v}'`);
|
||||
const lines: string[] = ["DELETE FROM attempts WHERE source = 'import';"];
|
||||
|
||||
let attempts = 0;
|
||||
for (const [lc, p] of Object.entries(state.problems)) {
|
||||
lines.push(
|
||||
`UPDATE problems SET stage = ${q(p.stage)}, next_review = ${q(p.next_review ?? null)}, ` +
|
||||
`defer_until = ${q(p.defer_until ?? null)} WHERE lc_number = ${Number(lc)};`,
|
||||
);
|
||||
for (const a of p.history) {
|
||||
lines.push(
|
||||
`INSERT INTO attempts (lc_number, date, kind, result, source) ` +
|
||||
`VALUES (${Number(lc)}, ${q(a.date)}, ${q(a.kind)}, ${q(a.result)}, 'import');`,
|
||||
);
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
for (const [topic, t] of Object.entries(state.topics)) {
|
||||
lines.push(
|
||||
`UPDATE topics SET misses = ${t.misses}, boost = ${t.boost ? 1 : 0} WHERE issue = ${Number(topic)};`,
|
||||
);
|
||||
}
|
||||
for (const lc of state.drill_pool_used) {
|
||||
lines.push(`INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (${lc});`);
|
||||
}
|
||||
|
||||
const sqlPath = join(API, "migrations", ".import.sql");
|
||||
await Bun.write(sqlPath, lines.join("\n") + "\n");
|
||||
const flag = REMOTE ? "--remote" : "--local";
|
||||
await $`bunx wrangler d1 execute srs ${flag} --file ${sqlPath}`.cwd(API);
|
||||
await $`rm ${sqlPath}`;
|
||||
|
||||
const counts =
|
||||
await $`bunx wrangler d1 execute srs ${flag} --json --command ${"SELECT (SELECT COUNT(*) FROM problems) AS problems, (SELECT COUNT(*) FROM problems WHERE stage != 'new') AS laddered, (SELECT COUNT(*) FROM problems WHERE defer_until IS NOT NULL) AS deferred, (SELECT COUNT(*) FROM attempts WHERE source='import') AS imported_attempts, (SELECT COUNT(*) FROM topics) AS topics, (SELECT COUNT(*) FROM drill_pool_used) AS drills_used"}`
|
||||
.cwd(API)
|
||||
.json();
|
||||
|
||||
const expected = {
|
||||
json_problems: Object.keys(state.problems).length,
|
||||
json_attempts: attempts,
|
||||
json_laddered: Object.values(state.problems).filter((p) => p.stage !== "new").length,
|
||||
json_deferred: Object.values(state.problems).filter((p) => p.defer_until).length,
|
||||
json_topics: Object.keys(state.topics).length,
|
||||
json_drills_used: state.drill_pool_used.length,
|
||||
};
|
||||
console.log("expected from srs.json:", JSON.stringify(expected));
|
||||
console.log("in D1:", JSON.stringify(counts[0]?.results?.[0] ?? counts));
|
||||
+37
-9
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
+70
-24
@@ -5,14 +5,16 @@
|
||||
* the HTML and the plain-text alternative from that single tree (React Email
|
||||
* `render`), so the two can never drift.
|
||||
*
|
||||
* Retrieval rules: review and drill rows carry number + difficulty only —
|
||||
* never the topic, never a solution link. `DigestRow` has no field for
|
||||
* either, so the rule holds by construction. The learning day's core list is
|
||||
* the only labeled section. At most REVIEW_CAP (3) reviews per day; drills
|
||||
* fill toward DAILY_CAP (6) only when the due queue is light. Overflow is
|
||||
* levelled, not stacked: sendDigest gives every review past the cap a
|
||||
* concrete future date, ≤ REVIEW_CAP per day, oldest first. Sunday is a
|
||||
* two-line rest note.
|
||||
* Retrieval rules: review, drill and sweep rows carry number + difficulty
|
||||
* only — never the topic, never a solution link. `DigestRow` has no field for
|
||||
* either, so the rule holds by construction. The plan's own sections — a
|
||||
* learning day's topics (core, then the optional "if time" list) and a set
|
||||
* day's problems — are the only labeled, linked ones. At most REVIEW_CAP (3)
|
||||
* reviews per day; drills fill toward DAILY_CAP (6) only when the due queue
|
||||
* is light, and never on a sweep day, which assigns every open miss and
|
||||
* nothing new. Overflow is levelled, not stacked: sendDigest gives every
|
||||
* review past the cap a concrete future date, ≤ REVIEW_CAP per day, oldest
|
||||
* first. Sunday is a two-line rest note.
|
||||
*
|
||||
* Idempotency: email_log keys sends by ET date — a same-day re-send is a
|
||||
* no-op unless forced; the body itself is deterministic (drill picks are
|
||||
@@ -22,20 +24,23 @@
|
||||
import {
|
||||
CAMPAIGN_DAYS,
|
||||
type ProblemRow,
|
||||
RESERVED,
|
||||
REVIEW_CAP,
|
||||
SCHEDULE,
|
||||
type ScheduledDay,
|
||||
addDays,
|
||||
campaignDay,
|
||||
campaignWeek,
|
||||
dueReviews,
|
||||
isoWeek,
|
||||
levelReviews,
|
||||
lookupProblems,
|
||||
pickDrills,
|
||||
prettyDate,
|
||||
streak,
|
||||
weekdayOf,
|
||||
} from "./srs.ts";
|
||||
import { type DigestData, type DigestRow, renderDigest } from "./email.tsx";
|
||||
import { type DigestData, type DigestRow, type TopicRow, renderDigest } from "./email.tsx";
|
||||
import { signLink } from "./links.ts";
|
||||
|
||||
/** Total daily ceiling: drills only fill what the whole due queue leaves. */
|
||||
@@ -63,23 +68,50 @@ async function tapRow(env: Env, p: ProblemRow, date: string, staged: boolean): P
|
||||
};
|
||||
}
|
||||
|
||||
/** A plan-named problem: linked to its issue, ticked once it has entered the ladder. */
|
||||
function topicRow(env: Env, p: ProblemRow): TopicRow {
|
||||
return {
|
||||
lc: p.lc_number,
|
||||
difficulty: p.difficulty,
|
||||
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
|
||||
solved: p.stage !== "new",
|
||||
};
|
||||
}
|
||||
|
||||
/** Problems whose most recent attempt failed — the miss log a sweep day works through. */
|
||||
async function openMisses(db: D1Database): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT p.* FROM problems p
|
||||
JOIN attempts a ON a.lc_number = p.lc_number
|
||||
WHERE a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number)
|
||||
AND a.result = 'fail' AND p.stage != 'retired'
|
||||
ORDER BY a.date, p.lc_number`,
|
||||
)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Everything the email needs, read straight out of D1 and the schedule. */
|
||||
export async function collectDigest(env: Env, date: string): Promise<DigestData> {
|
||||
const db = env.DB;
|
||||
const rest = weekdayOf(date) === 0;
|
||||
const plan: ScheduledDay = rest ? {} : (SCHEDULE[date] ?? {});
|
||||
|
||||
// Reviews surface at most REVIEW_CAP; drills fill toward DAILY_CAP only
|
||||
// when the WHOLE due queue is light — a heavy backlog must never trade
|
||||
// review slots for brand-new drill problems.
|
||||
// review slots for brand-new drill problems — and never on a sweep day.
|
||||
const due = rest ? [] : await dueReviews(db, date);
|
||||
const capped = due.slice(0, REVIEW_CAP);
|
||||
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
|
||||
const drills =
|
||||
rest || plan.sweep ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
|
||||
|
||||
const data: DigestData = {
|
||||
day: prettyDate(date),
|
||||
progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`,
|
||||
streak: await streak(db, date),
|
||||
rest,
|
||||
topics: [],
|
||||
reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))),
|
||||
carried: due.length - capped.length,
|
||||
drills: await Promise.all(drills.map((p) => tapRow(env, p, date, false))),
|
||||
@@ -88,28 +120,42 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
|
||||
progressUrl: `${env.DOCS_URL}/progress`,
|
||||
};
|
||||
|
||||
// New topic — the only section allowed to name problems and link them.
|
||||
const topicIssue = rest ? undefined : SCHEDULE[date];
|
||||
if (topicIssue !== undefined) {
|
||||
// New topics — the plan's sections, the only ones allowed to name problems
|
||||
// and link them. Core first; the optional set is "if time", minus anything
|
||||
// the plan has reserved for a gate or a set day.
|
||||
for (const topicIssue of plan.topics ?? []) {
|
||||
const topic = await db
|
||||
.prepare("SELECT name FROM topics WHERE issue = ?")
|
||||
.bind(topicIssue)
|
||||
.first<{ name: string }>();
|
||||
const { results: core } = await db
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
"SELECT * FROM problems WHERE topic_issue = ? AND set_label = 'core' ORDER BY lc_number",
|
||||
"SELECT * FROM problems WHERE topic_issue = ? AND set_label IN ('core', 'optional') ORDER BY lc_number",
|
||||
)
|
||||
.bind(topicIssue)
|
||||
.all<ProblemRow>();
|
||||
data.topic = {
|
||||
data.topics.push({
|
||||
name: topic?.name ?? `#${topicIssue}`,
|
||||
core: core.map((p) => ({
|
||||
lc: p.lc_number,
|
||||
difficulty: p.difficulty,
|
||||
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
|
||||
solved: p.stage !== "new",
|
||||
})),
|
||||
};
|
||||
core: results.filter((p) => p.set_label === "core").map((p) => topicRow(env, p)),
|
||||
ifTime: results
|
||||
.filter((p) => p.set_label === "optional" && !RESERVED.has(p.lc_number))
|
||||
.map((p) => topicRow(env, p)),
|
||||
});
|
||||
}
|
||||
|
||||
// A set day assigns problems by number rather than by topic.
|
||||
if (plan.problems) {
|
||||
const { rows, missing } = await lookupProblems(db, plan.problems);
|
||||
if (missing.length) console.warn(`digest ${date}: planned problems not in the catalog: ${missing.join(", ")}`);
|
||||
data.assigned = rows.map((p) => topicRow(env, p));
|
||||
}
|
||||
|
||||
// A sweep day works the miss log: every problem still standing on a
|
||||
// failure, minus the ones today's review list already carries.
|
||||
if (plan.sweep) {
|
||||
const reviewed = new Set(capped.map((p) => p.lc_number));
|
||||
const misses = (await openMisses(db)).filter((p) => !reviewed.has(p.lc_number));
|
||||
data.sweep = await Promise.all(misses.map((p) => tapRow(env, p, date, true)));
|
||||
}
|
||||
|
||||
// Saturday: the review issue already exists (created at midnight ET).
|
||||
|
||||
+91
-23
@@ -62,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",
|
||||
};
|
||||
|
||||
@@ -83,7 +83,7 @@ export interface DigestRow {
|
||||
failUrl: string;
|
||||
}
|
||||
|
||||
/** A learning-day core problem — the only rows allowed a link. */
|
||||
/** A plan-named problem — the only rows allowed a link. */
|
||||
export interface TopicRow {
|
||||
lc: number;
|
||||
difficulty: string;
|
||||
@@ -91,6 +91,13 @@ export interface TopicRow {
|
||||
solved: boolean;
|
||||
}
|
||||
|
||||
/** One learning-day topic: the core list, then the optional "if time" list. */
|
||||
export interface TopicSection {
|
||||
name: string;
|
||||
core: TopicRow[];
|
||||
ifTime: TopicRow[];
|
||||
}
|
||||
|
||||
export interface DigestData {
|
||||
/** "Tuesday, August 25" */
|
||||
day: string;
|
||||
@@ -98,11 +105,16 @@ export interface DigestData {
|
||||
progress: string;
|
||||
streak: number;
|
||||
rest: boolean;
|
||||
topic?: { name: string; core: TopicRow[] };
|
||||
/** Topics taught today, in plan order — empty on every other day. */
|
||||
topics: TopicSection[];
|
||||
/** A set day's problems, assigned by number rather than by topic. */
|
||||
assigned?: TopicRow[];
|
||||
reviews: DigestRow[];
|
||||
/** Reviews past the daily cap; levelled onto the coming days. */
|
||||
carried: number;
|
||||
drills: DigestRow[];
|
||||
/** A sweep day's miss log: present (even empty) only on a sweep day. */
|
||||
sweep?: DigestRow[];
|
||||
gate?: { week: number; url: string };
|
||||
yesterday: { total: number; failed: number[] };
|
||||
gateRate?: number;
|
||||
@@ -278,10 +290,15 @@ function Block({
|
||||
// ── email ────────────────────────────────────────────────────────
|
||||
|
||||
function DigestEmail({ data }: { data: DigestData }) {
|
||||
const load = data.reviews.length + data.drills.length;
|
||||
const preview = data.rest
|
||||
? "Rest day — nothing to do but rest."
|
||||
: `${load} to work through today${data.topic ? `, starting with ${data.topic.name}` : ""}.`;
|
||||
const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
|
||||
const lead = data.topics[0]
|
||||
? `, starting with ${data.topics[0].name}`
|
||||
: data.assigned
|
||||
? ", starting with today's set"
|
||||
: data.sweep
|
||||
? " — sweep day, nothing new"
|
||||
: "";
|
||||
const preview = data.rest ? "Rest day — nothing to do but rest." : `${load} to work through today${lead}.`;
|
||||
|
||||
const passed = data.yesterday.total - data.yesterday.failed.length;
|
||||
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
|
||||
@@ -302,7 +319,7 @@ function DigestEmail({ data }: { data: DigestData }) {
|
||||
<Text style={{ color: MUTED, fontSize: "13px", margin: "8px 0 0" }}>
|
||||
{data.progress}
|
||||
{data.rest
|
||||
? " · nothing is due today, and anything overdue waits for Monday."
|
||||
? " · the ladder never books a Sunday, so nothing is due — anything still open waits for Monday."
|
||||
: data.streak === 0
|
||||
? " · no streak going yet — today is a good day to start one."
|
||||
: ` · 🔥 ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`}
|
||||
@@ -310,15 +327,47 @@ function DigestEmail({ data }: { data: DigestData }) {
|
||||
|
||||
{data.rest ? null : (
|
||||
<>
|
||||
{data.topic ? (
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Something new today</Text>
|
||||
{data.topics.map((topic, i) => (
|
||||
<Section key={topic.name} style={{ marginTop: i === 0 ? "28px" : "20px" }}>
|
||||
{i === 0 ? (
|
||||
<Text style={label}>
|
||||
{data.topics.length === 1 ? "Something new today" : "Two new things today"}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
||||
{data.topic.name}
|
||||
{topic.name}
|
||||
</Text>
|
||||
<Text style={hint}>Work through these from the top — a tick means it is already solved.</Text>
|
||||
<TopicTable rows={data.topic.core} />
|
||||
<TopicTable rows={topic.core} />
|
||||
{topic.ifTime.length > 0 ? (
|
||||
<>
|
||||
<Text style={{ ...hint, margin: "12px 0 8px" }}>
|
||||
If time — only once the core is done inside the clock.
|
||||
</Text>
|
||||
<TopicTable rows={topic.ifTime} />
|
||||
</>
|
||||
) : null}
|
||||
</Section>
|
||||
))}
|
||||
|
||||
{data.assigned ? (
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Today's set</Text>
|
||||
<Text style={hint}>
|
||||
Picked by number, not by topic — all of them, in order, inside the clock.
|
||||
</Text>
|
||||
<TopicTable rows={data.assigned} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{data.sweep ? (
|
||||
<Block
|
||||
title="Sweep — redo every red"
|
||||
note="Every problem still standing on a miss. Nothing new today; clear these first."
|
||||
rows={data.sweep}
|
||||
staged
|
||||
empty="The miss log is clear — nothing is standing on a fail."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Block
|
||||
@@ -403,22 +452,33 @@ function plainDigest(data: DigestData): string {
|
||||
data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`,
|
||||
data.progress +
|
||||
(data.rest
|
||||
? " · nothing is due today, and anything overdue waits for Monday."
|
||||
? " · the ladder never books a Sunday, so nothing is due — anything still open waits for Monday."
|
||||
: data.streak === 0
|
||||
? " · no streak going yet — today is a good day to start one."
|
||||
: ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`),
|
||||
];
|
||||
|
||||
const linked = (p: TopicRow) =>
|
||||
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`;
|
||||
|
||||
if (!data.rest) {
|
||||
if (data.topic) {
|
||||
for (const topic of data.topics) {
|
||||
out.push(
|
||||
"",
|
||||
`SOMETHING NEW TODAY — ${data.topic.name}`,
|
||||
`SOMETHING NEW TODAY — ${topic.name}`,
|
||||
"Work through these from the top; the ones marked done you have already solved.",
|
||||
...data.topic.core.map(
|
||||
(p) =>
|
||||
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`,
|
||||
),
|
||||
...topic.core.map(linked),
|
||||
);
|
||||
if (topic.ifTime.length > 0) {
|
||||
out.push(" If time — only once the core is done inside the clock:", ...topic.ifTime.map(linked));
|
||||
}
|
||||
}
|
||||
if (data.assigned) {
|
||||
out.push(
|
||||
"",
|
||||
"TODAY'S SET",
|
||||
"Picked by number, not by topic — all of them, in order, inside the clock.",
|
||||
...data.assigned.map(linked),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -436,6 +496,14 @@ function plainDigest(data: DigestData): string {
|
||||
"No cold starts today.",
|
||||
],
|
||||
];
|
||||
if (data.sweep) {
|
||||
lists.unshift([
|
||||
"SWEEP — REDO EVERY RED",
|
||||
"Every problem still standing on a miss. Nothing new today; clear these first.",
|
||||
data.sweep,
|
||||
"The miss log is clear — nothing is standing on a fail.",
|
||||
]);
|
||||
}
|
||||
for (const [title, note, rows, empty] of lists) {
|
||||
out.push("", title);
|
||||
if (rows.length === 0) {
|
||||
|
||||
+58
-40
@@ -2,21 +2,27 @@
|
||||
* Saturday review issue: created at midnight ET so the 8 AM digest can link
|
||||
* to it; scored when the issue closes (webhook).
|
||||
*
|
||||
* The gate half is a topic-blind quiz — one unsolved optional problem per
|
||||
* topic scheduled this week (which is why daily drills only draw from
|
||||
* earlier weeks). The recap half is generated data, deliberately NOT a task
|
||||
* list: the week's solved problems are already scheduled by the +2 ladder,
|
||||
* and re-assigning them on Saturday would be massed practice.
|
||||
* The gate half is a topic-blind quiz. When the plan fixes the set
|
||||
* (`gate` in data/schedule.json) that list IS the gate, verbatim — every
|
||||
* number must be a catalog problem, or the issue is not created, because a
|
||||
* `/done` on an uncatalogued number can never be logged. Otherwise the set
|
||||
* is sampled: one unsolved optional problem per topic scheduled this week
|
||||
* (which is why daily drills only draw from earlier weeks), never one the
|
||||
* plan has reserved. The recap half is generated data, deliberately NOT a
|
||||
* task list: the week's solved problems are already scheduled by the +3/+7
|
||||
* ladder, and re-assigning them on Saturday would be massed practice.
|
||||
*
|
||||
* Label is `review` (the retired Actions system owned `gate`). Creation is
|
||||
* idempotent by week; deferred Hards never appear.
|
||||
* idempotent by week; deferred Hards never appear in a sampled set.
|
||||
*/
|
||||
import {
|
||||
type ProblemRow,
|
||||
RESERVED,
|
||||
type Result,
|
||||
SCHEDULE,
|
||||
campaignWeek,
|
||||
isoWeek,
|
||||
lookupProblems,
|
||||
rng,
|
||||
sample,
|
||||
streak,
|
||||
@@ -32,7 +38,7 @@ function capitalized(difficulty: string): string {
|
||||
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
|
||||
}
|
||||
|
||||
/** Unsolved problems of a topic in a set — never attempted, never drilled. */
|
||||
/** Unsolved problems of a topic in a set — never attempted, drilled, or reserved. */
|
||||
async function freshPool(db: D1Database, topic: number, set: string): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
@@ -44,7 +50,7 @@ async function freshPool(db: D1Database, topic: number, set: string): Promise<Pr
|
||||
)
|
||||
.bind(topic, set)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
return results.filter((p) => !RESERVED.has(p.lc_number));
|
||||
}
|
||||
|
||||
export interface CreateReport {
|
||||
@@ -67,40 +73,52 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
||||
return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` };
|
||||
}
|
||||
|
||||
// Topics scheduled this week; review-only weeks (Sep 23 onward) sample
|
||||
// across every topic that still has unsolved optional problems.
|
||||
let topics = Object.entries(SCHEDULE)
|
||||
.filter(([d]) => campaignWeek(d) === week)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([, t]) => t);
|
||||
if (topics.length === 0) {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT DISTINCT topic_issue AS t FROM problems
|
||||
WHERE set_label = 'optional' AND stage = 'new'
|
||||
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||
ORDER BY topic_issue`,
|
||||
)
|
||||
.all<{ t: number }>();
|
||||
topics = sample(results.map((r) => r.t), MAX_GATE_PROBLEMS, rng(`gate-topics-${iso}`)).sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
}
|
||||
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
|
||||
|
||||
const random = rng(`gate-${iso}`);
|
||||
// The plan's fixed set wins; otherwise sample this week's topics, and in a
|
||||
// week without topics, across every topic that still has an unsolved pool.
|
||||
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
||||
for (const topic of topics) {
|
||||
const optional = await freshPool(db, topic, "optional");
|
||||
if (optional.length > 0) {
|
||||
picks.push({ p: sample(optional, 1, random)[0]!, fallback: false });
|
||||
continue;
|
||||
const planned = SCHEDULE[date]?.gate;
|
||||
if (planned) {
|
||||
const { rows, missing } = await lookupProblems(db, planned);
|
||||
if (missing.length) {
|
||||
return {
|
||||
created: false,
|
||||
reason: `planned gate names problems outside the catalog: LC ${missing.join(", LC ")}`,
|
||||
};
|
||||
}
|
||||
const core = await freshPool(db, topic, "core");
|
||||
if (core.length > 0) picks.push({ p: sample(core, 1, random)[0]!, fallback: true });
|
||||
// Both pools exhausted: the topic is fully solved; nothing to quiz.
|
||||
picks.push(...rows.map((p) => ({ p, fallback: false })));
|
||||
} else {
|
||||
let topics = Object.entries(SCHEDULE)
|
||||
.filter(([d]) => campaignWeek(d) === week)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.flatMap(([, day]) => day.topics ?? []);
|
||||
if (topics.length === 0) {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT DISTINCT topic_issue AS t FROM problems
|
||||
WHERE set_label = 'optional' AND stage = 'new'
|
||||
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||
ORDER BY topic_issue`,
|
||||
)
|
||||
.all<{ t: number }>();
|
||||
topics = sample(results.map((r) => r.t), MAX_GATE_PROBLEMS, rng(`gate-topics-${iso}`)).sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
}
|
||||
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
|
||||
|
||||
const random = rng(`gate-${iso}`);
|
||||
for (const topic of topics) {
|
||||
const optional = await freshPool(db, topic, "optional");
|
||||
if (optional.length > 0) {
|
||||
picks.push({ p: sample(optional, 1, random)[0]!, fallback: false });
|
||||
continue;
|
||||
}
|
||||
const core = await freshPool(db, topic, "core");
|
||||
if (core.length > 0) picks.push({ p: sample(core, 1, random)[0]!, fallback: true });
|
||||
// Both pools exhausted: the topic is fully solved; nothing to quiz.
|
||||
}
|
||||
if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` };
|
||||
}
|
||||
if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` };
|
||||
|
||||
// Recap: everything attempted this week, plus streak — data, not tasks.
|
||||
const monday = weekMonday(date);
|
||||
@@ -154,7 +172,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
||||
|
||||
const milestone = await db
|
||||
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
|
||||
.bind(topics[0])
|
||||
.bind(picks[0]!.p.topic_issue)
|
||||
.first<{ milestone: number }>();
|
||||
|
||||
const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
|
||||
|
||||
+41
-26
@@ -19,7 +19,9 @@ import { type Mirror, projectMirror } from "./mirror.ts";
|
||||
import {
|
||||
CAMPAIGN_START,
|
||||
type LogOutcome,
|
||||
type Stage,
|
||||
daysBetween,
|
||||
dueToday,
|
||||
etDate,
|
||||
etHour,
|
||||
getProblem,
|
||||
@@ -30,27 +32,21 @@ import { buildStats } from "./stats.ts";
|
||||
|
||||
// ── shared side effects after a state write ──────────────────────
|
||||
|
||||
/** Project-field half of a mirror; shared by every write path. */
|
||||
async function mirrorFields(mirror: Mirror, outcome: LogOutcome): Promise<void> {
|
||||
await mirror.setStage(outcome.issue, outcome.stage);
|
||||
await mirror.setTargetDate(outcome.issue, outcome.next_review);
|
||||
if (outcome.first) await mirror.setFirstAttempt(outcome.issue, outcome.result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror one logged attempt into GitHub: Project fields always; on a
|
||||
* Mirror one logged attempt into GitHub: the Project's Target Date always; on a
|
||||
* first-ever pass also close the problem's sub-issue (comment first — repo
|
||||
* convention). Runs inside ctx.waitUntil; failures are warnings.
|
||||
*/
|
||||
async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Promise<void> {
|
||||
if (outcome.error || outcome.duplicate) return;
|
||||
const gh = github(env.GH_PAT, env.REPO);
|
||||
await mirrorFields(projectMirror(gh, env.REPO.split("/")[0]!), outcome);
|
||||
const mirror = projectMirror(gh, env.REPO.split("/")[0]!);
|
||||
await mirror.setTargetDate(outcome.issue, outcome.next_review);
|
||||
if (outcome.first && outcome.result === "pass") {
|
||||
try {
|
||||
await gh.closeIssue(
|
||||
outcome.issue,
|
||||
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +2. Logged by the SRS Worker.`,
|
||||
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +3. Logged by the SRS Worker.`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`close #${outcome.issue}: ${err}`);
|
||||
@@ -102,7 +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),
|
||||
);
|
||||
}
|
||||
@@ -208,47 +204,60 @@ async function adminAuthorized(request: Request, env: Env): Promise<boolean> {
|
||||
/**
|
||||
* Solutions landing in `work/` are the third way a problem gets solved, after
|
||||
* the digest tap and a /done comment. `close-solved` posts its whole
|
||||
* implemented set here on every push to main: logAttempt ignores anything
|
||||
* already on the ladder, so this is a total recompute like the reconcilers
|
||||
* that call it — a re-run changes nothing.
|
||||
* implemented set here on every push to main, each entry tagged with the
|
||||
* BUCKET the file sits in — 1 for the first solve, 3 and 7 for the blind
|
||||
* re-solves the picker scaffolds. The bucket names the ladder rung the file
|
||||
* settles, and logAttempt writes only when the problem is standing on that
|
||||
* rung, so this is a total recompute like the reconcilers that call it: a
|
||||
* re-run changes nothing, and a pushed re-solve advances +3 → +7 instead of
|
||||
* being invisible.
|
||||
*
|
||||
* Closing the sub-issue stays with close-solved: it is the side that knows
|
||||
* which files at which commit, so this path mirrors Project fields only.
|
||||
* Charts read D1 live, so a push moves them within the 5-minute cache.
|
||||
*/
|
||||
const RUNG_OF_BUCKET: Record<number, Stage> = { 1: "new", 3: "+3", 7: "+7" };
|
||||
|
||||
async function handleSolved(request: Request, env: Env, date: string): Promise<Response> {
|
||||
const body: unknown = await request.json().catch(() => null);
|
||||
const raw: unknown = body && typeof body === "object" && "lc" in body ? body.lc : null;
|
||||
if (!Array.isArray(raw)) return new Response('expected {"lc": [<number>, ...]}', { status: 400 });
|
||||
const lcs: number[] = [];
|
||||
const raw: unknown = body && typeof body === "object" && "solved" in body ? body.solved : null;
|
||||
const shape = 'expected {"solved": [{"lc": <number>, "bucket": 1|3|7}, ...]}';
|
||||
if (!Array.isArray(raw)) return new Response(shape, { status: 400 });
|
||||
const entries: { lc: number; rung: Stage; bucket: number }[] = [];
|
||||
for (const item of raw) {
|
||||
const lc: unknown = item;
|
||||
if (!item || typeof item !== "object") return new Response(shape, { status: 400 });
|
||||
const lc: unknown = "lc" in item ? item.lc : null;
|
||||
const bucket: unknown = "bucket" in item ? item.bucket : null;
|
||||
if (typeof lc !== "number" || !Number.isFinite(lc)) {
|
||||
return new Response(`not an LC number: ${JSON.stringify(lc)}`, { status: 400 });
|
||||
}
|
||||
lcs.push(lc);
|
||||
const rung = typeof bucket === "number" ? RUNG_OF_BUCKET[bucket] : undefined;
|
||||
if (rung === undefined) return new Response(`not a work/ bucket: ${JSON.stringify(bucket)}`, { status: 400 });
|
||||
entries.push({ lc, rung, bucket });
|
||||
}
|
||||
const dry = new URL(request.url).searchParams.get("dry") === "1";
|
||||
|
||||
const logged: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
let mirror: Mirror | undefined;
|
||||
for (const lc of lcs) {
|
||||
for (const { lc, rung, bucket } of entries) {
|
||||
if (dry) {
|
||||
const p = await getProblem(env.DB, lc);
|
||||
if (!p) skipped.push(`LC ${lc} is not in the curriculum`);
|
||||
else if (p.stage !== "new") skipped.push(`LC ${lc}: already at stage ${p.stage}`);
|
||||
else logged.push(`LC ${lc}: would enter the ladder at +2`);
|
||||
else if (p.stage !== rung) skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${p.stage}`);
|
||||
else if (rung === "new") logged.push(`LC ${lc}: would enter the ladder at +3`);
|
||||
else logged.push(`LC ${lc}: would pass ${rung} and move on`);
|
||||
continue;
|
||||
}
|
||||
const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit" });
|
||||
const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit", rung });
|
||||
if (outcome.error) skipped.push(outcome.error);
|
||||
else if (outcome.duplicate) skipped.push(`LC ${lc}: already at stage ${outcome.stage}`);
|
||||
else {
|
||||
else if (outcome.duplicate) {
|
||||
skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${outcome.stage}`);
|
||||
} else {
|
||||
logged.push(outcomeLine(outcome));
|
||||
// One mirror for the batch: field IDs resolve once, not per problem.
|
||||
mirror ??= projectMirror(github(env.GH_PAT, env.REPO), env.REPO.split("/")[0]!);
|
||||
await mirrorFields(mirror, outcome);
|
||||
await mirror.setTargetDate(outcome.issue, outcome.next_review);
|
||||
}
|
||||
}
|
||||
return Response.json({ date, logged, skipped, warnings: mirror?.warnings ?? [] });
|
||||
@@ -277,6 +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
@@ -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 });
|
||||
}),
|
||||
};
|
||||
}
|
||||
+148
-4
@@ -1,11 +1,35 @@
|
||||
/**
|
||||
* DST-guard and date-math checks with fixed instants — the acceptance proof
|
||||
* that the double crons fire exactly once at 8 AM ET (digest) and midnight
|
||||
* Saturday ET (review issue) on BOTH UTC offsets.
|
||||
* Acceptance proof for the two rules that cannot be eyeballed in production:
|
||||
* the double crons fire exactly once at 8 AM ET (digest) and midnight Saturday
|
||||
* ET (review issue) on BOTH UTC offsets, and NOTHING is ever scheduled on the
|
||||
* Sunday rest day — which is the whole reason the ladder's windows are 3 and 7.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { addDays, campaignDay, campaignWeek, etDate, etHour, isoWeek, rng, sample, weekdayOf } from "./srs.ts";
|
||||
import {
|
||||
INTERVAL,
|
||||
RESERVED,
|
||||
SCHEDULE,
|
||||
STAGES,
|
||||
WINDOWS,
|
||||
addDays,
|
||||
campaignDay,
|
||||
campaignWeek,
|
||||
etDate,
|
||||
etHour,
|
||||
isRestDay,
|
||||
isoWeek,
|
||||
rng,
|
||||
sample,
|
||||
TEMPERATURES,
|
||||
temperatureOf,
|
||||
topicDate,
|
||||
weekdayOf,
|
||||
workingDay,
|
||||
} from "./srs.ts";
|
||||
|
||||
/** Every ET date of the campaign, plus the deferred-Hard tail. */
|
||||
const CALENDAR = Array.from({ length: 70 }, (_, i) => addDays("2026-08-17", i));
|
||||
|
||||
describe("DST-proof cron guard", () => {
|
||||
test("digest fires only at ET hour 8 — EDT (UTC-4)", () => {
|
||||
@@ -50,6 +74,126 @@ describe("date math (noon-UTC anchored)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rest day", () => {
|
||||
test("Sunday is the only rest day", () => {
|
||||
expect(isRestDay("2026-08-30")).toBe(true); // Sunday
|
||||
expect(isRestDay("2026-08-29")).toBe(false); // Saturday: gate day, still work
|
||||
expect(isRestDay("2026-08-31")).toBe(false);
|
||||
});
|
||||
|
||||
test("workingDay never returns a Sunday, from any day and any offset", () => {
|
||||
for (const date of CALENDAR) {
|
||||
for (let offset = 0; offset < 10; offset++) {
|
||||
expect(isRestDay(workingDay(date, offset))).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("workingDay(date) leaves a working day alone and slides Sunday to Monday", () => {
|
||||
expect(workingDay("2026-09-04")).toBe("2026-09-04"); // Friday
|
||||
expect(workingDay("2026-09-05")).toBe("2026-09-05"); // Saturday
|
||||
expect(workingDay("2026-09-06")).toBe("2026-09-07"); // Sunday → Monday
|
||||
});
|
||||
|
||||
test("offsets count working days, so a spill skips the rest day", () => {
|
||||
// Levelling from Saturday: tomorrow is Sunday, so the spill starts Monday.
|
||||
expect(workingDay("2026-09-05", 1)).toBe("2026-09-07");
|
||||
expect(workingDay("2026-09-05", 2)).toBe("2026-09-08");
|
||||
// Two deferred Hards per working day from a Monday.
|
||||
expect(workingDay("2026-09-28", 5)).toBe("2026-10-03"); // Saturday
|
||||
expect(workingDay("2026-09-28", 6)).toBe("2026-10-05"); // Sunday skipped
|
||||
});
|
||||
|
||||
test("every ladder interval lands on a working day, from every learning day", () => {
|
||||
for (const date of CALENDAR) {
|
||||
if (isRestDay(date)) continue;
|
||||
for (const days of WINDOWS) {
|
||||
const review = workingDay(addDays(date, days));
|
||||
expect(isRestDay(review)).toBe(false);
|
||||
// Never earlier than the interval: a review may slip, never shorten.
|
||||
expect(review >= addDays(date, days)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("only Thursday's +3 needs the slide", () => {
|
||||
const slid = CALENDAR.filter(
|
||||
(date) => !isRestDay(date) && workingDay(addDays(date, 3)) !== addDays(date, 3),
|
||||
);
|
||||
expect(new Set(slid.map(weekdayOf))).toEqual(new Set([4])); // Thursday only
|
||||
for (const date of CALENDAR) {
|
||||
if (isRestDay(date)) continue;
|
||||
expect(workingDay(addDays(date, 7))).toBe(addDays(date, 7)); // +7 keeps the weekday
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the plan", () => {
|
||||
test("never books the rest day, and gates only on Saturday", () => {
|
||||
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||
expect(isRestDay(date)).toBe(false);
|
||||
if (day.gate) expect(weekdayOf(date)).toBe(6);
|
||||
if (day.topics) expect(weekdayOf(date)).toBeLessThan(6);
|
||||
}
|
||||
});
|
||||
|
||||
test("a topic is taught once; a doubled day answers for both", () => {
|
||||
const seen = new Set<number>();
|
||||
for (const day of Object.values(SCHEDULE)) {
|
||||
for (const topic of day.topics ?? []) {
|
||||
expect(seen.has(topic)).toBe(false);
|
||||
seen.add(topic);
|
||||
}
|
||||
}
|
||||
expect(topicDate(17)).toBe("2026-09-21");
|
||||
expect(topicDate(18)).toBe("2026-09-21");
|
||||
expect(topicDate(999)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("every gate and set problem is reserved from the samplers", () => {
|
||||
for (const day of Object.values(SCHEDULE)) {
|
||||
for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) {
|
||||
expect(RESERVED.has(lc)).toBe(true);
|
||||
}
|
||||
}
|
||||
expect(RESERVED.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ladder", () => {
|
||||
test("two rungs, named after the windows", () => {
|
||||
expect(STAGES).toEqual(["new", "+3", "+7", "retired"]);
|
||||
expect(WINDOWS.map((w) => INTERVAL[`+${w}`])).toEqual([...WINDOWS]);
|
||||
// Every non-terminal stage has an interval, and only those.
|
||||
expect(Object.keys(INTERVAL).sort()).toEqual(STAGES.slice(1, -1).sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("concept temperature", () => {
|
||||
test("the bands are the ladder's own windows", () => {
|
||||
// Boundaries are inclusive, and the bands tile the whole age axis with no
|
||||
// gap: whatever the windows become, every age still lands in exactly one.
|
||||
expect(TEMPERATURES).toEqual(["hot", "fresh", "fading", "cold"]);
|
||||
expect(temperatureOf(0)).toBe("hot");
|
||||
expect(temperatureOf(WINDOWS[0])).toBe("hot");
|
||||
expect(temperatureOf(WINDOWS[0] + 1)).toBe("fresh");
|
||||
expect(temperatureOf(WINDOWS[1])).toBe("fresh");
|
||||
expect(temperatureOf(WINDOWS[1] + 1)).toBe("fading");
|
||||
expect(temperatureOf(WINDOWS[1] * 2)).toBe("fading");
|
||||
expect(temperatureOf(WINDOWS[1] * 2 + 1)).toBe("cold");
|
||||
});
|
||||
|
||||
test("never attempted is cold, not unknown", () => {
|
||||
expect(temperatureOf(null)).toBe("cold");
|
||||
});
|
||||
|
||||
test("every age of the campaign has exactly one band", () => {
|
||||
for (let age = 0; age <= CALENDAR.length; age++) {
|
||||
expect(TEMPERATURES).toContain(temperatureOf(age));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deterministic sampling", () => {
|
||||
test("same seed, same picks", () => {
|
||||
const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
|
||||
+267
-39
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* SRS domain: ET dates, the interval ladder, deterministic sampling, and the
|
||||
* 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;
|
||||
}
|
||||
@@ -88,16 +126,50 @@ export function prettyDate(date: string): string {
|
||||
|
||||
// ── the schedule (bundled; humans edit apps/api/data/schedule.json) ───
|
||||
|
||||
export const SCHEDULE: Record<string, number> = scheduleJson;
|
||||
/**
|
||||
* One calendar day of the plan. Most days teach topics (one, or two when the
|
||||
* plan doubles up); the rest are named by what they assign instead: a fixed
|
||||
* gate set for Saturday's review issue, a fixed problem set (a "B-set" day),
|
||||
* or a sweep — redo every miss, nothing new. Absent dates are unplanned:
|
||||
* the ladder still runs, the digest just has nothing new to say.
|
||||
*/
|
||||
export interface ScheduledDay {
|
||||
/** Topic issues taught that day, in the order the digest presents them. */
|
||||
topics?: number[];
|
||||
/** Problems assigned by number instead of by topic. */
|
||||
problems?: number[];
|
||||
/** Saturday: the blind set, fixed by the plan rather than sampled. */
|
||||
gate?: number[];
|
||||
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
|
||||
sweep?: boolean;
|
||||
}
|
||||
|
||||
/** Week in which a topic was (or will be) taught. */
|
||||
export function topicWeek(topic: number): number | undefined {
|
||||
for (const [date, t] of Object.entries(SCHEDULE)) {
|
||||
if (t === topic) return campaignWeek(date);
|
||||
export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
|
||||
|
||||
/** Date the schedule teaches a topic on, if it teaches it at all. */
|
||||
export function topicDate(topic: number): string | undefined {
|
||||
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||
if (day.topics?.includes(topic)) return date;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Week in which a topic was (or will be) taught. */
|
||||
export function topicWeek(topic: number): number | undefined {
|
||||
const date = topicDate(topic);
|
||||
return date === undefined ? undefined : campaignWeek(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every problem the plan names outright — a gate's blind set or a set day's
|
||||
* list. The plan owns these: the samplers (daily drills, a sampled gate)
|
||||
* never draw them, so a gate problem stays unseen until its Saturday and a
|
||||
* set day's problems wait for their day.
|
||||
*/
|
||||
export const RESERVED: ReadonlySet<number> = new Set(
|
||||
Object.values(SCHEDULE).flatMap((day) => [...(day.gate ?? []), ...(day.problems ?? [])]),
|
||||
);
|
||||
|
||||
// ── deterministic sampling ───────────────────────────────────────
|
||||
|
||||
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
|
||||
@@ -126,10 +198,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 +267,8 @@ export interface ProblemRow {
|
||||
defer_until: string | null;
|
||||
}
|
||||
|
||||
export const INTERVAL: Record<string, number> = { "+2": 2, "+5": 5, "+10": 10 };
|
||||
const NEXT_STAGE: Record<string, Stage> = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" };
|
||||
export const INTERVAL: Record<string, number> = { "+3": 3, "+7": 7 };
|
||||
const NEXT_STAGE: Record<string, Stage> = { new: "+3", "+3": "+7", "+7": "retired" };
|
||||
|
||||
// ── queries ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -151,31 +276,122 @@ export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow
|
||||
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog rows for a list of numbers the plan names, in the plan's order,
|
||||
* plus the numbers the catalog does not know — a planned problem without an
|
||||
* issue can never be logged, so callers decide whether that is fatal.
|
||||
*/
|
||||
export async function lookupProblems(
|
||||
db: D1Database,
|
||||
lcs: number[],
|
||||
): Promise<{ rows: ProblemRow[]; missing: number[] }> {
|
||||
if (lcs.length === 0) return { rows: [], missing: [] };
|
||||
const { results } = await db
|
||||
.prepare(`SELECT * FROM problems WHERE lc_number IN (${lcs.map(() => "?").join(",")})`)
|
||||
.bind(...lcs)
|
||||
.all<ProblemRow>();
|
||||
const byNumber = new Map(results.map((p) => [p.lc_number, p]));
|
||||
const rows: ProblemRow[] = [];
|
||||
const missing: number[] = [];
|
||||
for (const lc of lcs) {
|
||||
const row = byNumber.get(lc);
|
||||
if (row) rows.push(row);
|
||||
else missing.push(lc);
|
||||
}
|
||||
return { rows, missing };
|
||||
}
|
||||
|
||||
/**
|
||||
* What "due" means, in one place: not retired, scheduled on/before ?1, and
|
||||
* past its deferral if it has one. Every caller that asks "what is open on
|
||||
* this day" — the digest, the review issue, the docs queue chart — shares
|
||||
* this clause so they cannot answer differently.
|
||||
*/
|
||||
export const DUE_WHERE = `stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||
AND (defer_until IS NULL OR defer_until <= ?1)`;
|
||||
|
||||
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
||||
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM problems
|
||||
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||
AND (defer_until IS NULL OR defer_until <= ?1)
|
||||
ORDER BY next_review, lc_number`,
|
||||
)
|
||||
.prepare(`SELECT * FROM problems WHERE ${DUE_WHERE} ORDER BY next_review, lc_number`)
|
||||
.bind(date)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
}
|
||||
|
||||
/** One problem the ladder wants re-solved today. */
|
||||
export interface DueRow {
|
||||
lc: number;
|
||||
issue: number;
|
||||
title: string;
|
||||
difficulty: string;
|
||||
/** The rung being settled: also the `work/<n>` bucket the re-solve goes in. */
|
||||
stage: Stage;
|
||||
bucket: number;
|
||||
/** Scheduled day; earlier than `date` when a day was missed. */
|
||||
due: string;
|
||||
/** Days late (0 = due today), so a renderer can say so. */
|
||||
late: number;
|
||||
/** Last logged attempt, and how long ago — the "last seen" line. */
|
||||
last_seen: string | null;
|
||||
days_since: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the ladder wants re-solved on `date`, oldest first.
|
||||
*
|
||||
* The read side of "which problems are open today": the same rows the digest
|
||||
* mails, shaped for whoever renders them (the daily Spaced Repetition issue).
|
||||
* Nothing here writes, so it is safe to call repeatedly, and there is no
|
||||
* second schedule to drift — D1's ladder is the schedule.
|
||||
*/
|
||||
export async function dueToday(db: D1Database, date: string): Promise<{ date: string; due: DueRow[] }> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT lc_number, issue, title, difficulty, stage, next_review,
|
||||
(SELECT MAX(a.date) FROM attempts a WHERE a.lc_number = problems.lc_number) AS last_seen
|
||||
FROM problems WHERE ${DUE_WHERE} ORDER BY next_review, lc_number`,
|
||||
)
|
||||
.bind(date)
|
||||
.all<{
|
||||
lc_number: number;
|
||||
issue: number;
|
||||
title: string;
|
||||
difficulty: string;
|
||||
stage: Stage;
|
||||
next_review: string;
|
||||
last_seen: string | null;
|
||||
}>();
|
||||
return {
|
||||
date,
|
||||
due: results.map((r) => ({
|
||||
lc: r.lc_number,
|
||||
issue: r.issue,
|
||||
title: r.title,
|
||||
difficulty: r.difficulty,
|
||||
stage: r.stage,
|
||||
// "+3" -> 3: the rung's number IS its work/ bucket.
|
||||
bucket: Number(r.stage.slice(1)),
|
||||
due: r.next_review,
|
||||
late: daysBetween(r.next_review, date),
|
||||
last_seen: r.last_seen,
|
||||
days_since: r.last_seen === null ? null : daysBetween(r.last_seen, date),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reviews surfaced per day; the digest levels everything past this forward. */
|
||||
export const REVIEW_CAP = 3;
|
||||
|
||||
/**
|
||||
* Load-level an overloaded review queue: everything due beyond today's
|
||||
* REVIEW_CAP is pushed to a concrete future date — at most REVIEW_CAP per
|
||||
* day, oldest first — instead of piling up as "due today". Runs every digest
|
||||
* morning, so a future day that grows past the cap (spill plus newly
|
||||
* REVIEW_CAP is pushed to a concrete future WORKING day — at most REVIEW_CAP
|
||||
* per day, oldest first — instead of piling up as "due today". Runs every
|
||||
* digest morning, so a future day that grows past the cap (spill plus newly
|
||||
* maturing reviews) is simply re-levelled when it arrives. Idempotent within
|
||||
* a date: after one pass at most REVIEW_CAP problems remain due today, so a
|
||||
* second pass moves nothing.
|
||||
* second pass moves nothing. The spill starts tomorrow, or Monday when
|
||||
* tomorrow is the rest day.
|
||||
*/
|
||||
export async function levelReviews(db: D1Database, date: string): Promise<number> {
|
||||
const overflow = (await dueReviews(db, date)).slice(REVIEW_CAP);
|
||||
@@ -184,7 +400,7 @@ export async function levelReviews(db: D1Database, date: string): Promise<number
|
||||
overflow.map((p, i) =>
|
||||
db
|
||||
.prepare("UPDATE problems SET next_review = ? WHERE lc_number = ?")
|
||||
.bind(addDays(date, 1 + Math.floor(i / REVIEW_CAP)), p.lc_number),
|
||||
.bind(workingDay(addDays(date, 1), Math.floor(i / REVIEW_CAP)), p.lc_number),
|
||||
),
|
||||
);
|
||||
return overflow.length;
|
||||
@@ -195,8 +411,9 @@ export async function levelReviews(db: D1Database, date: string): Promise<number
|
||||
* scheduled in an earlier week (the current week's optional pool is reserved
|
||||
* for Saturday's gate) AND showing learning evidence: at least one of the
|
||||
* topic's core problems has entered the ladder. A skipped learning day never
|
||||
* feeds drills just because its calendar week lapsed. Never repeated,
|
||||
* seeded by date. Boosted topics contribute up to 2 extra.
|
||||
* feeds drills just because its calendar week lapsed. Problems the plan names
|
||||
* outright (RESERVED) are never drawn. Never repeated, seeded by date.
|
||||
* Boosted topics contribute up to 2 extra.
|
||||
*/
|
||||
export async function pickDrills(
|
||||
db: D1Database,
|
||||
@@ -219,6 +436,7 @@ export async function pickDrills(
|
||||
)
|
||||
.all<ProblemRow & { boost: number }>();
|
||||
const pool = results.filter((p) => {
|
||||
if (RESERVED.has(p.lc_number)) return false;
|
||||
const w = topicWeek(p.topic_issue);
|
||||
return w !== undefined && w < week;
|
||||
});
|
||||
@@ -273,9 +491,14 @@ export interface LogOutcome {
|
||||
* Email idempotency comes from the partial unique index on
|
||||
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
||||
* nothing and must not touch the ladder. Webhook corrections (pass then fail
|
||||
* on the same day) remain legal — every webhook attempt appends. Commit
|
||||
* idempotency needs no index: a commit records a FIRST solve only, so the
|
||||
* stage check below turns every re-push into a no-op.
|
||||
* on the same day) remain legal — every webhook attempt appends.
|
||||
*
|
||||
* Commit idempotency needs no index either. A commit names the rung the file
|
||||
* it landed in settles — `work/1` settles `new`, `work/3` settles `+3`,
|
||||
* `work/7` settles `+7` — and the stage check below writes only when the
|
||||
* ladder is actually standing on that rung. So re-pushing a solution, or the
|
||||
* reconciler re-sending its whole implemented set on every push, moves
|
||||
* nothing: the rung it names has already been left behind.
|
||||
*/
|
||||
export async function logAttempt(
|
||||
db: D1Database,
|
||||
@@ -284,6 +507,8 @@ export async function logAttempt(
|
||||
date: string;
|
||||
result: Result;
|
||||
source: "email" | "webhook" | "commit";
|
||||
/** Commit only: the stage this file settles. Default `new` = first solve. */
|
||||
rung?: Stage;
|
||||
gate?: boolean;
|
||||
},
|
||||
): Promise<LogOutcome> {
|
||||
@@ -300,11 +525,12 @@ export async function logAttempt(
|
||||
duplicate: false,
|
||||
};
|
||||
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
|
||||
// A commit only ever reports a first solve: pushing the file again — or the
|
||||
// reconciler re-sending the whole solved set — must never move the ladder,
|
||||
// and must never wake a retired problem. Reviews come from the digest tap,
|
||||
// a /done comment, or a gate.
|
||||
if (opts.source === "commit" && p.stage !== "new") {
|
||||
// A commit reports a file, not an event: it may only settle the rung that
|
||||
// file's bucket IS. Any other stage means the push is old news (the ladder
|
||||
// already moved past it) or premature — no write either way, and a retired
|
||||
// problem can never be woken. Fresh reviews otherwise come from the digest
|
||||
// tap, a /done comment, or a gate.
|
||||
if (opts.source === "commit" && p.stage !== (opts.rung ?? "new")) {
|
||||
return {
|
||||
...nothing,
|
||||
title: p.title,
|
||||
@@ -336,16 +562,18 @@ export async function logAttempt(
|
||||
return { ...nothing, title: p.title, kind, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true };
|
||||
}
|
||||
|
||||
// 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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* The SRS Worker, from the CLI side: one base URL and one read-side payload.
|
||||
*
|
||||
* `SRS_API` points a script at a `wrangler dev` instance instead of production
|
||||
* — the same switch close-solved.ts uses for its `/admin/solved` push, which is
|
||||
* why the default lives here rather than in either script.
|
||||
*
|
||||
* The payload is narrowed with a guard rather than cast: D1 is the only source
|
||||
* of SRS truth, so if the Worker answers something else the caller must say so
|
||||
* out loud, not render half a dashboard off undefined fields.
|
||||
*/
|
||||
import { STAGES, TEMPERATURES, type Stage, type Temperature } from "../api/src/srs.ts";
|
||||
|
||||
export const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
||||
|
||||
// The rungs and the bands come straight from the Worker's domain module — the
|
||||
// same import work.ts makes for WINDOWS — so a change to either vocabulary
|
||||
// reaches this dashboard without a second list to remember.
|
||||
export { STAGES, TEMPERATURES, type Stage, type Temperature };
|
||||
|
||||
export interface Phase {
|
||||
milestone: number;
|
||||
name: string;
|
||||
core_done: number;
|
||||
core_total: number;
|
||||
optional_done: number;
|
||||
optional_total: number;
|
||||
deferred_done: number;
|
||||
deferred_total: number;
|
||||
}
|
||||
|
||||
export interface Topic {
|
||||
issue: number;
|
||||
name: string;
|
||||
milestone: number | null;
|
||||
week: number | null;
|
||||
taught: boolean;
|
||||
total: number;
|
||||
done: number;
|
||||
retired: number;
|
||||
due: number;
|
||||
last: string | null;
|
||||
age: number | null;
|
||||
temperature: Temperature;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
generated: string;
|
||||
campaign: { day: number; week: number; start: string; days: number };
|
||||
phases: Phase[];
|
||||
ladder: Record<Stage, number>;
|
||||
difficulty: { difficulty: string; total: number; done: number }[];
|
||||
topics: Topic[];
|
||||
/** week is the 1-based campaign week — the Worker converts from ISO. */
|
||||
gates: { week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }[];
|
||||
streak: number;
|
||||
queue: { date: string; due: number }[];
|
||||
/** Every campaign day, zeros and future days included. */
|
||||
heat: { date: string; attempts: number; passes: number }[];
|
||||
}
|
||||
|
||||
function isStats(value: unknown): value is Stats {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof v.campaign === "object" &&
|
||||
v.campaign !== null &&
|
||||
typeof v.ladder === "object" &&
|
||||
v.ladder !== null &&
|
||||
typeof v.streak === "number" &&
|
||||
Array.isArray(v.phases) &&
|
||||
Array.isArray(v.difficulty) &&
|
||||
Array.isArray(v.topics) &&
|
||||
Array.isArray(v.gates) &&
|
||||
Array.isArray(v.queue) &&
|
||||
Array.isArray(v.heat)
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/stats. Throws with the reason; there is no local substitute. */
|
||||
export async function fetchStats(signal?: AbortSignal): Promise<Stats> {
|
||||
const res = await fetch(`${SRS_API}/api/stats`, { signal });
|
||||
if (!res.ok) throw new Error(`GET /api/stats -> ${res.status} ${await res.text()}`);
|
||||
const body: unknown = await res.json();
|
||||
if (!isStats(body)) throw new Error("GET /api/stats -> unrecognised payload shape");
|
||||
return body;
|
||||
}
|
||||
+31
-76
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* `bun run pick` scaffolds a statement header plus an empty function body, so
|
||||
* file existence alone means nothing — a stub must not close its issue. See
|
||||
* isImplemented().
|
||||
* isImplemented() in source.ts.
|
||||
*
|
||||
* bun apps/cli/close-solved.ts # close matches, log them in D1
|
||||
* bun apps/cli/close-solved.ts --dry-run # report only, touch nothing
|
||||
@@ -19,68 +19,19 @@
|
||||
* SRS_ADMIN_KEY for the D1 push (unset = skip it); SRS_API overrides
|
||||
* the Worker URL for local `wrangler dev` runs.
|
||||
*/
|
||||
import { basename, join } from "node:path";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { SRS_API } from "./api.ts";
|
||||
import { github } from "./github.ts";
|
||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..", "..");
|
||||
const WORK = join(ROOT, "work");
|
||||
import { isImplemented } from "./source.ts";
|
||||
import { WORK, parseWorkPath, type Bucket } from "./work.ts";
|
||||
|
||||
const DRY =
|
||||
process.argv.includes("--dry-run") ||
|
||||
process.env.DRY_RUN === "1" ||
|
||||
process.env.DRY_RUN === "true";
|
||||
|
||||
// ── is the file a real solution or just a scaffolded stub? ────────
|
||||
|
||||
/** Placeholder bodies that leetcode-cli / a human leaves behind. */
|
||||
const PLACEHOLDERS: Record<string, true> = { pass: true, "...": true, TODO: true };
|
||||
|
||||
/**
|
||||
* Decide whether a work/ file contains an implementation.
|
||||
*
|
||||
* The header comment is dropped the same way sync.ts splitSource() does it
|
||||
* (duplicated rather than imported, because sync.ts runs its whole pipeline on
|
||||
* import). Comments must go before any brace analysis: the scaffold's JSDoc
|
||||
* carries `@param {number[]}`, whose braces would otherwise read as a body.
|
||||
*
|
||||
* A brace-language file counts as implemented when at least one *innermost*
|
||||
* brace pair holds real content. That distinguishes a bare stub
|
||||
* (`function(nums) {}`) and a class-shaped design stub (every method body
|
||||
* empty) from any genuine solution, whose innermost block always has code.
|
||||
*/
|
||||
function isImplemented(src: string, lang: "js" | "py"): boolean {
|
||||
if (lang === "py") {
|
||||
const open = src.indexOf('"""');
|
||||
const close = src.indexOf('"""', open + 3);
|
||||
const code = open === -1 || close === -1 ? src : src.slice(close + 3);
|
||||
for (const raw of code.split("\n")) {
|
||||
const line = raw.replace(/#.*$/, "").trim();
|
||||
if (!line || PLACEHOLDERS[line]) continue;
|
||||
if (/^(?:@|def\s|class\s)/.test(line)) continue;
|
||||
return true; // a statement inside some def body
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const headerEnd = src.indexOf("*/");
|
||||
const body = headerEnd === -1 ? src : src.slice(headerEnd + 2);
|
||||
const code = body.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/.*$/gm, " ");
|
||||
|
||||
let innermost = -1;
|
||||
for (let i = 0; i < code.length; i++) {
|
||||
if (code[i] === "{") {
|
||||
innermost = i;
|
||||
} else if (code[i] === "}" && innermost !== -1) {
|
||||
const inner = code.slice(innermost + 1, i).replace(/[\s;]/g, "");
|
||||
if (inner && !PLACEHOLDERS[inner]) return true;
|
||||
innermost = -1; // measured; the enclosing pair is not innermost
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── repo + auth ──────────────────────────────────────────────────
|
||||
|
||||
const gh = await github();
|
||||
@@ -89,27 +40,27 @@ const gh = await github();
|
||||
|
||||
interface WorkEntry {
|
||||
files: string[];
|
||||
/** Any bucket implemented — this is what closes the problem issue. */
|
||||
implemented: boolean;
|
||||
/** Buckets holding a real solution: 1 = first solve, 3/7 = the re-solves. */
|
||||
solved: Set<Bucket>;
|
||||
}
|
||||
|
||||
/** LC number -> solution files (a problem may have both js and py). */
|
||||
/** LC number -> its solution files across every bucket (js and py both count). */
|
||||
const work = new Map<number, WorkEntry>();
|
||||
for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
||||
const m = basename(rel).match(/^(\d+)\.(.+)\.(?:js|py)$/);
|
||||
if (!m) {
|
||||
console.warn(`skip (unrecognized name): work/${rel}`);
|
||||
const file = parseWorkPath(rel);
|
||||
if (!file) {
|
||||
console.warn(`skip (off-layout): work/${rel}`);
|
||||
continue;
|
||||
}
|
||||
const num = Number(m[1]);
|
||||
const src = await Bun.file(join(WORK, rel)).text();
|
||||
const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js");
|
||||
const entry = work.get(num);
|
||||
if (entry) {
|
||||
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 ──────────────────────────────────────────
|
||||
@@ -205,14 +156,18 @@ for (const num of [...work.keys()].sort((a, b) => a - b)) {
|
||||
// or the charts, the digest's "already solved" ticks, and the drill/gate
|
||||
// pools all keep treating it as untouched.
|
||||
//
|
||||
// The whole implemented set goes over, not just this run's closes: the
|
||||
// Worker drops anything already on the ladder, so the call is a total
|
||||
// recompute and backfills whatever earlier runs missed.
|
||||
const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
||||
// Each entry carries the BUCKET the file sits in, which is the rung it
|
||||
// settles: work/1 is the first solve, work/3 the 3-day review, work/7 the
|
||||
// 7-day one. That is what makes a pushed re-solve advance the ladder instead
|
||||
// of vanishing — and the Worker writes only when a problem is standing on the
|
||||
// rung named, so the whole implemented set can go over on every push (a total
|
||||
// recompute that backfills whatever earlier runs missed) and re-runs write
|
||||
// nothing.
|
||||
// SRS_API (apps/cli/api.ts) is the switch that points this at a wrangler dev
|
||||
// instance instead of production.
|
||||
const solved = [...work.entries()]
|
||||
.filter(([, entry]) => entry.implemented)
|
||||
.map(([lc]) => lc)
|
||||
.sort((a, b) => a - b);
|
||||
.flatMap(([lc, entry]) => [...entry.solved].sort().map((bucket) => ({ lc, bucket })))
|
||||
.sort((a, b) => a.lc - b.lc || a.bucket - b.bucket);
|
||||
|
||||
let syncNote: string;
|
||||
let syncFailed = false;
|
||||
@@ -228,7 +183,7 @@ if (!process.env.SRS_ADMIN_KEY) {
|
||||
authorization: `Bearer ${process.env.SRS_ADMIN_KEY}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ lc: solved }),
|
||||
body: JSON.stringify({ solved }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
||||
// Narrow the report payload instead of casting it; a shape change should
|
||||
@@ -257,7 +212,7 @@ printTable(rows);
|
||||
|
||||
const actionable = report.filter((r) => r[3] !== "no open issue");
|
||||
console.log(
|
||||
`\n${work.size} in work/ · ${solved.length} implemented · ` +
|
||||
`\n${work.size} problems in work/ · ${solved.length} solved files across buckets · ` +
|
||||
`${actionable.length} matched an open issue · ` +
|
||||
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}` +
|
||||
`\nd1: ${syncNote}`,
|
||||
@@ -265,7 +220,7 @@ console.log(
|
||||
|
||||
await writeStepSummary(
|
||||
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
|
||||
`${work.size} files in \`work/\`, ${solved.length} implemented, ` +
|
||||
`${work.size} problems in \`work/\`, ${solved.length} solved files across buckets, ` +
|
||||
`${actionable.length} matched an open issue.\n\n` +
|
||||
(actionable.length
|
||||
? `${markdownTable([rows[0]!, ...actionable])}\n`
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
* are ever fetched, and paid-only problems are skipped because GraphQL
|
||||
* returns null content for them.
|
||||
*
|
||||
* A `solutions` table tracks the lifecycle of each work/ file by LC number:
|
||||
* `created_at` (backfilled from the file's birthtime on every picker run),
|
||||
* `last_tested` and `last_submitted` (stamped on exit 0 of leetcode-cli).
|
||||
* The test picker sorts by last_tested, the submit picker by created_at, and
|
||||
* submit hides anything with a last_submitted.
|
||||
*
|
||||
* Library: side-effect-free on import — nothing is opened or fetched until
|
||||
* problemDb() is called. Index upserts never touch the description column, so
|
||||
* a daily refresh cannot evict cached statements.
|
||||
@@ -27,6 +33,13 @@ export interface Problem {
|
||||
paidOnly: boolean;
|
||||
}
|
||||
|
||||
/** Lifecycle of one work/ solution, keyed by LC number. ISO timestamps. */
|
||||
export interface SolutionActivity {
|
||||
createdAt: string;
|
||||
lastTested: string | null;
|
||||
lastSubmitted: string | null;
|
||||
}
|
||||
|
||||
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
|
||||
const INDEX_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -109,6 +122,13 @@ export function problemDb(path = DB_PATH) {
|
||||
paid_only INTEGER NOT NULL,
|
||||
description TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS solutions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
last_tested TEXT,
|
||||
last_submitted TEXT
|
||||
);
|
||||
DROP TABLE IF EXISTS submissions;
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
@@ -177,6 +197,37 @@ export function problemDb(path = DB_PATH) {
|
||||
db.query("UPDATE problems SET description = ?2 WHERE slug = ?1").run(slug, html);
|
||||
},
|
||||
|
||||
/** Per-solution lifecycle rows keyed by LC number. */
|
||||
solutionActivity(): Map<number, SolutionActivity> {
|
||||
const rows = db
|
||||
.query<{ id: number; created_at: string; last_tested: string | null; last_submitted: string | null }, []>(
|
||||
"SELECT id, created_at, last_tested, last_submitted FROM solutions",
|
||||
)
|
||||
.all();
|
||||
return new Map(
|
||||
rows.map((r) => [r.id, { createdAt: r.created_at, lastTested: r.last_tested, lastSubmitted: r.last_submitted }]),
|
||||
);
|
||||
},
|
||||
|
||||
/** Register a solution's creation time; existing rows are left alone. */
|
||||
ensureSolution(id: number, createdAt: string): void {
|
||||
db.query("INSERT INTO solutions (id, created_at) VALUES (?1, ?2) ON CONFLICT(id) DO NOTHING").run(id, createdAt);
|
||||
},
|
||||
|
||||
markTested(id: number): void {
|
||||
db.query(
|
||||
`INSERT INTO solutions (id, created_at, last_tested) VALUES (?1, ?2, ?2)
|
||||
ON CONFLICT(id) DO UPDATE SET last_tested = excluded.last_tested`,
|
||||
).run(id, new Date().toISOString());
|
||||
},
|
||||
|
||||
markSubmitted(id: number): void {
|
||||
db.query(
|
||||
`INSERT INTO solutions (id, created_at, last_submitted) VALUES (?1, ?2, ?2)
|
||||
ON CONFLICT(id) DO UPDATE SET last_submitted = excluded.last_submitted`,
|
||||
).run(id, new Date().toISOString());
|
||||
},
|
||||
|
||||
close(): void {
|
||||
db.close();
|
||||
},
|
||||
|
||||
+23
-3
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* GitHub REST plumbing shared by the issue reconcilers (close-solved,
|
||||
* close-topics).
|
||||
* GitHub REST + GraphQL plumbing shared by the issue reconcilers (close-solved,
|
||||
* close-topics) and the Project field reconciler (sync-project-fields).
|
||||
*
|
||||
* Importing this module is side-effect free — nothing resolves credentials or
|
||||
* touches the network until github() is awaited — so a script can import it
|
||||
@@ -22,6 +22,8 @@ export interface GitHub {
|
||||
repo: string;
|
||||
/** One authenticated request against api.github.com; throws on non-2xx. */
|
||||
api(path: string, init?: RequestInit): Promise<unknown>;
|
||||
/** One GraphQL call (Projects v2 lives here); throws on transport or query errors. */
|
||||
graphql(query: string, variables?: Record<string, unknown>): Promise<unknown>;
|
||||
/** Every page of a list endpoint, flattened into one stream of elements. */
|
||||
list(path: string): AsyncGenerator<unknown, void, void>;
|
||||
/** Comment on an issue, then close it as completed. */
|
||||
@@ -74,6 +76,24 @@ export async function github(): Promise<GitHub> {
|
||||
}
|
||||
}
|
||||
|
||||
async function graphql(
|
||||
query: string,
|
||||
variables: Record<string, unknown> = {},
|
||||
): Promise<unknown> {
|
||||
const res = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
|
||||
const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
|
||||
if (payload.errors?.length) throw new Error(payload.errors.map((e) => e.message).join("; "));
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment before closing: if the PATCH fails, the issue still carries a
|
||||
* visible note of what the automation decided, instead of failing silently.
|
||||
@@ -89,5 +109,5 @@ export async function github(): Promise<GitHub> {
|
||||
});
|
||||
}
|
||||
|
||||
return { repo, api, list, closeIssue };
|
||||
return { repo, api, graphql, list, closeIssue };
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"pick": "bun ./pick.ts",
|
||||
"stats": "bun ./stats.ts",
|
||||
"test": "bun ./test.ts",
|
||||
"submit": "bun ./submit.ts",
|
||||
"sync": "bun ./sync.ts",
|
||||
|
||||
+186
-30
@@ -1,19 +1,24 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Fuzzy-pick a LeetCode problem and scaffold it via leetcode-cli.
|
||||
* Fuzzy-pick a problem and scaffold it via leetcode-cli, in one of the three
|
||||
* work/ buckets (see work.ts): a first solve under work/1, or the 3-day and
|
||||
* 7-day blind re-solves under work/3 and work/7. Tab cycles the sections.
|
||||
*
|
||||
* The problem index lives in the local SQLite cache (see db.ts), refreshed at
|
||||
* most once per 24h; campaign problem descriptions from README.md are
|
||||
* backfilled into the same db on the way. Problems that already have a
|
||||
* numbered file under work/ are excluded from the picker — re-picking them
|
||||
* would only clobber the existing scaffold.
|
||||
* backfilled into the same db on the way. The new-problem section hides
|
||||
* anything that already has a numbered file under work/1 — re-picking it would
|
||||
* only clobber the existing scaffold — and each review section hides whatever
|
||||
* its own bucket already holds, so a finished re-solve leaves the list.
|
||||
*/
|
||||
import { autocomplete, cancel, isCancel, spinner } from "@clack/prompts";
|
||||
import { basename, join } from "node:path";
|
||||
import { fetchDescription, problemDb, readmeSlugs, type Problem } from "./db";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..", "..");
|
||||
const WORK_DIR = join(ROOT, "work");
|
||||
import { autocomplete, cancel, isCancel, log, spinner } from "@clack/prompts";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { styleText } from "node:util";
|
||||
import { etDate } from "../api/src/srs.ts";
|
||||
import { fetchDescription, problemDb, readmeSlugs } from "./db";
|
||||
import { ROOT, WINDOWS, bucketDir, reviewQueues, scanBucket, type Bucket } from "./work";
|
||||
|
||||
const db = problemDb();
|
||||
|
||||
@@ -57,26 +62,110 @@ if (missing.length > 0) {
|
||||
s.stop(failed ? `Cached ${fetched} descriptions (${failed} failed, will retry next run)` : `Cached ${fetched} problem descriptions`);
|
||||
}
|
||||
|
||||
// ── exclude problems already picked or solved under work/ ────────
|
||||
// ── the three sections ───────────────────────────────────────────
|
||||
|
||||
const done = new Set<number>();
|
||||
for (const rel of new Bun.Glob("**/*.*").scanSync({ cwd: WORK_DIR })) {
|
||||
const m = basename(rel).match(/^(\d+)\./);
|
||||
if (m) done.add(Number(m[1]));
|
||||
/** What Enter scaffolds: this problem, into this bucket. */
|
||||
interface Choice {
|
||||
bucket: Bucket;
|
||||
lc: number;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const problems = db.all().filter((p) => !done.has(p.id));
|
||||
interface Section {
|
||||
label: string;
|
||||
options: { value: Choice; label: string; hint: string }[];
|
||||
}
|
||||
|
||||
const first = scanBucket(1);
|
||||
const problems = db.all();
|
||||
const titles = new Map(problems.map((p) => [p.id, p.title] as const));
|
||||
db.close();
|
||||
|
||||
const picked = await autocomplete<Problem>({
|
||||
message: "Pick a problem",
|
||||
placeholder: "Type to search by number or title...",
|
||||
maxItems: 12,
|
||||
options: problems.map((p) => ({
|
||||
value: p,
|
||||
label: `${p.id}. ${p.title}`,
|
||||
hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty,
|
||||
const queues = reviewQueues(etDate(new Date()));
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
label: "new",
|
||||
options: problems
|
||||
.filter((p) => !first.has(p.id))
|
||||
.map((p) => ({
|
||||
value: { bucket: 1 as Bucket, lc: p.id, slug: p.slug },
|
||||
label: `${p.id}. ${p.title}`,
|
||||
hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty,
|
||||
})),
|
||||
},
|
||||
...WINDOWS.map((window) => ({
|
||||
label: `${window}-day`,
|
||||
options: queues.get(window)!.map((owed) => ({
|
||||
value: { bucket: window as Bucket, lc: owed.lc, slug: owed.slug },
|
||||
label: `${owed.lc}. ${titles.get(owed.lc) ?? owed.slug}`,
|
||||
hint: `${owed.difficulty} · ${owed.category} · solved ${owed.solved}, ${owed.days}d ago`,
|
||||
})),
|
||||
})),
|
||||
];
|
||||
|
||||
let active = 0;
|
||||
|
||||
/**
|
||||
* The slice of clack's AutocompletePrompt this picker drives. @clack/prompts
|
||||
* re-exports neither the class nor its type, and apps/cli does not depend on
|
||||
* @clack/core directly, so the contract is spelled out structurally.
|
||||
*/
|
||||
interface Autocomplete {
|
||||
userInput: string;
|
||||
on(event: "key", cb: (char: string | undefined, key: { name?: string }) => void): void;
|
||||
emit(event: "userInput", input: string): void;
|
||||
}
|
||||
|
||||
/** Search text that cannot match any option — see refresh() below. */
|
||||
const NO_MATCH = "\u0000";
|
||||
|
||||
let hooked = false;
|
||||
|
||||
/**
|
||||
* Bind Tab to the section switch, once per prompt.
|
||||
*
|
||||
* clack has no hook for extra keys, but it calls `options` with the prompt as
|
||||
* `this`, which is all a subscription needs. Switching sections also has to
|
||||
* re-filter the list, and the prompt only recomputes `filteredOptions` when
|
||||
* the search text *changes* — hence the pair of userInput events: an
|
||||
* unmatchable string, then the real one, which recomputes against the new
|
||||
* section, resets the cursor onto its first match and leaves the typed query
|
||||
* on screen untouched. clack repaints as soon as the key handlers return.
|
||||
*
|
||||
* No `placeholder` is passed to the prompt on purpose: clack's own Tab binding
|
||||
* (fill the input with the placeholder) is inert without one.
|
||||
*/
|
||||
function hookTab(prompt: Autocomplete): void {
|
||||
if (hooked) return;
|
||||
hooked = true;
|
||||
prompt.on("key", (_char, key) => {
|
||||
if (key.name !== "tab") return;
|
||||
active = (active + 1) % sections.length;
|
||||
prompt.emit("userInput", NO_MATCH);
|
||||
prompt.emit("userInput", prompt.userInput);
|
||||
});
|
||||
}
|
||||
|
||||
// Widened, not asserted: clack resolves `undefined` when Enter lands on an
|
||||
// empty list, which its own types do not admit.
|
||||
const picked: Choice | symbol | undefined = await autocomplete<Choice>({
|
||||
// Getter, not a string: clack reads `message` on every repaint, so the tab
|
||||
// bar tracks the active section.
|
||||
get message(): string {
|
||||
const bar = sections
|
||||
.map((section, i) => {
|
||||
const text = `${section.label} (${section.options.length})`;
|
||||
return i === active ? styleText("cyan", `▸ ${text}`) : styleText("dim", text);
|
||||
})
|
||||
.join(styleText("dim", " · "));
|
||||
return `Pick a problem ${bar} ${styleText("dim", "Tab: next section")}`;
|
||||
},
|
||||
maxItems: 12,
|
||||
options(this: Autocomplete) {
|
||||
hookTab(this);
|
||||
return sections[active]!.options;
|
||||
},
|
||||
filter: (search, option) => {
|
||||
const haystack = option.label!.toLowerCase();
|
||||
return search
|
||||
@@ -90,10 +179,77 @@ if (isCancel(picked)) {
|
||||
cancel("Nothing picked.");
|
||||
process.exit(0);
|
||||
}
|
||||
if (picked === undefined) {
|
||||
// Enter on an empty section: nothing was focused, so there is nothing to do.
|
||||
cancel(`Nothing owed in the ${sections[active]!.label} section.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const proc = Bun.spawn(
|
||||
// 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);
|
||||
+68
-12
@@ -1,18 +1,67 @@
|
||||
/**
|
||||
* Shared work/ solution picker for the test and submit entries: fuzzy-select
|
||||
* a solution file, then hand it to leetcode-cli. Library: side-effect-free on
|
||||
* import — nothing scans the filesystem until runOnSolution() is called.
|
||||
* a solution file, then hand it to leetcode-cli.
|
||||
*
|
||||
* Every bucket is listed — a 3-day or 7-day re-solve is tested and submitted
|
||||
* exactly like a first solve — and the hint says which one a file belongs to.
|
||||
* The db's per-solution timestamps are keyed by LC number, so the buckets of
|
||||
* one problem share them.
|
||||
*
|
||||
* Every run backfills the db's solutions table with created_at (from the
|
||||
* file's birthtime, mtime when the filesystem has none) so ordering works for
|
||||
* files that predate the db. 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 { join, relative } from "node:path";
|
||||
import { statSync } from "node:fs";
|
||||
import { basename, join, relative } from "node:path";
|
||||
import { problemDb } from "./db";
|
||||
import { ROOT, WORK as WORK_DIR, parseWorkPath } from "./work";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..", "..");
|
||||
const WORK_DIR = join(ROOT, "work");
|
||||
/** Leading LC number of a work/ file, or undefined for unnumbered names. */
|
||||
function lcNumber(rel: string): number | undefined {
|
||||
const m = basename(rel).match(/^(\d+)\./);
|
||||
return m ? Number(m[1]) : undefined;
|
||||
}
|
||||
|
||||
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
|
||||
export async function runOnSolution(command: "test" | "submit", message: string): Promise<never> {
|
||||
const files = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })]
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
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)}/`);
|
||||
@@ -24,12 +73,12 @@ export async function runOnSolution(command: "test" | "submit", message: string)
|
||||
placeholder: "Type to search...",
|
||||
maxItems: 12,
|
||||
options: files.map((f) => {
|
||||
// work layout: Difficulty/Category/<id>.<slug>.<ext>
|
||||
const [difficulty, category, name] = f.split("/");
|
||||
const parsed = parseWorkPath(f);
|
||||
const stage = parsed?.bucket === 1 ? "first solve" : `${parsed?.bucket}-day review`;
|
||||
return {
|
||||
value: f,
|
||||
label: name ?? f,
|
||||
hint: category ? `${difficulty} · ${category}` : difficulty,
|
||||
label: basename(f),
|
||||
hint: parsed ? `${stage} · ${parsed.difficulty} · ${parsed.category}` : f,
|
||||
};
|
||||
}),
|
||||
filter: (search, option) => {
|
||||
@@ -55,5 +104,12 @@ export async function runOnSolution(command: "test" | "submit", message: string)
|
||||
],
|
||||
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
|
||||
);
|
||||
process.exit(await proc.exited);
|
||||
const code = await proc.exited;
|
||||
const num = lcNumber(picked);
|
||||
if (code === 0 && num !== undefined) {
|
||||
if (command === "test") db.markTested(num);
|
||||
else db.markSubmitted(num);
|
||||
}
|
||||
db.close();
|
||||
process.exit(code);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Is a work/ file a real solution or just a scaffolded stub?
|
||||
*
|
||||
* `bun run pick` writes a statement header plus an empty function body, so
|
||||
* file existence alone means nothing: close-solved.ts must not close an issue
|
||||
* on a stub, and work.ts must not ask for the spaced-repetition re-solve of a
|
||||
* problem that was never solved in the first place.
|
||||
*
|
||||
* sync.ts keeps its own copy of the header split — it runs its whole pipeline
|
||||
* on import, so it cannot be imported from. Don't "deduplicate" that one.
|
||||
*
|
||||
* Library: side-effect-free on import.
|
||||
*/
|
||||
|
||||
/** Placeholder bodies that leetcode-cli / a human leaves behind. */
|
||||
const PLACEHOLDERS: Record<string, true> = { pass: true, "...": true, TODO: true };
|
||||
|
||||
/**
|
||||
* Decide whether a work/ file contains an implementation.
|
||||
*
|
||||
* The header comment is dropped the same way sync.ts splitSource() does it.
|
||||
* Comments must go before any brace analysis: the scaffold's JSDoc carries
|
||||
* `@param {number[]}`, whose braces would otherwise read as a body.
|
||||
*
|
||||
* A brace-language file counts as implemented when at least one *innermost*
|
||||
* brace pair holds real content. That distinguishes a bare stub
|
||||
* (`function(nums) {}`) and a class-shaped design stub (every method body
|
||||
* empty) from any genuine solution, whose innermost block always has code.
|
||||
* Every non-Python language the campaign has ever used is brace-shaped, so
|
||||
* "not .py" is the only branch that matters.
|
||||
*/
|
||||
export function isImplemented(src: string, lang: "js" | "py"): boolean {
|
||||
if (lang === "py") {
|
||||
const open = src.indexOf('"""');
|
||||
const close = src.indexOf('"""', open + 3);
|
||||
const code = open === -1 || close === -1 ? src : src.slice(close + 3);
|
||||
for (const raw of code.split("\n")) {
|
||||
const line = raw.replace(/#.*$/, "").trim();
|
||||
if (!line || PLACEHOLDERS[line]) continue;
|
||||
if (/^(?:@|def\s|class\s)/.test(line)) continue;
|
||||
return true; // a statement inside some def body
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const headerEnd = src.indexOf("*/");
|
||||
const body = headerEnd === -1 ? src : src.slice(headerEnd + 2);
|
||||
const code = body.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/.*$/gm, " ");
|
||||
|
||||
let innermost = -1;
|
||||
for (let i = 0; i < code.length; i++) {
|
||||
if (code[i] === "{") {
|
||||
innermost = i;
|
||||
} else if (code[i] === "}" && innermost !== -1) {
|
||||
const inner = code.slice(innermost + 1, i).replace(/[\s;]/g, "");
|
||||
if (inner && !PLACEHOLDERS[inner]) return true;
|
||||
innermost = -1; // measured; the enclosing pair is not innermost
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Executable
+384
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Open today's spaced-repetition issue: whatever the SRS ladder has due,
|
||||
* linked straight to LeetCode.
|
||||
*
|
||||
* D1 is the schedule and this script is a renderer. `POST /admin/due` returns
|
||||
* exactly the rows the 8 AM digest mails — reviews standing at the `+3` and
|
||||
* `+7` rungs, oldest first — so the issue and the email can never disagree
|
||||
* about a day, and there is no second scheduler to drift. The rung is also
|
||||
* the `work/<n>` bucket the blind re-solve belongs in, which is the section
|
||||
* `bun run pick` opens on Tab.
|
||||
*
|
||||
* How a problem gets here: closing its issue (a push, a digest tap, a `/done`
|
||||
* comment) enters it on the ladder at `+3` with a concrete next_review, and
|
||||
* passing that review moves it to `+7`. Nothing is "activated" by this script
|
||||
* — it asks what is standing today and writes it down. A day's issue is keyed
|
||||
* by its title (`Spaced Repetition — <Month D, YYYY>`), so re-runs rewrite
|
||||
* that one body instead of stacking duplicates.
|
||||
*
|
||||
* Sunday writes nothing: the ladder never schedules the rest day (WINDOWS of
|
||||
* 3 and 7 exist for exactly that reason, see apps/api/src/srs.ts), so there is
|
||||
* nothing to render. Each rung shows at most WINDOW_CAP problems, with the
|
||||
* overflow listed as optional — the digest's leveller moves that overflow to
|
||||
* later working days the next morning.
|
||||
*
|
||||
* Links: D1 stores titles, not URLs, so each due problem's issue body is read
|
||||
* for the canonical LeetCode link. A body with no URL falls back to a slug
|
||||
* guessed from the title and is flagged in the report, because repo titles are
|
||||
* sometimes abbreviated ("LC 167 · Two Sum II") and a guess can 404.
|
||||
*
|
||||
* bun apps/cli/spaced-repetition.ts # create/update today's issue
|
||||
* bun apps/cli/spaced-repetition.ts --dry-run # print the body, touch nothing
|
||||
* bun apps/cli/spaced-repetition.ts --date=2026-09-02 # pretend it is that ET day
|
||||
*
|
||||
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
||||
* SRS_ADMIN_KEY reads the due list; SRS_API overrides the Worker URL for
|
||||
* local `wrangler dev` runs.
|
||||
*/
|
||||
import { WINDOWS, etDate, isRestDay } from "../api/src/srs.ts";
|
||||
import { github } from "./github.ts";
|
||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
|
||||
const DRY =
|
||||
process.argv.includes("--dry-run") ||
|
||||
process.env.DRY_RUN === "1" ||
|
||||
process.env.DRY_RUN === "true";
|
||||
|
||||
/**
|
||||
* Required re-solves per window: 3 + 3 on top of the day's 3 new core problems
|
||||
* is already a 90-minute session. A window that closed more than this (the
|
||||
* catalog backfill closed 19 issues in one day) keeps the rest listed under a
|
||||
* collapsed "optional" block rather than pretending they were never due.
|
||||
*/
|
||||
const WINDOW_CAP = 3;
|
||||
|
||||
const LABEL = "spaced-repetition";
|
||||
|
||||
// ── ET calendar dates ────────────────────────────────────────────
|
||||
|
||||
// Every date here is an ET calendar string (YYYY-MM-DD): the campaign runs on
|
||||
// ET days, and an issue closed at 21:30 ET belongs to that ET day, not to the
|
||||
// next UTC one. The helpers come from the Worker's SRS domain so this script,
|
||||
// the picker and D1 cannot disagree about a date, a window, or the rest day.
|
||||
|
||||
const LONG = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "UTC",
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
/** `2026-08-26` -> `August 26, 2026`, or with the weekday prefix. */
|
||||
function longDate(date: string, weekday = false): string {
|
||||
// Noon-UTC anchor, like every date in srs.ts: no DST edge can move the day.
|
||||
const text = LONG.format(new Date(`${date}T12:00:00Z`));
|
||||
return weekday ? text : text.slice(text.indexOf(", ") + 2);
|
||||
}
|
||||
|
||||
// ── today ────────────────────────────────────────────────────────
|
||||
|
||||
// `||`, not `??`: the workflow passes SRS_TODAY="" on scheduled runs, where the
|
||||
// input has no value.
|
||||
const override = process.argv.find((a) => a.startsWith("--date="))?.slice(7) || process.env.SRS_TODAY;
|
||||
if (override && !/^\d{4}-\d{2}-\d{2}$/.test(override)) {
|
||||
throw new Error(`--date wants YYYY-MM-DD, got ${override}`);
|
||||
}
|
||||
const today = override || etDate(new Date());
|
||||
|
||||
// Sunday is the campaign's rest day and the reason the windows are 3 and 7:
|
||||
// nothing is ever booked on it, so there is no issue to write. The cron still
|
||||
// fires — the guard lives here, not in the workflow, so a manual run agrees.
|
||||
if (isRestDay(today)) {
|
||||
console.log(`${today} is the rest day — no spaced-repetition issue.`);
|
||||
await writeStepSummary(`### Spaced Repetition — ${longDate(today)}\n\nRest day: nothing scheduled.\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── what is due today (D1, via the Worker) ───────────────────────
|
||||
|
||||
// The ladder is the schedule: `POST /admin/due` returns exactly the rows the
|
||||
// morning digest mails, so the issue and the email can never disagree about a
|
||||
// day. This script adds no scheduling of its own — it renders, caps, and
|
||||
// links.
|
||||
const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
||||
|
||||
/** One problem the ladder wants re-solved today, as the Worker reports it. */
|
||||
interface Due {
|
||||
lc: number;
|
||||
issue: number;
|
||||
title: string;
|
||||
difficulty: string;
|
||||
/** The rung being settled: "+3" or "+7". */
|
||||
stage: string;
|
||||
/** The rung's number — also the `work/<n>` bucket the re-solve belongs in. */
|
||||
bucket: number;
|
||||
due: string;
|
||||
late: number;
|
||||
last_seen: string | null;
|
||||
days_since: number | null;
|
||||
}
|
||||
|
||||
function readDue(value: unknown): Due | undefined {
|
||||
if (!value || typeof value !== "object") return;
|
||||
const num = (key: string): number | undefined =>
|
||||
key in value && typeof (value as Record<string, unknown>)[key] === "number"
|
||||
? ((value as Record<string, unknown>)[key] as number)
|
||||
: undefined;
|
||||
const str = (key: string): string | undefined =>
|
||||
key in value && typeof (value as Record<string, unknown>)[key] === "string"
|
||||
? ((value as Record<string, unknown>)[key] as string)
|
||||
: undefined;
|
||||
const lc = num("lc");
|
||||
const issue = num("issue");
|
||||
const bucket = num("bucket");
|
||||
const stage = str("stage");
|
||||
const title = str("title");
|
||||
const dueOn = str("due");
|
||||
if (lc === undefined || issue === undefined || bucket === undefined) return;
|
||||
if (stage === undefined || title === undefined || dueOn === undefined) return;
|
||||
const difficulty = str("difficulty") ?? "unrated";
|
||||
return {
|
||||
lc,
|
||||
issue,
|
||||
title,
|
||||
difficulty: difficulty.charAt(0).toUpperCase() + difficulty.slice(1),
|
||||
stage,
|
||||
bucket,
|
||||
due: dueOn,
|
||||
late: num("late") ?? 0,
|
||||
last_seen: str("last_seen") ?? null,
|
||||
days_since: num("days_since") ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!process.env.SRS_ADMIN_KEY) {
|
||||
// Failing loudly beats writing an issue from a second, guessed schedule:
|
||||
// there is only one schedule now, and it lives in D1.
|
||||
console.error("SRS_ADMIN_KEY unset — cannot read today's due list from the Worker.");
|
||||
process.exit(1);
|
||||
}
|
||||
const res = await fetch(`${SRS_API}/admin/due?date=${today}`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${process.env.SRS_ADMIN_KEY}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`GET due list -> ${res.status} ${await res.text()}`);
|
||||
const payload: unknown = await res.json();
|
||||
const dueRaw: unknown = payload && typeof payload === "object" && "due" in payload ? payload.due : null;
|
||||
const due: Due[] = (Array.isArray(dueRaw) ? dueRaw : []).flatMap((r) => {
|
||||
const row = readDue(r);
|
||||
return row ? [row] : [];
|
||||
});
|
||||
|
||||
// ── repo + auth ──────────────────────────────────────────────────
|
||||
|
||||
const gh = await github();
|
||||
|
||||
// ── canonical LeetCode links ─────────────────────────────────────
|
||||
|
||||
const LC_URL = /https:\/\/leetcode\.com\/problems\/[a-z0-9-]+/;
|
||||
|
||||
/** LeetCode's own slug shape, for bodies that lost their URL line. */
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* D1 stores titles, not URLs, so each due problem's issue body is read for the
|
||||
* canonical LeetCode link written by the campaign-issues convention. That is
|
||||
* one request per due problem — at most REVIEW_CAP per rung — instead of the
|
||||
* old walk over every closed issue in the repo. A body with no URL falls back
|
||||
* to a slug guessed from the title and is flagged in the report, because repo
|
||||
* titles are sometimes abbreviated ("LC 167 · Two Sum II") and a guess can 404.
|
||||
*/
|
||||
interface Linked extends Due {
|
||||
url: string;
|
||||
linked: boolean;
|
||||
}
|
||||
|
||||
async function link(row: Due): Promise<Linked> {
|
||||
let url: string | undefined;
|
||||
try {
|
||||
const issue: unknown = await gh.api(`/repos/${gh.repo}/issues/${row.issue}`);
|
||||
const body =
|
||||
issue && typeof issue === "object" && "body" in issue && typeof issue.body === "string"
|
||||
? issue.body
|
||||
: "";
|
||||
url = LC_URL.exec(body)?.[0];
|
||||
} catch (err) {
|
||||
console.warn(`#${row.issue}: could not read the issue body (${err})`);
|
||||
}
|
||||
return {
|
||||
...row,
|
||||
url: `${url ?? `https://leetcode.com/problems/${slugify(row.title)}`}/`,
|
||||
linked: url !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ── issue body ───────────────────────────────────────────────────
|
||||
|
||||
const title = `Spaced Repetition — ${longDate(today)}`;
|
||||
|
||||
/** The window's due problems, capped, oldest scheduled first. */
|
||||
const byWindow = new Map<number, Linked[]>();
|
||||
for (const days of WINDOWS) {
|
||||
const rows = due.filter((d) => d.bucket === days);
|
||||
byWindow.set(days, await Promise.all(rows.map(link)));
|
||||
}
|
||||
|
||||
function bucket(days: number): Linked[] {
|
||||
return byWindow.get(days) ?? [];
|
||||
}
|
||||
|
||||
/** One checkbox line, straight to LeetCode. */
|
||||
function line(s: Linked, box: boolean): string {
|
||||
const seen = s.days_since === null ? "unlogged" : `last seen ${s.days_since}d ago`;
|
||||
return (
|
||||
`${box ? "- [ ] " : "- "}[LC ${s.lc} · ${s.title}](${s.url}) · ${s.difficulty} · #${s.issue}` +
|
||||
` · ${seen}${s.late > 0 ? ` · ⏰ ${s.late}d late` : ""}` +
|
||||
(s.linked ? "" : " · ⚠️ link guessed from title")
|
||||
);
|
||||
}
|
||||
|
||||
const sections = WINDOWS.map((days) => {
|
||||
const rows = bucket(days);
|
||||
const required = rows.slice(0, WINDOW_CAP);
|
||||
const spill = rows.slice(WINDOW_CAP);
|
||||
return [
|
||||
`### +${days} days — re-solve into \`work/${days}/\``,
|
||||
"",
|
||||
...(required.length
|
||||
? required.map((s) => line(s, true))
|
||||
: [`_Nothing at the +${days} rung today._`]),
|
||||
// Overflow is shown, not dropped: the ladder levels everything past the
|
||||
// cap onto later working days, but until that morning's digest runs this
|
||||
// issue is the only record of what was standing.
|
||||
...(spill.length
|
||||
? [
|
||||
"",
|
||||
`<details><summary>${spill.length} more at this rung — optional</summary>`,
|
||||
"",
|
||||
...spill.map((s) => line(s, false)),
|
||||
"",
|
||||
"</details>",
|
||||
]
|
||||
: []),
|
||||
"",
|
||||
].join("\n");
|
||||
});
|
||||
|
||||
const total = WINDOWS.reduce((n, days) => n + Math.min(bucket(days).length, WINDOW_CAP), 0);
|
||||
|
||||
const body = [
|
||||
`Re-solve from memory, ${longDate(today, true)}. Blind: no notes, no \`work/\` file, no editor history.`,
|
||||
"",
|
||||
"- 15 minutes per problem, then stop and read your own solution.",
|
||||
"- Say the invariant and the complexity out loud before typing.",
|
||||
"- Failed one? Tick it anyway and expect it back at +3.",
|
||||
"- `bun run pick` → Tab to this rung's section scaffolds the blind re-solve.",
|
||||
"",
|
||||
...sections,
|
||||
"<sub>Today's due list straight from the SRS ladder in D1 (`POST /admin/due`), rendered by " +
|
||||
"`.github/workflows/spaced-repetition.yml` — re-runs on the same day rewrite this body. " +
|
||||
"The ladder never schedules a Sunday, so there is no issue on the rest day.</sub>",
|
||||
].join("\n");
|
||||
|
||||
// ── create or refresh today's issue ──────────────────────────────
|
||||
|
||||
interface Existing {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function readExisting(value: unknown): Existing | undefined {
|
||||
if (!value || typeof value !== "object") return;
|
||||
if ("pull_request" in value) return;
|
||||
if (!("number" in value) || typeof value.number !== "number") return;
|
||||
if (!("title" in value) || typeof value.title !== "string") return;
|
||||
const text = "body" in value && typeof value.body === "string" ? value.body : "";
|
||||
return { number: value.number, title: value.title, body: text };
|
||||
}
|
||||
|
||||
// state=all: a day's issue that was already closed must not be recreated.
|
||||
let existing: Existing | undefined;
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=${LABEL}&state=all`)) {
|
||||
const row = readExisting(raw);
|
||||
if (row?.title === title) {
|
||||
existing = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let status: string;
|
||||
if (total === 0) {
|
||||
status = existing
|
||||
? `nothing due — left #${existing.number} alone`
|
||||
: "nothing due — no issue created";
|
||||
} else if (existing && existing.body.trim() === body.trim()) {
|
||||
status = `#${existing.number} already current`;
|
||||
} else if (DRY) {
|
||||
status = existing ? `would update #${existing.number}` : "would create";
|
||||
} else if (existing) {
|
||||
await gh.api(`/repos/${gh.repo}/issues/${existing.number}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
status = `updated #${existing.number}`;
|
||||
} else {
|
||||
// Ensure the label exists before it is used (422 = someone already made it).
|
||||
try {
|
||||
await gh.api(`/repos/${gh.repo}/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: LABEL,
|
||||
color: "0E8A16",
|
||||
description: "Daily blind re-solve of whatever the SRS ladder has due",
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!String(err).includes("422")) throw err;
|
||||
}
|
||||
|
||||
const created = (await gh.api(`/repos/${gh.repo}/issues`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, body, labels: [LABEL] }),
|
||||
})) as { number: number };
|
||||
status = `created #${created.number}`;
|
||||
}
|
||||
|
||||
// ── report ───────────────────────────────────────────────────────
|
||||
|
||||
const report = WINDOWS.flatMap((days) =>
|
||||
bucket(days).map((s, i) => [
|
||||
`+${days}d`,
|
||||
String(s.lc),
|
||||
`#${s.issue}`,
|
||||
s.difficulty,
|
||||
s.due + (s.late > 0 ? ` (${s.late}d late)` : ""),
|
||||
i < WINDOW_CAP ? "due" : "optional",
|
||||
s.linked ? s.url : `${s.url} (inferred)`,
|
||||
]),
|
||||
);
|
||||
const rows = [["rung", "lc", "issue", "diff", "scheduled", "state", "leetcode"], ...report];
|
||||
if (report.length) printTable(rows);
|
||||
|
||||
console.log(
|
||||
`\n${title}${DRY ? " (dry run)" : ""}\n` +
|
||||
WINDOWS.map((days) => {
|
||||
const n = bucket(days).length;
|
||||
return `+${days}d: ${Math.min(n, WINDOW_CAP)} due${n > WINDOW_CAP ? ` (+${n - WINDOW_CAP} optional)` : ""}`;
|
||||
}).join(" · ") +
|
||||
`\n${status}`,
|
||||
);
|
||||
if (DRY) console.log(`\n${body}`);
|
||||
|
||||
await writeStepSummary(
|
||||
`### ${title}${DRY ? " (dry run)" : ""}\n\n` +
|
||||
`${total} problem${total === 1 ? "" : "s"} due · ${status}\n\n` +
|
||||
(report.length ? `${markdownTable(rows)}\n` : "The ladder has nothing due today.\n"),
|
||||
);
|
||||
Executable
+458
@@ -0,0 +1,458 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* `bun run stats` — the campaign dashboard, in the terminal.
|
||||
*
|
||||
* Four panes over ONE source: GET /api/stats. D1 owns SRS state, so this
|
||||
* fetches the Worker's aggregate rather than recomputing anything from work/
|
||||
* or from git — a second answer to "how far along am I" is exactly the drift
|
||||
* the campaign is built to avoid. The one local fact shown is the work/ file
|
||||
* inventory, which is inventory, not schedule. Unreachable Worker = an error,
|
||||
* never a reconstructed guess.
|
||||
*
|
||||
* The panes:
|
||||
* Overview campaign clock, solved counts, phase bars, recent activity
|
||||
* Ladder the +3/+7 rungs and the 14-day due queue
|
||||
* Concepts every topic by temperature — what has gone cold
|
||||
* Activity the campaign heatmap and the weekly gates
|
||||
*
|
||||
* Outside a TTY (a pipe, CI) it prints every pane once and exits, so the same
|
||||
* command works in a scrollback or a file.
|
||||
*/
|
||||
import {
|
||||
fetchStats,
|
||||
SRS_API,
|
||||
STAGES,
|
||||
TEMPERATURES,
|
||||
type Stats,
|
||||
type Temperature,
|
||||
type Topic,
|
||||
} from "./api.ts";
|
||||
import {
|
||||
BLUE,
|
||||
FG,
|
||||
GREEN,
|
||||
GREENS,
|
||||
LINE,
|
||||
MUTED,
|
||||
RED,
|
||||
YELLOW,
|
||||
bar,
|
||||
bold,
|
||||
cell,
|
||||
fg,
|
||||
heatCell,
|
||||
openScreen,
|
||||
padTo,
|
||||
rule,
|
||||
sparkline,
|
||||
stackedBar,
|
||||
width,
|
||||
} from "./tui.ts";
|
||||
import { BUCKETS, WINDOWS, solvedInBucket } from "./work.ts";
|
||||
|
||||
// ── shared vocabulary → colour ───────────────────────────────────
|
||||
|
||||
// Temperature is an action ramp, not a thermometer: green is warm enough to
|
||||
// leave alone, red is the concept that wants revisiting. The band names carry
|
||||
// the heat, the colours carry the urgency — and the glyphs carry both again,
|
||||
// so a piped or NO_COLOR dashboard still separates the bands.
|
||||
const TEMP_COLOR: Record<Temperature, string> = {
|
||||
hot: GREENS[4]!,
|
||||
fresh: GREEN,
|
||||
fading: YELLOW,
|
||||
cold: RED,
|
||||
};
|
||||
|
||||
const TEMP_GLYPH: Record<Temperature, string> = {
|
||||
hot: "█",
|
||||
fresh: "▓",
|
||||
fading: "▒",
|
||||
cold: "░",
|
||||
};
|
||||
|
||||
const DIFF_COLOR: Record<string, string> = { easy: GREEN, medium: YELLOW, hard: RED };
|
||||
|
||||
const LABEL = 15; // left-hand label column, shared by every pane
|
||||
|
||||
/** "2026-08-31" → "Mon 31", read as a plain calendar date, never localised. */
|
||||
function dayLabel(iso: string): string {
|
||||
const [y, m, d] = iso.split("-").map(Number);
|
||||
const weekday = new Date(Date.UTC(y!, m! - 1, d!)).getUTCDay();
|
||||
return `${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][weekday]} ${String(d).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// ── overview ─────────────────────────────────────────────────────
|
||||
|
||||
function overview(s: Stats, cols: number): string[] {
|
||||
const bars = Math.max(10, Math.min(48, cols - LABEL - 18));
|
||||
const lines: string[] = [];
|
||||
|
||||
const { day, days, week } = s.campaign;
|
||||
lines.push(rule("campaign", cols));
|
||||
lines.push(
|
||||
`${cell("Day", LABEL)}${bar(day, days, bars)} ${fg(FG, `${day}/${days}`)}` +
|
||||
fg(MUTED, ` week ${week}`),
|
||||
);
|
||||
const streak = s.streak > 0 ? fg(GREENS[4]!, `${s.streak} day${s.streak === 1 ? "" : "s"}`) : fg(MUTED, "none");
|
||||
lines.push(`${cell("Streak", LABEL)}${streak}`);
|
||||
|
||||
const total = s.difficulty.reduce((n, d) => n + d.total, 0);
|
||||
const done = s.difficulty.reduce((n, d) => n + d.done, 0);
|
||||
lines.push("");
|
||||
lines.push(rule("solved", cols));
|
||||
lines.push(
|
||||
`${cell("All", LABEL)}${bar(done, total, bars)} ${fg(FG, `${done}/${total}`)}` +
|
||||
fg(MUTED, ` ${total > 0 ? Math.round((done / total) * 100) : 0}%`),
|
||||
);
|
||||
for (const d of s.difficulty) {
|
||||
lines.push(
|
||||
`${cell(d.difficulty, LABEL)}${bar(d.done, d.total, bars, DIFF_COLOR[d.difficulty] ?? GREEN)}` +
|
||||
` ${fg(MUTED, `${d.done}/${d.total}`)}`,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(rule("phases", cols));
|
||||
lines.push(
|
||||
`${" ".repeat(LABEL)}${fg(GREEN, "█")} ${fg(MUTED, "core")} ${fg(BLUE, "█")} ${fg(MUTED, "optional")} ` +
|
||||
`${fg(RED, "█")} ${fg(MUTED, "deferred")} ${fg(LINE, "░")} ${fg(MUTED, "remaining")}`,
|
||||
);
|
||||
// Every phase bar is scaled to the LARGEST phase, not to itself: phases are
|
||||
// not the same size, and equal-length bars would hide that.
|
||||
const scale = Math.max(
|
||||
1,
|
||||
...s.phases.map((p) => p.core_total + p.optional_total + p.deferred_total),
|
||||
);
|
||||
for (const p of s.phases) {
|
||||
const phaseDone = p.core_done + p.optional_done + p.deferred_done;
|
||||
const phaseTotal = p.core_total + p.optional_total + p.deferred_total;
|
||||
const segments = [
|
||||
{ value: p.core_done, color: GREEN },
|
||||
{ value: p.optional_done, color: BLUE },
|
||||
{ value: p.deferred_done, color: RED },
|
||||
{ value: phaseTotal - phaseDone, color: LINE, glyph: "░" },
|
||||
];
|
||||
lines.push(
|
||||
`${cell(p.name, LABEL - 1)} ${stackedBar(segments, scale, bars)} ${fg(MUTED, `${phaseDone}/${phaseTotal}`)}`,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(rule("last 14 days", cols));
|
||||
const window = s.heat.slice(Math.max(0, day - 14), day);
|
||||
const attempts = window.reduce((n, h) => n + h.attempts, 0);
|
||||
const passes = window.reduce((n, h) => n + h.passes, 0);
|
||||
lines.push(
|
||||
`${cell("Attempts", LABEL)}${sparkline(window.map((h) => h.attempts))} ` +
|
||||
fg(MUTED, `${attempts} attempts · ${passes} passed`),
|
||||
);
|
||||
|
||||
// The one local fact on the dashboard: solutions on disk, per
|
||||
// spaced-repetition bucket. Inventory, not schedule — D1's ladder is the
|
||||
// schedule, and the Ladder pane is where it is shown. Empty scaffolds do not
|
||||
// count, the same rule close-solved.ts closes issues by.
|
||||
lines.push("");
|
||||
lines.push(rule("work/ on disk", cols));
|
||||
lines.push(
|
||||
cell("Solutions", LABEL) +
|
||||
BUCKETS.map((b) => `${fg(FG, String(solvedInBucket(b)))} ${fg(MUTED, `in work/${b}`)}`).join(
|
||||
fg(MUTED, " · "),
|
||||
),
|
||||
);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── ladder ───────────────────────────────────────────────────────
|
||||
|
||||
function ladder(s: Stats, cols: number): string[] {
|
||||
const bars = Math.max(10, Math.min(48, cols - LABEL - 18));
|
||||
const lines: string[] = [rule("ladder", cols)];
|
||||
lines.push(fg(MUTED, `${" ".repeat(LABEL)}the review each problem owes next`));
|
||||
|
||||
const rungs = STAGES.map((stage) => s.ladder[stage] ?? 0);
|
||||
const peak = Math.max(1, ...rungs);
|
||||
STAGES.forEach((stage, i) => {
|
||||
const n = rungs[i]!;
|
||||
// new is the untouched pile and retired is the finished one; only the
|
||||
// middle rungs are live work, so only they get the live colour.
|
||||
const color = stage === "new" ? LINE : stage === "retired" ? GREENS[4]! : GREEN;
|
||||
lines.push(`${cell(stage, LABEL)}${bar(n, peak, bars, color)} ${fg(FG, String(n))}`);
|
||||
});
|
||||
|
||||
lines.push("");
|
||||
lines.push(rule("due queue", cols));
|
||||
const today = s.queue[0];
|
||||
lines.push(
|
||||
fg(MUTED, `${" ".repeat(LABEL)}cumulative: everything the ladder wants by that day`) +
|
||||
(today ? fg(FG, ` · ${today.due} due today`) : ""),
|
||||
);
|
||||
const depth = Math.max(1, ...s.queue.map((q) => q.due));
|
||||
for (const q of s.queue) {
|
||||
const color = q.due > 3 ? RED : q.due > 0 ? GREEN : LINE;
|
||||
lines.push(
|
||||
`${cell(dayLabel(q.date), LABEL)}${bar(q.due, depth, bars, color)} ${fg(MUTED, String(q.due))}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── concepts ─────────────────────────────────────────────────────
|
||||
|
||||
/** Coldest first, and everything the schedule has not reached yet last. */
|
||||
function byUrgency(a: Topic, b: Topic): number {
|
||||
if (a.taught !== b.taught) return a.taught ? -1 : 1;
|
||||
return (
|
||||
TEMPERATURES.indexOf(b.temperature) - TEMPERATURES.indexOf(a.temperature) ||
|
||||
(b.age ?? Infinity) - (a.age ?? Infinity) ||
|
||||
a.issue - b.issue
|
||||
);
|
||||
}
|
||||
|
||||
// Row layout: "● " + name + temp + coverage bar + done/total + last + due.
|
||||
// Everything but the name is fixed, so the name absorbs the terminal width.
|
||||
const TEMP_W = 7;
|
||||
const COVER_W = 10;
|
||||
const COUNT_W = 6;
|
||||
const AGE_W = 6;
|
||||
const DUE_W = 3;
|
||||
const CONCEPT_FIXED = 3 + (TEMP_W + 1) + (COVER_W + 1) + (COUNT_W + 1) + (AGE_W + 1) + DUE_W + 1;
|
||||
|
||||
function concepts(s: Stats, cols: number): string[] {
|
||||
const lines: string[] = [rule("concepts by temperature", cols)];
|
||||
// Thresholds are the ladder's windows; print them rather than restate them.
|
||||
const bands: [Temperature, string][] = [
|
||||
["hot", `≤${WINDOWS[0]}d`],
|
||||
["fresh", `≤${WINDOWS[1]}d`],
|
||||
["fading", `≤${WINDOWS[1] * 2}d`],
|
||||
["cold", "older, or never"],
|
||||
];
|
||||
lines.push(
|
||||
fg(MUTED, " last exercised — ") +
|
||||
bands
|
||||
.map(([t, when]) => `${fg(TEMP_COLOR[t], `${TEMP_GLYPH[t]} ${t}`)} ${fg(MUTED, when)}`)
|
||||
.join(fg(MUTED, " · ")),
|
||||
);
|
||||
|
||||
const taught = s.topics.filter((t) => t.taught);
|
||||
const counts = TEMPERATURES.map((t) => ({
|
||||
value: taught.filter((x) => x.temperature === t).length,
|
||||
color: TEMP_COLOR[t],
|
||||
glyph: TEMP_GLYPH[t],
|
||||
}));
|
||||
lines.push("");
|
||||
lines.push(` ${stackedBar(counts, Math.max(1, taught.length), Math.max(10, Math.min(60, cols - 4)))}`);
|
||||
lines.push(
|
||||
` ${TEMPERATURES.map((t, i) => `${fg(TEMP_COLOR[t], TEMP_GLYPH[t])} ${counts[i]!.value} ${fg(MUTED, t)}`).join(" ")}` +
|
||||
fg(MUTED, ` of ${taught.length} taught so far`),
|
||||
);
|
||||
|
||||
const nameW = Math.max(12, Math.min(34, cols - CONCEPT_FIXED));
|
||||
lines.push("");
|
||||
lines.push(
|
||||
fg(
|
||||
MUTED,
|
||||
` ${cell("concept", nameW)} ${cell("temp", TEMP_W)} ` +
|
||||
`${cell("coverage", COVER_W + 1 + COUNT_W)} ${cell("last", AGE_W)} ${cell("due", DUE_W, "right")}`,
|
||||
),
|
||||
);
|
||||
|
||||
for (const t of s.topics.slice().sort(byUrgency)) {
|
||||
// An unreached concept has no temperature to report — it shows the week
|
||||
// the schedule gets to it instead, so the list stays one list.
|
||||
const color = t.taught ? TEMP_COLOR[t.temperature] : LINE;
|
||||
const age =
|
||||
!t.taught ? "—" : t.age === null ? "never" : t.age <= 0 ? "today" : `${t.age}d`;
|
||||
lines.push(
|
||||
` ${fg(color, t.taught ? TEMP_GLYPH[t.temperature] : "·")} ${fg(t.taught ? FG : MUTED, cell(t.name, nameW))} ` +
|
||||
`${fg(color, cell(t.taught ? t.temperature : `wk ${t.week ?? "?"}`, TEMP_W))} ` +
|
||||
`${bar(t.done, t.total, COVER_W, t.taught ? GREEN : LINE)} ` +
|
||||
`${fg(MUTED, cell(`${t.done}/${t.total}`, COUNT_W))} ${fg(MUTED, cell(age, AGE_W))} ` +
|
||||
`${t.due > 0 ? fg(GREEN, cell(String(t.due), DUE_W, "right")) : fg(LINE, cell("·", DUE_W, "right"))}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── activity ─────────────────────────────────────────────────────
|
||||
|
||||
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
function activity(s: Stats, cols: number): string[] {
|
||||
const lines: string[] = [rule("attempts per campaign day", cols)];
|
||||
lines.push(fg(MUTED, " the README heatmap, same ramp — Sunday is the rest day"));
|
||||
lines.push("");
|
||||
|
||||
// CAMPAIGN_START is a Monday, so the grid is simply the series in weeks of
|
||||
// seven: column = week, row = weekday, exactly as the SVG lays it out.
|
||||
const weeks = Math.ceil(s.heat.length / 7);
|
||||
lines.push(
|
||||
` ${fg(MUTED, Array.from({ length: weeks }, (_, w) => ` ${String(w + 1).padStart(2)}`).join(""))}`,
|
||||
);
|
||||
WEEKDAYS.forEach((name, row) => {
|
||||
const cells = Array.from({ length: weeks }, (_, w) => {
|
||||
const entry = s.heat[w * 7 + row];
|
||||
if (entry === undefined) return " ";
|
||||
// The marker sits in the gutter, not inside the cell: a foreground
|
||||
// escape within a background run would end the background early.
|
||||
const marker = s.campaign.day - 1 === w * 7 + row ? fg(FG, "▸") : " ";
|
||||
return marker + heatCell(entry.attempts, " ");
|
||||
}).join("");
|
||||
lines.push(` ${fg(MUTED, cell(name, 4))}${cells}`);
|
||||
});
|
||||
lines.push("");
|
||||
lines.push(
|
||||
` ${fg(MUTED, "less ")}${GREENS.map((_, level) => heatCell(level, " ")).join("")}${fg(MUTED, " more")}` +
|
||||
fg(MUTED, " · one cell per day, colour = attempts, ▸ marks today"),
|
||||
);
|
||||
|
||||
lines.push("");
|
||||
lines.push(rule("weekly gates", cols));
|
||||
if (s.gates.length === 0) {
|
||||
lines.push(fg(MUTED, " no gate has been scored yet"));
|
||||
}
|
||||
const gateW = Math.max(10, Math.min(24, cols - 30));
|
||||
for (const g of s.gates) {
|
||||
// Gates were stored as an integer percent before they were stored as a
|
||||
// fraction; both are in the table, so normalise on read.
|
||||
const rate = g.pass_rate === null ? null : g.pass_rate <= 1 ? g.pass_rate : g.pass_rate / 100;
|
||||
const color = rate === null ? LINE : rate >= 0.7 ? GREEN : RED;
|
||||
lines.push(
|
||||
` ${fg(FG, cell(`Week ${g.week}`, 9))}` +
|
||||
`${bar(rate ?? 0, 1, gateW, color)}` +
|
||||
` ${fg(color, cell(rate === null ? "open" : `${Math.round(rate * 100)}%`, 5))}` +
|
||||
` ${fg(MUTED, g.issue === null ? "" : `#${g.issue}`)}${fg(MUTED, g.closed_on ? ` closed ${g.closed_on}` : "")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── panes ────────────────────────────────────────────────────────
|
||||
|
||||
const PANES: { name: string; render: (s: Stats, cols: number) => string[] }[] = [
|
||||
{ name: "Overview", render: overview },
|
||||
{ name: "Ladder", render: ladder },
|
||||
{ name: "Concepts", render: concepts },
|
||||
{ name: "Activity", render: activity },
|
||||
];
|
||||
|
||||
// ── non-interactive: every pane, once ────────────────────────────
|
||||
|
||||
let stats: Stats;
|
||||
try {
|
||||
stats = await fetchStats();
|
||||
} catch (err) {
|
||||
console.error(`stats: ${err instanceof Error ? err.message : String(err)}`);
|
||||
console.error(`stats: D1 is the only source of SRS truth — nothing to fall back to (SRS_API=${SRS_API}).`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!process.stdout.isTTY) {
|
||||
const cols = 80;
|
||||
for (const pane of PANES) {
|
||||
console.log(bold(pane.name.toUpperCase()));
|
||||
for (const line of pane.render(stats, cols)) console.log(line);
|
||||
console.log("");
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── interactive ──────────────────────────────────────────────────
|
||||
|
||||
let pane = 0;
|
||||
let offset = 0;
|
||||
let note = "";
|
||||
|
||||
// Quitting resolves this; the finally below is what restores the terminal, so
|
||||
// a thrown render never leaves the shell in raw mode on the alternate screen.
|
||||
const { promise: quit, resolve: leave } = Promise.withResolvers<void>();
|
||||
const screen = openScreen(onKey, draw);
|
||||
|
||||
function frame(): string[] {
|
||||
const cols = screen.columns;
|
||||
const tabs = PANES.map((p, i) =>
|
||||
i === pane ? bold(fg(FG, ` ${p.name} `)) : fg(MUTED, ` ${p.name} `),
|
||||
).join(fg(LINE, "│"));
|
||||
const clock = fg(MUTED, `day ${stats.campaign.day}/${stats.campaign.days} · streak ${stats.streak} `);
|
||||
const header = padTo(tabs, Math.max(0, cols - width(clock))) + clock;
|
||||
|
||||
// header + rule + body + footer fills the terminal exactly.
|
||||
const body = PANES[pane]!.render(stats, cols);
|
||||
const height = Math.max(1, screen.rows - 3);
|
||||
offset = Math.max(0, Math.min(offset, Math.max(0, body.length - height)));
|
||||
const scrolled =
|
||||
body.length > height ? ` · ${offset + 1}-${Math.min(offset + height, body.length)}/${body.length}` : "";
|
||||
|
||||
return [
|
||||
header,
|
||||
fg(LINE, "─".repeat(cols)),
|
||||
...body.slice(offset, offset + height),
|
||||
fg(MUTED, `Tab/←→ pane · ↑↓ scroll · r refresh · q quit${scrolled}`) + (note ? ` ${fg(YELLOW, note)}` : ""),
|
||||
];
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
screen.draw(frame());
|
||||
}
|
||||
|
||||
function onKey(chunk: string): void {
|
||||
switch (chunk) {
|
||||
// Raw mode delivers Ctrl-C as a byte, not a signal, so it is handled here.
|
||||
case "q":
|
||||
case "\x03":
|
||||
case "\x1b":
|
||||
leave();
|
||||
return;
|
||||
case "\t":
|
||||
case "\x1b[C":
|
||||
case "l":
|
||||
pane = (pane + 1) % PANES.length;
|
||||
offset = 0;
|
||||
break;
|
||||
case "\x1b[Z":
|
||||
case "\x1b[D":
|
||||
case "h":
|
||||
pane = (pane + PANES.length - 1) % PANES.length;
|
||||
offset = 0;
|
||||
break;
|
||||
case "\x1b[A":
|
||||
case "k":
|
||||
offset = Math.max(0, offset - 1);
|
||||
break;
|
||||
case "\x1b[B":
|
||||
case "j":
|
||||
offset += 1;
|
||||
break;
|
||||
case "r":
|
||||
note = "refreshing…";
|
||||
draw();
|
||||
void fetchStats()
|
||||
.then((fresh) => {
|
||||
stats = fresh;
|
||||
note = "";
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
note = `refresh failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
})
|
||||
.finally(draw);
|
||||
return;
|
||||
default:
|
||||
if (chunk >= "1" && chunk <= String(PANES.length)) {
|
||||
pane = Number(chunk) - 1;
|
||||
offset = 0;
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
draw();
|
||||
}
|
||||
|
||||
try {
|
||||
draw();
|
||||
await quit;
|
||||
} finally {
|
||||
screen.close();
|
||||
}
|
||||
Executable
+252
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Reconcile the "Interview Prep" Project's Set and Difficulty fields from the
|
||||
* labels on each problem issue.
|
||||
*
|
||||
* The issue labels are the truth: `set:core|optional|deferred` and
|
||||
* `diff:easy|medium|hard` are fixed when a problem issue is filed, and the
|
||||
* Project's single-selects are a projection of them so the board can group and
|
||||
* filter. This recomputes every desired value from scratch and writes only the
|
||||
* ones that differ, so re-runs are no-ops and a backfill needs no special
|
||||
* casing — same reconcile-don't-react shape as close-solved / close-topics.
|
||||
*
|
||||
* Scope guard: only items whose issue carries the `problem` label are touched.
|
||||
* Topic, cadence and review issues have no set or difficulty, so they are
|
||||
* skipped outright — never written, never cleared.
|
||||
*
|
||||
* Deliberately NOT mirrored: SRS stage and first-attempt result. Those live
|
||||
* only in D1 (apps/api owns them; the charts, digest and /api/stats read them
|
||||
* from there). The Project mirror writes Target Date and nothing else.
|
||||
*
|
||||
* Auth: needs a token carrying the `project` scope — neither the default
|
||||
* `gh auth token` nor Actions' GITHUB_TOKEN has it. Pass one in GH_TOKEN: the
|
||||
* PROJECT_PAT secret in CI, or the Worker's GH_PAT locally:
|
||||
* GH_TOKEN=$GH_PAT bun run sync-project-fields -- --dry-run
|
||||
*/
|
||||
import { github, type GitHub } from "./github.ts";
|
||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
|
||||
const PROJECT_TITLE = "Interview Prep";
|
||||
|
||||
/** Label → single-select option name, per field. Static tables, so Record. */
|
||||
const FIELDS: Record<string, Record<string, string>> = {
|
||||
Set: { "set:core": "Core", "set:optional": "Optional", "set:deferred": "Deferred" },
|
||||
Difficulty: { "diff:easy": "Easy", "diff:medium": "Medium", "diff:hard": "Hard" },
|
||||
};
|
||||
|
||||
const DRY =
|
||||
process.argv.includes("--dry-run") ||
|
||||
process.env.DRY_RUN === "1" ||
|
||||
process.env.DRY_RUN === "true";
|
||||
|
||||
// ── project + field resolution ───────────────────────────────────
|
||||
|
||||
interface SelectField {
|
||||
id: string;
|
||||
/** option name → option id */
|
||||
options: Record<string, string>;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
fields: Record<string, SelectField>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field and option IDs are resolved by NAME, so re-creating a field in the UI
|
||||
* does not strand this script on a dead id.
|
||||
*/
|
||||
async function readProject(gh: GitHub, owner: string): Promise<Project> {
|
||||
const data = (await gh.graphql(
|
||||
`query($owner: String!, $title: String!) {
|
||||
user(login: $owner) {
|
||||
projectsV2(first: 10, query: $title) {
|
||||
nodes {
|
||||
id title
|
||||
fields(first: 30) {
|
||||
nodes {
|
||||
... on ProjectV2SingleSelectField { id name options { id name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner, title: PROJECT_TITLE },
|
||||
)) as {
|
||||
user: {
|
||||
projectsV2: {
|
||||
nodes: {
|
||||
id: string;
|
||||
title: string;
|
||||
fields: { nodes: ({ id: string; name: string; options: { id: string; name: string }[] } | Record<string, never>)[] };
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
|
||||
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
|
||||
|
||||
const fields: Record<string, SelectField> = {};
|
||||
for (const name of Object.keys(FIELDS)) {
|
||||
const field = node.fields.nodes.find((f) => "name" in f && f.name === name);
|
||||
if (!field || !("options" in field)) {
|
||||
throw new Error(`single-select field "${name}" missing from "${PROJECT_TITLE}"`);
|
||||
}
|
||||
fields[name] = {
|
||||
id: field.id,
|
||||
options: Object.fromEntries(field.options.map((o) => [o.name, o.id])),
|
||||
};
|
||||
}
|
||||
return { id: node.id, fields };
|
||||
}
|
||||
|
||||
// ── items ────────────────────────────────────────────────────────
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
issue: number;
|
||||
title: string;
|
||||
labels: string[];
|
||||
/** field name → current option name */
|
||||
current: Record<string, string>;
|
||||
}
|
||||
|
||||
async function* readItems(gh: GitHub, project: string): AsyncGenerator<Item, void, void> {
|
||||
let cursor: string | null = null;
|
||||
for (;;) {
|
||||
const data = (await gh.graphql(
|
||||
`query($id: ID!, $after: String) {
|
||||
node(id: $id) {
|
||||
... on ProjectV2 {
|
||||
items(first: 100, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id
|
||||
fieldValues(first: 25) {
|
||||
nodes {
|
||||
... on ProjectV2ItemFieldSingleSelectValue {
|
||||
name field { ... on ProjectV2FieldCommon { name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
content {
|
||||
... on Issue { number title labels(first: 25) { nodes { name } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ id: project, after: cursor },
|
||||
)) as {
|
||||
node: {
|
||||
items: {
|
||||
pageInfo: { hasNextPage: boolean; endCursor: string };
|
||||
nodes: {
|
||||
id: string;
|
||||
fieldValues: { nodes: ({ name: string; field: { name: string } } | Record<string, never>)[] };
|
||||
content: { number: number; title: string; labels: { nodes: { name: string }[] } } | Record<string, never>;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
for (const node of data.node.items.nodes) {
|
||||
const content = node.content;
|
||||
// Draft items and PRs have no issue number; nothing to reconcile.
|
||||
if (!("number" in content) || typeof content.number !== "number") continue;
|
||||
const current: Record<string, string> = {};
|
||||
for (const value of node.fieldValues.nodes) {
|
||||
if ("name" in value && value.field?.name) current[value.field.name] = value.name;
|
||||
}
|
||||
yield {
|
||||
id: node.id,
|
||||
issue: content.number,
|
||||
title: content.title,
|
||||
labels: content.labels.nodes.map((l) => l.name),
|
||||
current,
|
||||
};
|
||||
}
|
||||
|
||||
const page = data.node.items.pageInfo;
|
||||
if (!page.hasNextPage) return;
|
||||
cursor = page.endCursor;
|
||||
}
|
||||
}
|
||||
|
||||
// ── reconcile ────────────────────────────────────────────────────
|
||||
|
||||
interface Write {
|
||||
item: string;
|
||||
issue: number;
|
||||
title: string;
|
||||
field: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
async function setField(gh: GitHub, project: string, w: Write, option: string): Promise<void> {
|
||||
await gh.graphql(
|
||||
`mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) {
|
||||
updateProjectV2ItemFieldValue(
|
||||
input: {
|
||||
projectId: $project, itemId: $item, fieldId: $field
|
||||
value: { singleSelectOptionId: $option }
|
||||
}
|
||||
) { projectV2Item { id } }
|
||||
}`,
|
||||
{ project, item: w.item, field: w.field, option },
|
||||
);
|
||||
}
|
||||
|
||||
const gh = await github();
|
||||
const owner = gh.repo.split("/")[0]!;
|
||||
const project = await readProject(gh, owner);
|
||||
|
||||
const writes: Write[] = [];
|
||||
let problems = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for await (const item of readItems(gh, project.id)) {
|
||||
if (!item.labels.includes("problem")) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
problems++;
|
||||
for (const [field, mapping] of Object.entries(FIELDS)) {
|
||||
const want = item.labels.map((l) => mapping[l]).find(Boolean);
|
||||
if (!want) {
|
||||
console.warn(`#${item.issue} has no ${field.toLowerCase()} label — left untouched`);
|
||||
continue;
|
||||
}
|
||||
const from = item.current[field];
|
||||
if (from === want) continue;
|
||||
if (!project.fields[field]!.options[want]) {
|
||||
throw new Error(`field "${field}" has no option "${want}"`);
|
||||
}
|
||||
writes.push({
|
||||
item: item.id,
|
||||
issue: item.issue,
|
||||
title: item.title,
|
||||
field: project.fields[field]!.id,
|
||||
from: from ?? "(empty)",
|
||||
to: want,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rows: string[][] = [["ISSUE", "PROBLEM", "FIELD", "FROM", "TO"]];
|
||||
for (const w of writes) {
|
||||
const name = Object.entries(project.fields).find(([, f]) => f.id === w.field)![0];
|
||||
rows.push([`#${w.issue}`, w.title.slice(0, 46), name, w.from, w.to]);
|
||||
if (DRY) continue;
|
||||
await setField(gh, project.id, w, project.fields[name]!.options[w.to]!);
|
||||
}
|
||||
|
||||
if (writes.length) printTable(rows);
|
||||
console.log(
|
||||
`\n${DRY ? "would update" : "updated"} ${writes.length} field value(s) across ${problems} problem item(s); ${skipped} non-problem item(s) skipped`,
|
||||
);
|
||||
if (writes.length) await writeStepSummary(markdownTable(rows));
|
||||
+28
-4
@@ -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));
|
||||
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Terminal drawing primitives for the stats dashboard: colour, block-glyph
|
||||
* charts, and the alternate-screen plumbing.
|
||||
*
|
||||
* Hand-rolled for the same reason apps/api/src/charts.ts and png.ts are — the
|
||||
* repo carries near-zero runtime dependencies, and everything a progress
|
||||
* dashboard needs is a bar, a sparkline and a coloured cell. The palette is
|
||||
* charts.ts's, so the terminal, the README SVGs and the digest email are the
|
||||
* same picture in three renderers.
|
||||
*
|
||||
* Side-effect-free on import except for reading the colour environment once;
|
||||
* nothing is written to the terminal until openScreen() is called.
|
||||
*/
|
||||
|
||||
// ── palette (apps/api/src/charts.ts) ─────────────────────────────
|
||||
|
||||
export const FG = "#fafafa"; // zinc-50 text
|
||||
export const MUTED = "#a1a1aa"; // zinc-400 secondary text
|
||||
export const LINE = "#27272a"; // zinc-800 structure / empty track
|
||||
export const GREEN = "#16a34a"; // green-600 (core / pass)
|
||||
export const BLUE = "#2563eb"; // blue-600 (optional)
|
||||
export const YELLOW = "#eab308"; // yellow-500 (attention)
|
||||
export const RED = "#f87171"; // red-400 (fail, on dark)
|
||||
|
||||
/** GitHub dark-mode contribution hues; level 0 is the empty cell. */
|
||||
export const GREENS = ["#27272a", "#0e4429", "#006d32", "#26a641", "#39d353"];
|
||||
|
||||
// ── colour ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Honour NO_COLOR and pipes, and let FORCE_COLOR override both — the dashboard
|
||||
* is legible without colour, and `bun run stats | less -R` should still be.
|
||||
*/
|
||||
export const COLOR =
|
||||
process.env.FORCE_COLOR !== undefined
|
||||
? process.env.FORCE_COLOR !== "0"
|
||||
: process.env.NO_COLOR === undefined && process.stdout.isTTY === true;
|
||||
|
||||
const RESET = "\x1b[0m";
|
||||
|
||||
function channels(hex: string): string {
|
||||
return `${parseInt(hex.slice(1, 3), 16)};${parseInt(hex.slice(3, 5), 16)};${parseInt(hex.slice(5, 7), 16)}`;
|
||||
}
|
||||
|
||||
export function fg(hex: string, text: string): string {
|
||||
return COLOR ? `\x1b[38;2;${channels(hex)}m${text}${RESET}` : text;
|
||||
}
|
||||
|
||||
export function bg(hex: string, text: string): string {
|
||||
return COLOR ? `\x1b[48;2;${channels(hex)}m${text}${RESET}` : text;
|
||||
}
|
||||
|
||||
export function bold(text: string): string {
|
||||
return COLOR ? `\x1b[1m${text}${RESET}` : text;
|
||||
}
|
||||
|
||||
const ANSI = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
/** Printable width — every glyph used here is one cell wide. */
|
||||
export function width(text: string): number {
|
||||
return text.replace(ANSI, "").length;
|
||||
}
|
||||
|
||||
/** Pad or cut a *plain* string to exactly n cells; colour it afterwards. */
|
||||
export function cell(text: string, n: number, align: "left" | "right" = "left"): string {
|
||||
const clipped = text.length > n ? `${text.slice(0, Math.max(0, n - 1))}…` : text;
|
||||
return align === "left" ? clipped.padEnd(n) : clipped.padStart(n);
|
||||
}
|
||||
|
||||
/** Pad an already-coloured string to n cells. */
|
||||
export function padTo(text: string, n: number): string {
|
||||
return text + " ".repeat(Math.max(0, n - width(text)));
|
||||
}
|
||||
|
||||
// ── charts ───────────────────────────────────────────────────────
|
||||
|
||||
const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
|
||||
const SPARKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
||||
|
||||
/**
|
||||
* Filled and empty. The two differ by GLYPH as well as by colour, so a bar
|
||||
* still reads under NO_COLOR or in a pipe — every chart here has to survive
|
||||
* `bun run stats > notes.txt`.
|
||||
*/
|
||||
const FILL = "█";
|
||||
const TRACK = "░";
|
||||
|
||||
/**
|
||||
* One bar over a muted track, `cells` wide. Sub-cell remainders use the
|
||||
* eighth-block glyphs, so one solved problem out of 161 still shows.
|
||||
*/
|
||||
export function bar(value: number, total: number, cells: number, color = GREEN): string {
|
||||
const units = Math.round(Math.max(0, Math.min(1, total > 0 ? value / total : 0)) * cells * 8);
|
||||
const filled = FILL.repeat(Math.floor(units / 8)) + EIGHTHS[units % 8]!;
|
||||
return fg(color, filled) + fg(LINE, TRACK.repeat(Math.max(0, cells - filled.length)));
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
value: number;
|
||||
color: string;
|
||||
/** Defaults to the solid block; pass TRACK-ish glyphs for "not done yet". */
|
||||
glyph?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segments laid end to end, `total` mapped to `cells`. Anything short of
|
||||
* `total` is left BLANK, not tracked: bars sharing a scale must show their
|
||||
* different lengths. A caller wanting a visible remainder passes it as a
|
||||
* segment of its own.
|
||||
*
|
||||
* Non-zero segments are floored but never vanish; the overshoot that floor
|
||||
* creates is taken back from the widest segment, which can spare it.
|
||||
*/
|
||||
export function stackedBar(segments: Segment[], total: number, cells: number): string {
|
||||
if (total <= 0) return " ".repeat(cells);
|
||||
const widths = segments.map((s) =>
|
||||
s.value > 0 ? Math.max(1, Math.floor((s.value / total) * cells)) : 0,
|
||||
);
|
||||
let over = widths.reduce((a, b) => a + b, 0) - cells;
|
||||
while (over > 0) {
|
||||
const i = widths.indexOf(Math.max(...widths));
|
||||
if (widths[i]! <= 1) break;
|
||||
widths[i]!--;
|
||||
over--;
|
||||
}
|
||||
const drawn = widths.map((n, i) => fg(segments[i]!.color, (segments[i]!.glyph ?? FILL).repeat(n)));
|
||||
return drawn.join("") + " ".repeat(Math.max(0, cells - widths.reduce((a, b) => a + b, 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* One cell of the campaign heatmap. With colour it is the GREENS ramp the
|
||||
* README and the digest use; without it, the same five levels as shading.
|
||||
*/
|
||||
export function heatCell(attempts: number, body: string): string {
|
||||
const level = Math.min(attempts, GREENS.length - 1);
|
||||
return COLOR ? bg(GREENS[level]!, body) : [" ", "·", "▒", "▓", "█"][level]!.repeat(body.length);
|
||||
}
|
||||
|
||||
/** One glyph per value, scaled to the largest. All-zero renders as a floor. */
|
||||
export function sparkline(values: number[], color = GREEN): string {
|
||||
const peak = Math.max(...values, 0);
|
||||
const line = values
|
||||
.map((v) => (peak === 0 ? SPARKS[0]! : SPARKS[Math.min(7, Math.round((v / peak) * 7))]!))
|
||||
.join("");
|
||||
return fg(color, line);
|
||||
}
|
||||
|
||||
// ── layout ───────────────────────────────────────────────────────
|
||||
|
||||
/** A section rule that fills the width: `── name ────────────`. */
|
||||
export function rule(title: string, cells: number): string {
|
||||
const label = ` ${title} `;
|
||||
return (
|
||||
fg(LINE, "──") + fg(MUTED, label) + fg(LINE, "─".repeat(Math.max(0, cells - 2 - label.length)))
|
||||
);
|
||||
}
|
||||
|
||||
// ── screen ───────────────────────────────────────────────────────
|
||||
|
||||
const ALT_ON = "\x1b[?1049h\x1b[?25l";
|
||||
const ALT_OFF = "\x1b[?25h\x1b[?1049l";
|
||||
|
||||
export interface Screen {
|
||||
readonly rows: number;
|
||||
readonly columns: number;
|
||||
/** Draw a frame, clipped to the terminal height. */
|
||||
draw(lines: string[]): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take over the terminal: alternate buffer, hidden cursor, raw keys. `onKey`
|
||||
* receives the raw chunk (`"\t"`, `"\x1b[B"`, `"q"`, …) — decoding belongs to
|
||||
* the app, which is the only thing that knows what a key means.
|
||||
*
|
||||
* The caller MUST close() in a finally: raw mode survives a thrown exception.
|
||||
*/
|
||||
export function openScreen(onKey: (chunk: string) => void, onResize: () => void): Screen {
|
||||
const out = process.stdout;
|
||||
const input = process.stdin;
|
||||
|
||||
out.write(ALT_ON);
|
||||
input.setRawMode?.(true);
|
||||
input.setEncoding("utf8");
|
||||
input.resume();
|
||||
input.on("data", onKey);
|
||||
out.on("resize", onResize);
|
||||
|
||||
let closed = false;
|
||||
return {
|
||||
get rows() {
|
||||
return out.rows ?? 24;
|
||||
},
|
||||
get columns() {
|
||||
return out.columns ?? 80;
|
||||
},
|
||||
draw(lines: string[]): void {
|
||||
const visible = lines.slice(0, this.rows);
|
||||
// Home, then erase each line as it is rewritten plus everything below the
|
||||
// last one. No full clear, so a redraw does not flash, and no trailing
|
||||
// newline, which would scroll the alternate screen by a row per frame.
|
||||
out.write(`\x1b[H${visible.map((l) => `${l}\x1b[K`).join("\r\n")}\x1b[J`);
|
||||
},
|
||||
close(): void {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
input.off("data", onKey);
|
||||
out.off("resize", onResize);
|
||||
input.setRawMode?.(false);
|
||||
input.pause();
|
||||
out.write(ALT_OFF);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" />
|
||||
+14
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
@@ -27,6 +27,32 @@ You must write a solution in O(log(m * n)) time complexity.
|
||||
- `1 <= m, n <= 100`
|
||||
- `-10^4 <= matrix[i][j], target <= 10^4`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["searchMatrix(matrix, target)"]) --> I["top = 0, bot = ROWS - 1"]
|
||||
I --> W1{"top <= bot?"}
|
||||
W1 -- yes --> R1["row = (top + bot) // 2"]
|
||||
R1 --> C1{"target vs that row's range"}
|
||||
C1 -- "above matrix[row][-1]" --> U["top = row + 1"]
|
||||
C1 -- "below matrix[row][0]" --> V["bot = row - 1"]
|
||||
C1 -- inside --> B(["break — row found"])
|
||||
U --> W1
|
||||
V --> W1
|
||||
W1 -- no --> F(["return False — no row can hold it"])
|
||||
B --> P["row = (top + bot) // 2; l = 0, r = COLS - 1"]
|
||||
P --> W2{"l <= r?"}
|
||||
W2 -- no --> G(["return False"])
|
||||
W2 -- yes --> M["m = (l + r) // 2"]
|
||||
M --> C2{"target vs matrix[row][m]"}
|
||||
C2 -- greater --> X["l = m + 1"]
|
||||
C2 -- smaller --> Y["r = m - 1"]
|
||||
C2 -- equal --> T(["return True"])
|
||||
X --> W2
|
||||
Y --> W2
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -59,3 +85,7 @@ class Solution:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: '208. Implement Trie (Prefix Tree)'
|
||||
description: A trie (pronounced as "try") or prefix tree is a tree data structure used to efficiently store and retrieve keys in a dataset of strings. There are various applications of this data structure, such as autocomplete and spellchecker
|
||||
sidebar:
|
||||
label: 'Implement Trie (Prefix Tree)'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Trie</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: ``
|
||||
- Output: ``
|
||||
- Explanation: Input ["Trie", "insert", "search", "search", "startsWith", "insert", "search"] `[[]`, `["apple"]`, `["apple"]`, `["app"]`, `["app"]`, `["app"]`, ["app"]] Output [null, null, true, false, true, null, true] Explanation Trie trie = new Trie(); trie.insert("apple"); trie.search("apple"); // return True trie.search("app"); // return False trie.startsWith("app"); // return True trie.insert("app"); trie.search("app"); // return True
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= word.length, prefix.length <= 2000`
|
||||
- `word` and `prefix` consist only of lowercase English letters.
|
||||
- At most 3 * 10^4 calls in total will be made to insert, search, and startsWith.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class TrieNode:
|
||||
def __init__(self):
|
||||
self.children = {}
|
||||
self.endOfWord = False
|
||||
|
||||
|
||||
class Trie:
|
||||
def __init__(self):
|
||||
self.root = TrieNode()
|
||||
|
||||
def insert(self, word: str) -> None:
|
||||
cur = self.root
|
||||
|
||||
for c in word:
|
||||
if c not in cur.children:
|
||||
cur.children[c] = TrieNode()
|
||||
cur = cur.children[c]
|
||||
cur.endOfWord = True
|
||||
|
||||
def search(self, word: str) -> bool:
|
||||
cur = self.root
|
||||
|
||||
for c in word:
|
||||
if c not in cur.children:
|
||||
return False
|
||||
cur = cur.children[c]
|
||||
|
||||
return cur.endOfWord
|
||||
|
||||
def startsWith(self, prefix: str) -> bool:
|
||||
cur = self.root
|
||||
|
||||
for c in prefix:
|
||||
if c not in cur.children:
|
||||
return False
|
||||
cur = cur.children[c]
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# Your Trie object will be instantiated and called as such:
|
||||
# obj = Trie()
|
||||
# obj.insert(word)
|
||||
# param_2 = obj.search(word)
|
||||
# param_3 = obj.startsWith(prefix)
|
||||
```
|
||||
@@ -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,65 @@
|
||||
---
|
||||
title: '211. Design Add and Search Words Data Structure'
|
||||
description: Design a data structure that supports adding new words and finding if a string matches any previously added string
|
||||
sidebar:
|
||||
label: 'Design Add and Search Words Data Structure'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Trie</Badge>
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= word.length <= 25`
|
||||
- `word` in addWord consists of lowercase English letters.
|
||||
- `word` in search consist of '.' or lowercase English letters.
|
||||
- There will be at most 2 dots in `word` for search queries.
|
||||
- At most 10^4 calls will be made to addWord and search.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class TrieNode:
|
||||
def __init__(self):
|
||||
self.children = {}
|
||||
self.endOfWord = False
|
||||
|
||||
|
||||
class WordDictionary:
|
||||
def __init__(self):
|
||||
self.root = TrieNode()
|
||||
|
||||
def addWord(self, word: str) -> None:
|
||||
cur = self.root
|
||||
for c in word:
|
||||
if c not in cur.children:
|
||||
cur.children[c] = TrieNode()
|
||||
cur = cur.children[c]
|
||||
cur.endOfWord = True
|
||||
|
||||
# Leverage Recursion
|
||||
def search(self, word: str) -> bool:
|
||||
def dfs(j, root):
|
||||
cur = root
|
||||
for i in range(j, len(word)):
|
||||
c = word[i]
|
||||
|
||||
if c == ".":
|
||||
for child in cur.children.values():
|
||||
if dfs(i + 1, child):
|
||||
return True
|
||||
return False
|
||||
else:
|
||||
if c not in cur.children:
|
||||
return False
|
||||
cur = cur.children[c]
|
||||
return cur.endOfWord
|
||||
|
||||
return dfs(0, self.root)
|
||||
|
||||
|
||||
# Your WordDictionary object will be instantiated and called as such:
|
||||
# obj = WordDictionary()
|
||||
# obj.addWord(word)
|
||||
# param_2 = obj.search(word)
|
||||
```
|
||||
@@ -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" />
|
||||
@@ -25,6 +25,18 @@ You must do this by modifying the input array in-place with O(1) extra memory.
|
||||
- `1 <= s.length <= 10^5`
|
||||
- `s[i]` is a printable ascii character.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["reverseString(s)"]) --> I["i = 0, j = s.length - 1"]
|
||||
I --> W{"i < j?"}
|
||||
W -- no --> E(["done — s was reversed in place, nothing returned"])
|
||||
W -- yes --> A["swap s[i] and s[j]"]
|
||||
A --> B["i++; j--"]
|
||||
B --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
@@ -43,3 +55,7 @@ var reverseString = function(s) {
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="_d0T_2Lk2qA" title="Reverse String - 3 Ways - Leetcode 344 - Python" />
|
||||
@@ -26,6 +26,22 @@ Suppose there are lots of incoming s, say s1, s2, ..., sk where k >= 10^9, and y
|
||||
- `0 <= t.length <= 10^4`
|
||||
- `s` and `t` consist only of lowercase English letters.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["isSubsequence(s, t)"]) --> G{"len(s) > len(t)?"}
|
||||
G -- yes --> X(["return False"])
|
||||
G -- no --> I["i = 0 into s, j = 0 into t"]
|
||||
I --> W{"i < len(s) and j < len(t)?"}
|
||||
W -- no --> E(["return i == len(s) — every char of s was matched"])
|
||||
W -- yes --> Q{"s[i] == t[j]?"}
|
||||
Q -- yes --> A["i += 1 — consume the match"]
|
||||
Q -- no --> B["j += 1 — always advance t"]
|
||||
A --> B
|
||||
B --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -42,3 +58,7 @@ class Solution:
|
||||
|
||||
return i == len(s)
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="99RVfqklbCE" title="Is Subsequence - Leetcode 392" />
|
||||
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user