mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
1b5bdc4ceb | ||
|
|
22ebb82e55 | ||
|
|
180134597d | ||
|
|
b669e97c3f | ||
|
|
d57d75efcc | ||
|
|
f1445a071f | ||
|
|
bb8aa00175 | ||
|
|
6df1e0e620 | ||
|
|
1b4ad8ca5b | ||
|
|
f697a986b8 | ||
|
|
de5101b4d1 | ||
|
|
3068b4e4aa | ||
|
|
e4de74c935 | ||
|
|
aba3d9b18b | ||
|
|
554145072d | ||
|
|
5cac46a0bf | ||
|
|
31f89dd4f5 | ||
|
|
217e899281 | ||
|
|
bc2269d9dd | ||
|
|
fd0d6c6ea5 | ||
|
|
9fbe800fed | ||
|
|
e64285f201 | ||
|
|
94b1e1af55 | ||
|
|
cc6c2da859 | ||
|
|
10d133f005 | ||
|
|
8f62875fa7 | ||
|
|
a3431da300 | ||
|
|
f551878961 | ||
|
|
881530a808 | ||
|
|
6b9a259ec7 | ||
|
|
3aa8d50644 | ||
|
|
ddf249c884 | ||
|
|
226fd2a6a8 | ||
|
|
a41ff59406 | ||
|
|
ab5c251952 | ||
|
|
7213cc1c8a | ||
|
|
6be5d55366 | ||
|
|
5bdc0d9089 | ||
|
|
c5e5e26d9d | ||
|
|
43c9b099fa | ||
|
|
0f509c5296 | ||
|
|
e5dbef9ee9 | ||
|
|
4c7a366bfd | ||
|
|
c3265f5672 | ||
|
|
48452fb72c | ||
|
|
d4b9c5b4fa |
No files matched your search
@@ -0,0 +1,175 @@
|
||||
---
|
||||
name: campaign-issues
|
||||
description: Create GitHub issues for this LeetCode campaign — individual problem issues and the topic issues that own them — using the repo's exact title, label, milestone, body, and sub-issue conventions. Use when asked to add/create an issue for a LeetCode problem or a topic, attach a problem to its topic, or backfill the issue catalog.
|
||||
---
|
||||
|
||||
# Campaign issues
|
||||
|
||||
The GitHub issue tracker **is the campaign catalog**: ~24 `topic` issues each own a
|
||||
set of `problem` sub-issues. The Worker reconciles this catalog into D1 nightly
|
||||
(`/admin/reconcile`), so a correctly-labelled issue is all that's needed — the SRS,
|
||||
digest, charts, and Project mirror pick it up automatically. Get the labels/body shape
|
||||
right; everything downstream is reconciliation.
|
||||
|
||||
All commands use `gh` against `prdlk/leetcode` and fall back to `gh auth token`.
|
||||
|
||||
## Problem issue
|
||||
|
||||
One issue per LeetCode problem, attached as a sub-issue of its topic.
|
||||
|
||||
- **Title:** `LC <num> · <Title>` — the LC number and the problem's name, nothing
|
||||
else. Separator is a middle dot `·` (U+00B7) with single spaces. Difficulty and
|
||||
set are **labels only**; never repeat them in the title (they used to be a `·
|
||||
Hard · core` suffix — that is gone, and `catalog.ts` no longer parses it).
|
||||
- **Labels** (four, all required — these are what the Worker reads):
|
||||
- `problem` — marks the sub-issue as curriculum; without it the reconcile skips it.
|
||||
- `diff:easy` | `diff:medium` | `diff:hard` — LeetCode's own rating, do not invent.
|
||||
- `set:core` | `set:optional` | `set:deferred` — see set semantics below.
|
||||
- `phase:N` — the **topic's** phase (same phase label the topic issue carries).
|
||||
- **Milestone:**
|
||||
- `core` / `optional` → the topic's phase milestone.
|
||||
- `deferred` → `Review — Transfer & Mocks` (deferred Hards are parked until week 8).
|
||||
- Pass the milestone **title**, not its number, to `--milestone`.
|
||||
- **Body** (exact shape; `<slug>` is the LeetCode URL slug, `<topic-issue>` the owning topic's number):
|
||||
|
||||
```
|
||||
https://leetcode.com/problems/<slug>/
|
||||
|
||||
- **Topic:** #<topic-issue>
|
||||
- **Set:** <core|optional|deferred>
|
||||
- **Language:** Python
|
||||
|
||||
### Close-out (paste before closing this issue)
|
||||
- Time: O(?)
|
||||
- Space: O(?)
|
||||
- Edge case traced:
|
||||
- First attempt passed: yes / no
|
||||
- Miss note:
|
||||
```
|
||||
|
||||
Create it:
|
||||
|
||||
```sh
|
||||
gh issue create --repo prdlk/leetcode \
|
||||
--title "LC 42 · Trapping Rain Water" \
|
||||
--label problem --label set:core --label diff:hard --label phase:1 \
|
||||
--milestone "Phase I — Linear Structures" \
|
||||
--body "https://leetcode.com/problems/trapping-rain-water/
|
||||
|
||||
- **Topic:** #9
|
||||
- **Set:** core
|
||||
- **Language:** Python
|
||||
|
||||
### Close-out (paste before closing this issue)
|
||||
- Time: O(?)
|
||||
- Space: O(?)
|
||||
- Edge case traced:
|
||||
- First attempt passed: yes / no
|
||||
- Miss note:"
|
||||
```
|
||||
|
||||
Then **attach it as a sub-issue of the topic** (this is a separate API call — creating
|
||||
the issue does not link it). The sub-issues API wants the child's internal **id**, not
|
||||
its issue number, and the field is integer-typed, so use `-F` (not `-f`):
|
||||
|
||||
```sh
|
||||
child_id=$(gh api repos/prdlk/leetcode/issues/211 --jq '.id')
|
||||
gh api --method POST repos/prdlk/leetcode/issues/9/sub_issues -F sub_issue_id=$child_id
|
||||
# verify
|
||||
gh api repos/prdlk/leetcode/issues/9/sub_issues --jq '.[] | "\(.number) \(.title)"'
|
||||
```
|
||||
|
||||
## Topic issue
|
||||
|
||||
One issue per curriculum topic; owns the problem sub-issues.
|
||||
|
||||
- **Title:** `Topic NN — <Name>` (zero-padded number, em dash, e.g. `Topic 07 — Monotonic Stack`).
|
||||
- **Labels:** `topic`, `phase:N`.
|
||||
- **Milestone:** that phase's milestone (title, not number).
|
||||
- **Body:**
|
||||
|
||||
```
|
||||
**Scheduled:** <Weekday Mon DD, YYYY>
|
||||
**Phase:** <Roman> — <Phase name> (Week N)
|
||||
**Builds on:** Topic <n>
|
||||
|
||||
### Trigger
|
||||
TODO
|
||||
|
||||
### Invariant
|
||||
TODO
|
||||
|
||||
### Trap
|
||||
TODO
|
||||
|
||||
### Close-out ritual
|
||||
Before every submit, out loud:
|
||||
1. State time and space complexity.
|
||||
2. Trace one edge case.
|
||||
|
||||
### Day rules
|
||||
- 90-minute coding cap. Stop at the cap, even mid-problem.
|
||||
- Core problems first. Optional only if the core is done.
|
||||
- Finished early? Do this topic's optional set. Do not start tomorrow.
|
||||
```
|
||||
|
||||
`Scheduled` should come from the calendar of record — `apps/api/data/schedule.json`
|
||||
(day → topic issue) — not guessed.
|
||||
|
||||
## Reference tables
|
||||
|
||||
Set semantics (from the label descriptions):
|
||||
|
||||
| set | meaning | milestone |
|
||||
|---|---|---|
|
||||
| `core` | must solve on the topic day | topic's phase milestone |
|
||||
| `optional` | solve only if the clock allows | topic's phase milestone |
|
||||
| `deferred` | a Hard, parked until week 8 | `Review — Transfer & Mocks` |
|
||||
|
||||
Milestones (`--milestone` takes the **title**):
|
||||
|
||||
| # | title |
|
||||
|---|---|
|
||||
| 1 | Phase I — Linear Structures |
|
||||
| 2 | Phase II — Nodal & Grid |
|
||||
| 3 | Phase III — Hierarchical |
|
||||
| 4 | Phase IV — Relational |
|
||||
| 5 | Phase V — Decision Space |
|
||||
| 6 | Review — Transfer & Mocks |
|
||||
|
||||
Find a topic issue and confirm its phase before creating problems under it:
|
||||
|
||||
```sh
|
||||
gh issue list --repo prdlk/leetcode --label topic --state all --limit 30 \
|
||||
--json number,title,labels,milestone
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Check the LC number is not already in the curriculum BEFORE creating.** D1 keys
|
||||
`problems` on `lc_number` (`ON CONFLICT(lc_number)`), so two issues for one LC number
|
||||
silently fight over a single row — whichever the reconcile walks last wins, and the
|
||||
loser's issue number ends up orphaned in D1. A problem can legitimately move topic or
|
||||
set; that is a relabel/re-parent of the *existing* issue, never a second issue:
|
||||
|
||||
```sh
|
||||
gh issue list --repo prdlk/leetcode --label problem --state all --limit 300 \
|
||||
--search "LC 42" --json number,title,labels,state
|
||||
```
|
||||
|
||||
If a row already exists, change the existing issue instead: swap its `set:*` label,
|
||||
move the sub-issue to the new topic, and fix its milestone. Then reconcile the board
|
||||
(`project-fields` skill). If a duplicate did get created and one is deleted, the
|
||||
surviving issue number and set only reach D1 on the next catalog reconcile — SRS
|
||||
columns (`stage`, `next_review`) are preserved, since they are the real attempt history.
|
||||
|
||||
- **`-F` vs `-f`:** `sub_issue_id` must be an integer; `-f` sends a string and the API
|
||||
rejects it with `is not of type integer`.
|
||||
- **id, not number:** the sub-issue payload wants the child's `.id` (a large opaque
|
||||
integer), never its display `#number`.
|
||||
- **`--milestone` wants the title string**, not the numeric id (`'1' not found` otherwise).
|
||||
- **Reconcile, don't react:** don't edit D1 or the Project by hand — a correct issue is
|
||||
reconciled into SRS state on the nightly cron. Solutions landing in `work/` are what
|
||||
close problem issues (via `close-solved.yml`), which then roll up to close topics.
|
||||
- **Difficulty is LeetCode's**, `set` is our scheduling choice — they're independent. A
|
||||
Hard can be `core` if we deliberately schedule it (vs the usual `deferred`).
|
||||
@@ -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.
|
||||
@@ -28,7 +28,36 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
# No install step: the script only uses Bun builtins + fetch.
|
||||
- run: bun scripts/close-solved.ts
|
||||
#
|
||||
# SRS_ADMIN_KEY lets the script report the solved set to the Worker
|
||||
# (POST /admin/solved). Closing an issue is invisible to D1 — the
|
||||
# catalog reconcile never reads issue state, and only logAttempt() moves
|
||||
# the ladder — so without this a pushed solution would never reach the
|
||||
# charts, the digest's solved ticks, or the drill/gate pools.
|
||||
- run: bun apps/cli/close-solved.ts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
SRS_ADMIN_KEY: ${{ secrets.SRS_ADMIN_KEY }}
|
||||
|
||||
# Project fields are a projection of the issue labels, so the same push
|
||||
# that adds a solution (and any issue filed since the last run) can leave
|
||||
# the board's Set/Difficulty empty. Reconciling here keeps the one
|
||||
# "solution landed" entry point responsible for the whole fan-out; the
|
||||
# script is idempotent, so a run with nothing to do writes nothing.
|
||||
#
|
||||
# Needs the `project` scope, which GITHUB_TOKEN does not have — hence
|
||||
# PROJECT_PAT. Unset = skip, matching SRS_ADMIN_KEY above. Best-effort by
|
||||
# design: D1 and the issues are the truth, so a Project failure must not
|
||||
# fail a run that already closed issues and logged the solve.
|
||||
- name: Reconcile Project Set/Difficulty
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PROJECT_PAT }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "PROJECT_PAT unset — skipping the Project field reconcile"
|
||||
exit 0
|
||||
fi
|
||||
bun apps/cli/sync-project-fields.ts
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Close Topics
|
||||
|
||||
# No D1 push here, unlike Close Solved: D1 stores no topic completion column.
|
||||
# Phase progress, the gate pools and the charts all derive from the problem
|
||||
# rows, which close-solved already logs — a topic is finished exactly when its
|
||||
# core problems are, so there is nothing left to reconcile.
|
||||
|
||||
on:
|
||||
# Close Solved closes problem issues with GITHUB_TOKEN, and token-driven
|
||||
# closes never fire `issues` events — so chain off its run instead.
|
||||
# Conclusion is not filtered: the script reconciles state, so running after a
|
||||
# partially successful run is safe and catches whatever it did close.
|
||||
workflow_run:
|
||||
workflows: [Close Solved]
|
||||
types: [completed]
|
||||
# Covers problem issues closed by hand in the UI.
|
||||
issues:
|
||||
types: [closed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Report finished topics without closing anything
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: close-topics
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
close:
|
||||
# The `issues` trigger fires for every issue in the repo; only closing a
|
||||
# problem sub-issue can ever complete a topic's core set.
|
||||
if: github.event_name != 'issues' || contains(github.event.issue.labels.*.name, 'problem')
|
||||
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.
|
||||
- run: bun apps/cli/close-topics.ts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Deploy Docs
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -10,8 +10,11 @@ permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# The Worker deploy is idempotent (wrangler uploads the whole bundle), so on a
|
||||
# burst of pushes only the last one matters: cancel-in-progress is correct for
|
||||
# both the Pages build and the Worker job.
|
||||
concurrency:
|
||||
group: pages
|
||||
group: deploy
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -31,7 +34,7 @@ jobs:
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: dist
|
||||
path: apps/docs/dist
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
@@ -42,3 +45,24 @@ jobs:
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
# Deploys on every main push (not path-filtered): apps/api/data/schedule.json
|
||||
# is bundled at deploy time, and re-deploying an unchanged Worker is a no-op.
|
||||
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit) and
|
||||
# CLOUDFLARE_ACCOUNT_ID.
|
||||
deploy-api:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
# DST cron guards must hold before the Worker ships.
|
||||
- run: bun run api:test
|
||||
|
||||
- run: bun run api:deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Sync D1 Catalog
|
||||
|
||||
# GitHub issues own the SRS catalog (topics, problems, set/diff labels,
|
||||
# milestones); the Worker's D1 holds a reconciled copy. The 8 AM ET cron is
|
||||
# the backstop — this workflow pushes curriculum edits into D1 immediately.
|
||||
#
|
||||
# The reconcile is a total recompute (never touches SRS-owned columns), so
|
||||
# bursts can safely collapse to the last run: cancel-in-progress is correct.
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
[opened, edited, labeled, unlabeled, milestoned, demilestoned, reopened, transferred, deleted]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: sync-d1
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
reconcile:
|
||||
# Only curriculum issues shape the catalog.
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
contains(github.event.issue.labels.*.name, 'problem') ||
|
||||
contains(github.event.issue.labels.*.name, 'topic')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Reconcile catalog into D1
|
||||
run: |
|
||||
curl -sf -X POST -H "Authorization: Bearer $SRS_ADMIN_KEY" \
|
||||
https://srs-api.prdlk.workers.dev/admin/reconcile
|
||||
env:
|
||||
SRS_ADMIN_KEY: ${{ secrets.SRS_ADMIN_KEY }}
|
||||
+8
-1
@@ -1,6 +1,8 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
tmp/
|
||||
*.log
|
||||
.crush
|
||||
|
||||
# output
|
||||
out
|
||||
@@ -35,5 +37,10 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
.DS_Store
|
||||
/CLAUDE.md
|
||||
.blume/
|
||||
/docs/.wrangler/state/v3
|
||||
/apps/docs/docs/.wrangler/state/v3
|
||||
.blume-verify/
|
||||
|
||||
# api worker
|
||||
apps/api/.dev.vars
|
||||
apps/api/.wrangler/
|
||||
apps/api/worker-configuration.d.ts
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
description: Sync work/ leetcode solutions into docs/solutions/ pages (gold standard format)
|
||||
---
|
||||
|
||||
Sync every leetcode solution under `work/` into a docs page under `docs/solutions/`, formatted like the gold standard `docs/solutions/(two-pointers)/344-reverse-string.mdx`. Extra focus (optional): $@
|
||||
|
||||
## Run the script
|
||||
|
||||
The port is fully scripted — do not hand-write pages:
|
||||
|
||||
```
|
||||
bun run sync
|
||||
```
|
||||
|
||||
`scripts/sync.ts` does the whole pipeline:
|
||||
|
||||
- Globs `work/**/*.{js,py}` (`<number>.<kebab-slug>.{js,py}` under `work/<Difficulty>/<Category>/`) and groups the two language variants of a problem by leetcode number.
|
||||
- Parses each file's header comment (js block comment / py docstring) into title, difficulty, statement, examples, constraints, and follow-up.
|
||||
- New problems get a full page at `docs/solutions/(<category>)/<leetcode-number>-<kebab-slug>.mdx`: frontmatter, `<Badge>` topic (README curriculum table, falling back to the `work/` subdirectory), `::::warning` for follow-ups/special requirements, examples, constraints, and the solution code verbatim.
|
||||
- `(<category>)` is the kebab-cased `work/` subdirectory (`(array)`, `(hash-table)`, `(two-pointers)`, …) and becomes the sidebar group; the leetcode-number filename prefix orders pages numerically. Both are stripped from the URL (`(two-pointers)/344-reverse-string.mdx` → `/solutions/reverse-string`). The script moves any page whose path drifts from this scheme.
|
||||
- Frontmatter `title` keeps the number (`'344. Reverse String'`); `sidebar.label` is the number-free problem name. The script backfills a missing `sidebar.label` on existing pages but never overwrites one.
|
||||
- Already-ported pages (frontmatter title starts with the leetcode number) only get their `## Solution` section regenerated — curated prose is never touched.
|
||||
- Problems solved in both languages render as a `<CodeGroup>` (Python first, then JavaScript); single-language solutions render as a plain fence.
|
||||
|
||||
The script prints the report table: work file → docs page → created / moved / updated / skipped (already in sync).
|
||||
|
||||
## Review
|
||||
|
||||
Skim any **created** pages — the prose transforms (backticking literals in example explanations, warning detection) are heuristic. Fix wording by editing the page prose directly; it won't be overwritten on the next sync. Never edit inside `## Solution` — that section is owned by the script and synced from `work/`.
|
||||
|
||||
## Verify
|
||||
|
||||
1. `bunx blume build --isolated` must succeed with no new warnings; confirm each new route generated (`/solutions/<slug>`).
|
||||
2. Spot-check one new page's built HTML for the badge, callout (when present), and solution code.
|
||||
3. `rm -rf .blume-verify` when done.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## 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 (`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
|
||||
|
||||
Three owners, one direction of truth:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
|
||||
work -->|close-solved.yml| PI[problem issues]
|
||||
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
|
||||
PI -->|close-topics.yml| TI[topic issues]
|
||||
GH[GitHub issues = catalog] -->|nightly reconcile| D1
|
||||
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
|
||||
Mail -->|GET /log| D1
|
||||
PI -->|/done webhook| D1
|
||||
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`, which takes `{lc, bucket}` pairs (`source='commit'`): the bucket names the rung the file settles — `work/1` settles `new`, `work/3` settles `+3`, `work/7` settles `+7` — so a pushed re-solve advances the ladder instead of vanishing, and re-posting the whole implemented set writes nothing because the rung named has already been left behind. That stage check is the entire idempotency mechanism; no index, no dedupe table, no state file. `close-topics` needs no such call — D1 stores no topic completion, only problem rows.
|
||||
- **The rest day is structural, not cosmetic.** Sunday is never booked: topics run Mon–Fri (`apps/api/data/schedule.json`), the gate is Saturday, and the ladder's windows are 3 and 7 precisely because those return a solve to a working day. Every scheduled date — ladder review, levelled overflow, deferred-Hard release, review-issue window — comes out of `workingDay()` in `apps/api/src/srs.ts`, which slides the one exception (a Thursday solve's `+3`) forward to Monday. Forward, never back: a review may slip later than its interval, never shorten it. Never mint a scheduled date with bare `addDays()`, and never "fix" a Sunday landing downstream — the digest's Sunday rest branch is a courtesy, not the mechanism. `apps/api/src/srs.test.ts` sweeps the campaign calendar to keep this honest.
|
||||
- **One schedule, one answer to "what is open today".** D1's ladder IS the schedule; `dueToday()` (`POST /admin/due`) is its read side and `DUE_WHERE` is the single SQL definition of "due", shared by the digest, the review issue and the docs queue chart. `spaced-repetition.yml` renders that answer into today's `Spaced Repetition — <Month D, YYYY>` issue — grouped by rung, `WINDOW_CAP` (3) required per rung with the overflow listed as optional, keyed by the ET date in the title so a re-run rewrites one body. It schedules nothing, stores nothing, and writes nothing on Sunday. Without `SRS_ADMIN_KEY` it FAILS rather than inventing a second schedule from issue close dates — that reconstruction was the drift, and it is gone. NEVER add a JSON state file (`.github/problems.json` and friends) or a second "when is this due" rule: D1 answers it, with one writer (`logAttempt`) and git-free history in `attempts`.
|
||||
- **The picker owns the local review loop; the buckets are its state.** `work/` holds three buckets (`1` = first solve, `3`/`7` = blind re-solves, named after the ladder rungs) and `bun run pick` has three sections — Tab cycles them. Pools are reconciled from the filesystem plus one `git log` (`apps/cli/work.ts`): a first solve dates from the ET date of the commit that added its `work/1` file — the same push that closes the problem issue and starts the +3/+7 windows — and a window stops being owed the moment its bucket holds a file. Uncapped, unlike the review issue's `WINDOW_CAP`: that issue is one day's assignment, the picker is everything still owed. An empty scaffold under `work/1` owes nothing (it is unsolved), which is why `isImplemented()` lives in `apps/cli/source.ts` and is shared with `close-solved.ts`. leetcode-cli reads its output directory from `~/.leetcode/workspaces/<active>/config.json` and nowhere else — no flag, no env var — so `pick.ts` MERGES `workDir` into that file around the child (never replays saved bytes: the CLI owns the file too) and leaves it pointed at `work/1`, then treats the scaffold's existence as the acceptance test, since leetcode-cli exits 0 even when an expired session made it write nothing. The picker itself writes no state: pushing the re-solve is what moves the ladder, via `close-solved` → `/admin/solved` with that file's bucket.
|
||||
- **The README charts are live, never committed.** They are Worker endpoints reading D1 per request (`Cache-Control: max-age=300`, honored by GitHub Camo), so a solved push moves them within ~5 minutes with no commit and no workflow of their own.
|
||||
- **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). `Target Date` is the *only* Project field the Worker mirrors (`apps/api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written. SRS stage and first-attempt result live **only** in D1 — do not re-add them as Project fields. The Project's `Set`/`Difficulty` single-selects are a projection of the issue labels, reconciled by `bun run sync-project-fields` — a best-effort final step of `close-solved.yml`, never the Worker.
|
||||
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
|
||||
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
|
||||
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
||||
|
||||
## Key Directories
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
||||
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
|
||||
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `islands/`, `public/`). `content/(<category>)/<num>-<slug>.mdx` generated by sync |
|
||||
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (day → topic issue, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
|
||||
| `.github/workflows/` | deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
|
||||
|
||||
## Development Commands
|
||||
|
||||
All from the repo root (a Bun workspace over `apps/*`):
|
||||
|
||||
```sh
|
||||
bun run pick # scaffold a solution via leetcode-cli (fuzzy picker; Tab cycles the
|
||||
# sections: new problems → work/1, then the owed 3-day and 7-day
|
||||
# re-solves → work/3, work/7. Each section hides what its own bucket
|
||||
# already holds; index+descriptions cached in
|
||||
# ~/.local/share/leetcode/data.db — see apps/cli/db.ts)
|
||||
bun run stats # terminal dashboard over GET /api/stats — four panes (Overview,
|
||||
# Ladder, Concepts, Activity); Tab/1-4 switches, ↑↓ scrolls,
|
||||
# r refetches, q quits. Outside a TTY it prints every pane once.
|
||||
# SRS_API points it at a local Worker like close-solved does.
|
||||
bun run test # run ONE solution against LeetCode's judge (not a test suite;
|
||||
# most recently tested first, stamped in the db on exit 0)
|
||||
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
|
||||
# hidden; last_submitted stamped in the db on exit 0)
|
||||
bun run sync # work/1 → apps/docs/content/ pages (re-solves are never published)
|
||||
bun run dev|build # Blume docs site (runs in apps/docs/)
|
||||
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
|
||||
bun run close-topics -- --dry-run
|
||||
# close-solved also pushes the solved set to the Worker; point it at a local
|
||||
# one and nothing production is touched:
|
||||
SRS_API=http://localhost:8787 SRS_ADMIN_KEY=$LINK_KEY bun run close-solved -- --dry-run
|
||||
# Project Set/Difficulty from the issue labels (needs a `project`-scoped token;
|
||||
# runs as the last step of close-solved.yml in CI):
|
||||
GH_TOKEN=$PROJECT_PAT bun run sync-project-fields -- --dry-run
|
||||
# today's review issue from the +3d/+7d closes (--date backfills a missed day;
|
||||
# a Sunday date writes nothing — the rest day has no assignment):
|
||||
bun run spaced-repetition -- --dry-run --date=2026-08-31
|
||||
bun run api:dev # wrangler dev on :8787 (local D1)
|
||||
bun run api:test # DST guards + the rest-day calendar sweep
|
||||
bun run api:deploy # deploy the Worker by hand (CI also deploys on main pushes)
|
||||
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
||||
"localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing
|
||||
```
|
||||
|
||||
## Code Conventions & Common Patterns
|
||||
|
||||
- **Scripts are either entries or libraries.** Entries (`apps/cli/close-*.ts`) use shebang + top-level `await`. Libraries (`apps/cli/github.ts`, `apps/cli/report.ts`, everything in `apps/api/src/`) are side-effect-free on import — nothing touches network/credentials until a factory (`github()`, `projectMirror()`) is called.
|
||||
- **Shared plumbing:** `github()` gives `repo`, `api()` (throws `METHOD path -> status body`), `list()` (paginating async generator), `closeIssue()` (comment **first**, then close — a failed PATCH still leaves a trace). `report.ts` gives `printTable`/`markdownTable`/`writeStepSummary`.
|
||||
- **Narrow API payloads with guards**, not inline casts: `if (!("number" in value) || typeof value.number !== "number") return;` (see `readProblemIssue` in `close-solved.ts`). Named-const casts only with a reason.
|
||||
- **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:** `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: 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: 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, 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.
|
||||
- **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
|
||||
|
||||
- Repo-wide: no test framework — `bun run test` submits one solution to LeetCode's judge. Exception: `apps/api/src/srs.test.ts` (`bun:test`) proves the DST cron guards with fixed dates; run via `bun run api:test`.
|
||||
- QA is dry-runs + local state: Worker logic against `wrangler dev` + local D1 with `?dry=1&date=`; reconcilers with `--dry-run`; idempotency check = run twice, `cmp` output.
|
||||
- Docs changes: `bunx blume build --isolated` (from `apps/docs/`) must pass with no new warnings (then `rm -rf apps/docs/.blume-verify`); after sync, review only *created* pages (prose transforms are heuristic).
|
||||
@@ -1,111 +1,32 @@
|
||||
<p align="center">
|
||||
<img alt="header" src="public/dependency-spine.svg" />
|
||||
<img alt="header" src="https://shieldcn.dev/header/gradient.svg?title=prdlk%2Fleetcode&subtitle=My+Personal+LeetCode+Solutions&logo=leetcode&logoColor=eab308&mode=dark" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/basedprad"><img alt="follow" src="https://shieldcn.dev/x/follow/basedprad.svg" /></a>
|
||||
<a href="https://lc.prad.nu"><img alt="badge" src="https://shieldcn.dev/badge/Read-Documentation.svg?logo=lu%3ABook" /></a>
|
||||
<a href="https://lc.prad.nu/progress"><img alt="gate pass rate" src="https://srs-api.prdlk.workers.dev/badge/gate.svg" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>8-week interview-prep fight camp. Each phase is a milestone; each topic an issue; each problem a sub-issue.<br>
|
||||
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>
|
||||
|
||||
## Campaign
|
||||
|
||||
<sub>8-week interview-prep fight camp. Each phase is a milestone; each topic an issue; each problem a sub-issue. Core count reflects closed sub-issues.</sub>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">Phase</th>
|
||||
<th align="center">Wk</th>
|
||||
<th align="left">Date</th>
|
||||
<th align="left">Topic</th>
|
||||
<th align="center">Core</th>
|
||||
<th align="left">Core problems</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="7" align="center"><strong>I</strong><br><sub>Linear Structures</sub></td>
|
||||
<td align="center">1</td><td>Mon Aug 17</td><td><a href="https://github.com/prdlk/leetcode/issues/3"><strong>01</strong> · Hash-Based Lookup</a></td><td align="center">5/5</td><td><a href="https://leetcode.com/problems/contains-duplicate/">217</a> · <a href="https://leetcode.com/problems/valid-anagram/">242</a> · <a href="https://leetcode.com/problems/two-sum/">1</a> · <a href="https://leetcode.com/problems/group-anagrams/">49</a> · <a href="https://leetcode.com/problems/top-k-frequent-elements/">347</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">1</td><td>Tue Aug 18</td><td><a href="https://github.com/prdlk/leetcode/issues/4"><strong>02</strong> · Prefix Sum</a></td><td align="center">3/3</td><td><a href="https://leetcode.com/problems/range-sum-query-immutable/">303</a> · <a href="https://leetcode.com/problems/product-of-array-except-self/">238</a> · <a href="https://leetcode.com/problems/subarray-sum-equals-k/">560</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">2</td><td>Mon Aug 24</td><td><a href="https://github.com/prdlk/leetcode/issues/5"><strong>03</strong> · Two Pointers</a></td><td align="center">2/4</td><td><a href="https://leetcode.com/problems/valid-palindrome/">125</a> · <a href="https://leetcode.com/problems/two-sum-ii-input-array-is-sorted/">167</a> · <a href="https://leetcode.com/problems/3sum/">15</a> · <a href="https://leetcode.com/problems/container-with-most-water/">11</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">2</td><td>Tue Aug 25</td><td><a href="https://github.com/prdlk/leetcode/issues/6"><strong>04</strong> · Sliding Window</a></td><td align="center">1/4</td><td><a href="https://leetcode.com/problems/best-time-to-buy-and-sell-stock/">121</a> · <a href="https://leetcode.com/problems/longest-substring-without-repeating-characters/">3</a> · <a href="https://leetcode.com/problems/longest-repeating-character-replacement/">424</a> · <a href="https://leetcode.com/problems/permutation-in-string/">567</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">2</td><td>Wed Aug 26</td><td><a href="https://github.com/prdlk/leetcode/issues/7"><strong>05</strong> · Binary Search</a></td><td align="center">3/5</td><td><a href="https://leetcode.com/problems/binary-search/">704</a> · <a href="https://leetcode.com/problems/koko-eating-bananas/">875</a> · <a href="https://leetcode.com/problems/search-in-rotated-sorted-array/">33</a> · <a href="https://leetcode.com/problems/search-a-2d-matrix/">74</a> · <a href="https://leetcode.com/problems/find-minimum-in-rotated-sorted-array/">153</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">2</td><td>Thu Aug 27</td><td><a href="https://github.com/prdlk/leetcode/issues/8"><strong>06</strong> · Stack</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/valid-parentheses/">20</a> · <a href="https://leetcode.com/problems/min-stack/">155</a> · <a href="https://leetcode.com/problems/evaluate-reverse-polish-notation/">150</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">2</td><td>Fri Aug 28</td><td><a href="https://github.com/prdlk/leetcode/issues/9"><strong>07</strong> · Monotonic Stack</a></td><td align="center">0/2</td><td><a href="https://leetcode.com/problems/daily-temperatures/">739</a> · <a href="https://leetcode.com/problems/car-fleet/">853</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3" align="center"><strong>II</strong><br><sub>Nodal & Grid</sub></td>
|
||||
<td align="center">3</td><td>Mon Aug 31</td><td><a href="https://github.com/prdlk/leetcode/issues/10"><strong>08</strong> · Linked List</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/reverse-linked-list/">206</a> · <a href="https://leetcode.com/problems/merge-two-sorted-lists/">21</a> · <a href="https://leetcode.com/problems/linked-list-cycle/">141</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">3</td><td>Tue Sep 1</td><td><a href="https://github.com/prdlk/leetcode/issues/11"><strong>09</strong> · Hybrid Structures</a></td><td align="center">0/2</td><td><a href="https://leetcode.com/problems/lru-cache/">146</a> · <a href="https://leetcode.com/problems/add-two-numbers/">2</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">3</td><td>Wed Sep 2</td><td><a href="https://github.com/prdlk/leetcode/issues/12"><strong>10</strong> · Matrix Index Math</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/rotate-image/">48</a> · <a href="https://leetcode.com/problems/spiral-matrix/">54</a> · <a href="https://leetcode.com/problems/set-matrix-zeroes/">73</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="5" align="center"><strong>III</strong><br><sub>Hierarchical</sub></td>
|
||||
<td align="center">3</td><td>Thu Sep 3</td><td><a href="https://github.com/prdlk/leetcode/issues/13"><strong>11</strong> · Tree DFS</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/invert-binary-tree/">226</a> · <a href="https://leetcode.com/problems/maximum-depth-of-binary-tree/">104</a> · <a href="https://leetcode.com/problems/lowest-common-ancestor-of-a-binary-tree/">236</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">3</td><td>Fri Sep 4</td><td><a href="https://github.com/prdlk/leetcode/issues/14"><strong>12</strong> · Tree BFS</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/binary-tree-level-order-traversal/">102</a> · <a href="https://leetcode.com/problems/binary-tree-right-side-view/">199</a> · <a href="https://leetcode.com/problems/count-good-nodes-in-binary-tree/">1448</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">4</td><td>Mon Sep 7</td><td><a href="https://github.com/prdlk/leetcode/issues/15"><strong>13</strong> · Binary Search Tree</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/validate-binary-search-tree/">98</a> · <a href="https://leetcode.com/problems/kth-smallest-element-in-a-bst/">230</a> · <a href="https://leetcode.com/problems/lowest-common-ancestor-of-a-binary-search-tree/">235</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">4</td><td>Tue Sep 8</td><td><a href="https://github.com/prdlk/leetcode/issues/16"><strong>14</strong> · Heap / Priority Queue</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/kth-largest-element-in-an-array/">215</a> · <a href="https://leetcode.com/problems/k-closest-points-to-origin/">973</a> · <a href="https://leetcode.com/problems/merge-k-sorted-lists/">23</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">4</td><td>Wed Sep 9</td><td><a href="https://github.com/prdlk/leetcode/issues/17"><strong>15</strong> · Trie</a></td><td align="center">0/2</td><td><a href="https://leetcode.com/problems/implement-trie-prefix-tree/">208</a> · <a href="https://leetcode.com/problems/design-add-and-search-words-data-structure/">211</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="4" align="center"><strong>IV</strong><br><sub>Relational</sub></td>
|
||||
<td align="center">4</td><td>Thu Sep 10</td><td><a href="https://github.com/prdlk/leetcode/issues/18"><strong>16</strong> · Graph DFS</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/number-of-islands/">200</a> · <a href="https://leetcode.com/problems/clone-graph/">133</a> · <a href="https://leetcode.com/problems/pacific-atlantic-water-flow/">417</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">4</td><td>Fri Sep 11</td><td><a href="https://github.com/prdlk/leetcode/issues/19"><strong>17</strong> · Graph BFS</a></td><td align="center">0/2</td><td><a href="https://leetcode.com/problems/rotting-oranges/">994</a> · <a href="https://leetcode.com/problems/word-ladder/">127</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">5</td><td>Mon Sep 14</td><td><a href="https://github.com/prdlk/leetcode/issues/20"><strong>18</strong> · Topological Sort</a></td><td align="center">0/2</td><td><a href="https://leetcode.com/problems/course-schedule/">207</a> · <a href="https://leetcode.com/problems/course-schedule-ii/">210</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">5</td><td>Tue Sep 15</td><td><a href="https://github.com/prdlk/leetcode/issues/21"><strong>19</strong> · Union-Find</a></td><td align="center">0/2</td><td><a href="https://leetcode.com/problems/redundant-connection/">684</a> · <a href="https://leetcode.com/problems/number-of-connected-components-in-an-undirected-graph/">323</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="5" align="center"><strong>V</strong><br><sub>Decision Space</sub></td>
|
||||
<td align="center">5</td><td>Wed Sep 16</td><td><a href="https://github.com/prdlk/leetcode/issues/22"><strong>20</strong> · Backtracking</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/subsets/">78</a> · <a href="https://leetcode.com/problems/combination-sum/">39</a> · <a href="https://leetcode.com/problems/permutations/">46</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">5</td><td>Thu Sep 17</td><td><a href="https://github.com/prdlk/leetcode/issues/23"><strong>21</strong> · 1-D Dynamic Programming</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/climbing-stairs/">70</a> · <a href="https://leetcode.com/problems/house-robber/">198</a> · <a href="https://leetcode.com/problems/coin-change/">322</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">5</td><td>Fri Sep 18</td><td><a href="https://github.com/prdlk/leetcode/issues/24"><strong>22</strong> · Multi-D / Grid DP</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/unique-paths/">62</a> · <a href="https://leetcode.com/problems/minimum-path-sum/">64</a> · <a href="https://leetcode.com/problems/longest-common-subsequence/">1143</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">6</td><td>Mon Sep 21</td><td><a href="https://github.com/prdlk/leetcode/issues/25"><strong>23</strong> · Greedy</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/maximum-subarray/">53</a> · <a href="https://leetcode.com/problems/jump-game/">55</a> · <a href="https://leetcode.com/problems/gas-station/">134</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">6</td><td>Tue Sep 22</td><td><a href="https://github.com/prdlk/leetcode/issues/26"><strong>24</strong> · Intervals</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/insert-interval/">57</a> · <a href="https://leetcode.com/problems/merge-intervals/">56</a> · <a href="https://leetcode.com/problems/non-overlapping-intervals/">435</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<table align="center">
|
||||
<tr>
|
||||
<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 — 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>
|
||||
|
||||
## Phase I — Linear Structures (Week 1)
|
||||
|
||||
## Phase I — Linear Structures · Weeks 1–2
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -117,7 +38,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="9"><strong>1. Hash-Based Lookup</strong><br><sub>builds on nothing</sub></td>
|
||||
<td rowspan="9"><a href="https://github.com/prdlk/leetcode/issues/3"><strong>1. Hash-Based Lookup</strong></a><br><sub>Wk 1 · Mon Aug 17</sub><br><sub>builds on nothing</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">217</td><td><a href="https://leetcode.com/problems/contains-duplicate/">Contains Duplicate</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">242</td><td><a href="https://leetcode.com/problems/valid-anagram/">Valid Anagram</a></td></tr>
|
||||
@@ -129,7 +50,7 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">128</td><td><a href="https://leetcode.com/problems/longest-consecutive-sequence/">Longest Consecutive Sequence</a></td></tr>
|
||||
<tr><td align="center"><code>B</code></td><td align="center">290</td><td><a href="https://leetcode.com/problems/word-pattern/">Word Pattern</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="5"><strong>2. Prefix Sum</strong><br><sub>builds on 1</sub></td>
|
||||
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/4"><strong>2. Prefix Sum</strong></a><br><sub>Wk 1 · Tue Aug 18</sub><br><sub>builds on 1</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">303</td><td><a href="https://leetcode.com/problems/range-sum-query-immutable/">Range Sum Query</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">238</td><td><a href="https://leetcode.com/problems/product-of-array-except-self/">Product of Array Except Self</a></td></tr>
|
||||
@@ -137,7 +58,7 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">525</td><td><a href="https://leetcode.com/problems/contiguous-array/">Contiguous Array</a></td></tr>
|
||||
<tr><td align="center"><code>B</code></td><td align="center">974</td><td><a href="https://leetcode.com/problems/subarray-sums-divisible-by-k/">Subarray Sums Divisible by K</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="9"><strong>3. Two Pointers</strong><br><sub>builds on ordered iteration</sub></td>
|
||||
<td rowspan="9"><a href="https://github.com/prdlk/leetcode/issues/5"><strong>3. Two Pointers</strong></a><br><sub>Wk 2 · Mon Aug 24</sub><br><sub>builds on ordered iteration</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">125</td><td><a href="https://leetcode.com/problems/valid-palindrome/">Valid Palindrome</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">167</td><td><a href="https://leetcode.com/problems/two-sum-ii-input-array-is-sorted/">Two Sum II</a></td></tr>
|
||||
@@ -149,7 +70,7 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">189</td><td><a href="https://leetcode.com/problems/rotate-array/">Rotate Array</a></td></tr>
|
||||
<tr><td align="center"><code>B</code></td><td align="center">80</td><td><a href="https://leetcode.com/problems/remove-duplicates-from-sorted-array-ii/">Remove Duplicates II</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="8"><strong>4. Sliding Window</strong><br><sub>builds on 3</sub></td>
|
||||
<td rowspan="8"><a href="https://github.com/prdlk/leetcode/issues/6"><strong>4. Sliding Window</strong></a><br><sub>Wk 2 · Tue Aug 25</sub><br><sub>builds on 3</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">121</td><td><a href="https://leetcode.com/problems/best-time-to-buy-and-sell-stock/">Best Time to Buy and Sell Stock</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">3</td><td><a href="https://leetcode.com/problems/longest-substring-without-repeating-characters/">Longest Substring Without Repeating Characters</a></td></tr>
|
||||
@@ -160,7 +81,7 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">1004</td><td><a href="https://leetcode.com/problems/max-consecutive-ones-iii/">Max Consecutive Ones III</a></td></tr>
|
||||
<tr><td align="center"><code>B</code></td><td align="center">643</td><td><a href="https://leetcode.com/problems/maximum-average-subarray-i/">Maximum Average Subarray</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="9"><strong>5. Binary Search</strong><br><sub>builds on ordered / monotonic invariants</sub></td>
|
||||
<td rowspan="9"><a href="https://github.com/prdlk/leetcode/issues/7"><strong>5. Binary Search</strong></a><br><sub>Wk 2 · Wed Aug 26</sub><br><sub>builds on ordered / monotonic invariants</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">704</td><td><a href="https://leetcode.com/problems/binary-search/">Binary Search</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">74</td><td><a href="https://leetcode.com/problems/search-a-2d-matrix/">Search a 2D Matrix</a></td></tr>
|
||||
@@ -172,7 +93,7 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">34</td><td><a href="https://leetcode.com/problems/find-first-and-last-position-of-element-in-sorted-array/">First & Last Position</a></td></tr>
|
||||
<tr><td align="center"><code>B</code> ⭐</td><td align="center">4</td><td><a href="https://leetcode.com/problems/median-of-two-sorted-arrays/">Median of Two Sorted Arrays</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="5"><strong>6. Stack</strong><br><sub>independent primitive</sub></td>
|
||||
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/8"><strong>6. Stack</strong></a><br><sub>Wk 2 · Thu Aug 27</sub><br><sub>independent primitive</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">20</td><td><a href="https://leetcode.com/problems/valid-parentheses/">Valid Parentheses</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">155</td><td><a href="https://leetcode.com/problems/min-stack/">Min Stack</a></td></tr>
|
||||
@@ -180,7 +101,7 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">71</td><td><a href="https://leetcode.com/problems/simplify-path/">Simplify Path</a></td></tr>
|
||||
<tr><td align="center"><code>B</code> ⭐</td><td align="center">224</td><td><a href="https://leetcode.com/problems/basic-calculator/">Basic Calculator</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="5"><strong>7. Monotonic Stack</strong><br><sub>builds on 6</sub></td>
|
||||
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/9"><strong>7. Monotonic Stack</strong></a><br><sub>Wk 2 · Fri Aug 28</sub><br><sub>builds on 6</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">739</td><td><a href="https://leetcode.com/problems/daily-temperatures/">Daily Temperatures</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">853</td><td><a href="https://leetcode.com/problems/car-fleet/">Car Fleet</a></td></tr>
|
||||
@@ -189,10 +110,10 @@
|
||||
<tr><td align="center"><code>B</code> ⭐</td><td align="center">84</td><td><a href="https://leetcode.com/problems/largest-rectangle-in-histogram/">Largest Rectangle in Histogram</a></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
---
|
||||
|
||||
## Phase II — Nodal & Grid Structures (Week 2)
|
||||
|
||||
|
||||
|
||||
## Phase II — Nodal & Grid Structures · Week 3
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -204,7 +125,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="10"><strong>8. Linked List</strong><br><sub>independent primitive</sub></td>
|
||||
<td rowspan="10"><a href="https://github.com/prdlk/leetcode/issues/10"><strong>8. Linked List</strong></a><br><sub>Wk 3 · Mon Aug 31</sub><br><sub>independent primitive</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">206</td><td><a href="https://leetcode.com/problems/reverse-linked-list/">Reverse Linked List</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">21</td><td><a href="https://leetcode.com/problems/merge-two-sorted-lists/">Merge Two Sorted Lists</a></td></tr>
|
||||
@@ -217,14 +138,14 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">61</td><td><a href="https://leetcode.com/problems/rotate-list/">Rotate List</a></td></tr>
|
||||
<tr><td align="center"><code>B</code></td><td align="center">86</td><td><a href="https://leetcode.com/problems/partition-list/">Partition List</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="4"><strong>9. Hybrid Structures</strong><br><sub>builds on 1 + 8</sub></td>
|
||||
<td rowspan="4"><a href="https://github.com/prdlk/leetcode/issues/11"><strong>9. Hybrid Structures</strong></a><br><sub>Wk 3 · Tue Sep 1</sub><br><sub>builds on 1 + 8</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">146</td><td><a href="https://leetcode.com/problems/lru-cache/">LRU Cache</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">2</td><td><a href="https://leetcode.com/problems/add-two-numbers/">Add Two Numbers</a></td></tr>
|
||||
<tr><td align="center"><code>B</code></td><td align="center">138</td><td><a href="https://leetcode.com/problems/copy-list-with-random-pointer/">Copy List with Random Pointer</a></td></tr>
|
||||
<tr><td align="center"><code>B</code></td><td align="center">380</td><td><a href="https://leetcode.com/problems/insert-delete-getrandom-o1/">Insert Delete GetRandom O(1)</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="5"><strong>10. Matrix Index Math</strong><br><sub>builds on 3 — NOT graph traversal</sub></td>
|
||||
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/12"><strong>10. Matrix Index Math</strong></a><br><sub>Wk 3 · Wed Sep 2</sub><br><sub>builds on 3 — NOT graph traversal</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">48</td><td><a href="https://leetcode.com/problems/rotate-image/">Rotate Image</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">54</td><td><a href="https://leetcode.com/problems/spiral-matrix/">Spiral Matrix</a></td></tr>
|
||||
@@ -233,10 +154,10 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">289</td><td><a href="https://leetcode.com/problems/game-of-life/">Game of Life</a></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
---
|
||||
|
||||
## Phase III — Hierarchical Structures (Week 3)
|
||||
|
||||
|
||||
|
||||
## Phase III — Hierarchical Structures · Weeks 3–4
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -248,7 +169,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="10"><strong>11. Tree DFS</strong><br><sub>builds on recursion</sub></td>
|
||||
<td rowspan="10"><a href="https://github.com/prdlk/leetcode/issues/13"><strong>11. Tree DFS</strong></a><br><sub>Wk 3 · Thu Sep 3</sub><br><sub>builds on recursion</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">226</td><td><a href="https://leetcode.com/problems/invert-binary-tree/">Invert Binary Tree</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">104</td><td><a href="https://leetcode.com/problems/maximum-depth-of-binary-tree/">Maximum Depth of Binary Tree</a></td></tr>
|
||||
@@ -261,7 +182,7 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">129</td><td><a href="https://leetcode.com/problems/sum-root-to-leaf-numbers/">Sum Root to Leaf Numbers</a></td></tr>
|
||||
<tr><td align="center"><code>B</code></td><td align="center">543</td><td><a href="https://leetcode.com/problems/diameter-of-binary-tree/">Diameter of Binary Tree</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="5"><strong>12. Tree BFS</strong><br><sub>builds on 6's sibling (queue) + 11</sub></td>
|
||||
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/14"><strong>12. Tree BFS</strong></a><br><sub>Wk 3 · Fri Sep 4</sub><br><sub>builds on 6's sibling (queue) + 11</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">102</td><td><a href="https://leetcode.com/problems/binary-tree-level-order-traversal/">Level Order Traversal</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">199</td><td><a href="https://leetcode.com/problems/binary-tree-right-side-view/">Right Side View</a></td></tr>
|
||||
@@ -269,7 +190,7 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">103</td><td><a href="https://leetcode.com/problems/binary-tree-zigzag-level-order-traversal/">Zigzag Level Order</a></td></tr>
|
||||
<tr><td align="center"><code>B</code></td><td align="center">637</td><td><a href="https://leetcode.com/problems/average-of-levels-in-binary-tree/">Average of Levels</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="7"><strong>13. Binary Search Tree</strong><br><sub>builds on 5 + 11</sub></td>
|
||||
<td rowspan="7"><a href="https://github.com/prdlk/leetcode/issues/15"><strong>13. Binary Search Tree</strong></a><br><sub>Wk 4 · Mon Sep 7</sub><br><sub>builds on 5 + 11</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">98</td><td><a href="https://leetcode.com/problems/validate-binary-search-tree/">Validate BST</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">230</td><td><a href="https://leetcode.com/problems/kth-smallest-element-in-a-bst/">Kth Smallest in BST</a></td></tr>
|
||||
@@ -279,7 +200,7 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">173</td><td><a href="https://leetcode.com/problems/binary-search-tree-iterator/">BST Iterator</a></td></tr>
|
||||
<tr><td align="center"><code>B</code></td><td align="center">108</td><td><a href="https://leetcode.com/problems/convert-sorted-array-to-binary-search-tree/">Sorted Array to BST</a></td></tr>
|
||||
<tr>
|
||||
<td rowspan="9"><strong>14. Heap / Priority Queue</strong><br><sub>implicit tree over array</sub></td>
|
||||
<td rowspan="9"><a href="https://github.com/prdlk/leetcode/issues/16"><strong>14. Heap / Priority Queue</strong></a><br><sub>Wk 4 · Tue Sep 8</sub><br><sub>implicit tree over array</sub></td>
|
||||
<td align="center"><code>A</code></td><td align="center">215</td><td><a href="https://leetcode.com/problems/kth-largest-element-in-an-array/">Kth Largest in Array</a></td>
|
||||
</tr>
|
||||
<tr><td align="center"><code>A</code></td><td align="center">703</td><td><a href="https://leetcode.com/problems/kth-largest-element-in-a-stream/">Kth Largest in Stream</a></td></tr>
|
||||
@@ -291,17 +212,17 @@
|
||||
<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"><strong>15. Trie</strong><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 4 · Wed Sep 9</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>
|
||||
<tr><td align="center"><code>B</code></td><td align="center">212</td><td><a href="https://leetcode.com/problems/word-search-ii/">Word Search II</a> <sub>(needs topic 20 — schedule after)</sub></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
---
|
||||
|
||||
## Phase IV — Relational Structures (Week 4)
|
||||
|
||||
|
||||
|
||||
## Phase IV — Relational Structures · Weeks 4–5
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -313,7 +234,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="7"><strong>16. Graph DFS</strong><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 4 · Thu Sep 10</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>
|
||||
@@ -323,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"><strong>17. Graph BFS</strong><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 4 · Fri Sep 11</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>
|
||||
@@ -331,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"><strong>18. Topological Sort</strong><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 5 · Mon Sep 14</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"><strong>19. Union-Find</strong><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 5 · Tue Sep 15</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>
|
||||
@@ -346,10 +267,10 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">721</td><td><a href="https://leetcode.com/problems/accounts-merge/">Accounts Merge</a></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
---
|
||||
|
||||
## Phase V — Decision-Space Exploration (Weeks 4–5)
|
||||
|
||||
|
||||
|
||||
## Phase V — Decision-Space Exploration · Weeks 5–6
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -361,7 +282,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="8"><strong>20. Backtracking</strong><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 5 · Wed Sep 16</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>
|
||||
@@ -372,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"><strong>21. 1-D Dynamic Programming</strong><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 5 · Thu Sep 17</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>
|
||||
@@ -384,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"><strong>22. Multi-D / Grid DP</strong><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 5 · Fri Sep 18</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>
|
||||
@@ -395,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"><strong>23. Greedy</strong><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 6 · Mon Sep 21</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>
|
||||
@@ -405,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"><strong>24. Intervals</strong><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 6 · Tue Sep 22</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>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
GH_PAT=github_pat_or_gho_token_with_issues_and_projects_rw
|
||||
WEBHOOK_SECRET=random_hex_matching_the_repo_webhook
|
||||
LINK_KEY=random_hex_signing_one_tap_links
|
||||
@@ -0,0 +1,126 @@
|
||||
# srs-api
|
||||
|
||||
Cloudflare Worker running the spaced-repetition system: daily digest email,
|
||||
one-tap logging, `/done` webhook, Saturday review issues, live SVG charts,
|
||||
and the `/api/stats` feed for the docs progress page.
|
||||
|
||||
Live at `https://srs-api.prdlk.workers.dev`.
|
||||
|
||||
## Direction of truth
|
||||
|
||||
- **D1 owns SRS state** — stages, review dates, attempt history, gate
|
||||
scores, boost flags. Nothing else is authoritative.
|
||||
- **GitHub issues own the catalog** — topics, problems, `set:*`/`diff:*`
|
||||
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 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 → +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.
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Auth | Purpose |
|
||||
|---|---|---|
|
||||
| `GET /log?p&r&d&sig` | HMAC (`LINK_KEY`) | one-tap pass/fail; idempotent per (problem, date, kind); links expire after 3 days |
|
||||
| `POST /webhook/github` | HMAC (`WEBHOOK_SECRET`) | `/done <n> pass\|fail` comments (owner only, any issue); `review`-issue close → gate scoring |
|
||||
| `GET /chart/{progress,ladder,heatmap}.svg`, `GET /badge/gate.svg` | public | hand-rolled SVGs, `max-age=300` (GitHub Camo's freshness floor) |
|
||||
| `GET /api/stats` | public, CORS-pinned to the docs origin | one JSON document for `/progress` |
|
||||
| `POST /admin/solved` | `Authorization: Bearer <LINK_KEY>` | `{"solved":[{"lc":1,"bucket":1},…]}` — solutions committed under `work/`; the bucket names the rung it settles (1 → `new`, 3 → `+3`, 7 → `+7`), so a first solve enters the ladder and a pushed re-solve advances it. Writes only when the problem stands on that rung, which is what makes re-posting the whole set a no-op. Takes `?dry=1&date=` |
|
||||
| `POST /admin/due` | `Authorization: Bearer <LINK_KEY>` | read-only: what the ladder has due on `?date=` (default today), oldest first, with the rung/bucket, how late it is, and when it was last seen. The one answer behind the daily `Spaced Repetition` issue |
|
||||
| `POST /admin/{digest,review,reconcile}` | `Authorization: Bearer <LINK_KEY>` | manual triggers; `digest` takes `?dry=1&force=1&date=` |
|
||||
|
||||
## Crons (DST-proof)
|
||||
|
||||
Each event has two UTC crons; code fires only when the computed ET hour
|
||||
matches (`etHour` in `src/srs.ts`, unit-checked in `src/srs.test.ts`):
|
||||
|
||||
- `0 12,13 * * *` → 8 AM ET: catalog reconcile, then the digest.
|
||||
- `0 4,5 * * 6` → midnight ET Saturday: create `Review — Week N` (so the
|
||||
8 AM digest can link to it).
|
||||
|
||||
## Secrets & bindings
|
||||
|
||||
`wrangler secret put` — `GH_PAT` (Issues + Projects RW), `WEBHOOK_SECRET`
|
||||
(matches the repo webhook), `LINK_KEY` (signs one-tap links, gates admin
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
```sh
|
||||
cp .dev.vars.example .dev.vars # or fill GH_PAT/WEBHOOK_SECRET/LINK_KEY
|
||||
bun install
|
||||
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 guards, date math, the rest-day sweep
|
||||
```
|
||||
|
||||
`?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
|
||||
|
||||
```sh
|
||||
bun run deploy # from apps/api/, or `bun run api:deploy` at the repo root
|
||||
```
|
||||
|
||||
CI deploys on every push to `main` (`deploy.yml`, gated on `bun run api:test`,
|
||||
using the `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_ACCOUNT_ID` repo secrets); the
|
||||
commands above are for out-of-band deploys. After changing bindings, rerun
|
||||
`bunx wrangler types`.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
-- SRS schema. D1 owns SRS state (stage, next_review, attempts, gates,
|
||||
-- boosts); GitHub issues own the catalog columns, reconciled nightly.
|
||||
CREATE TABLE problems (
|
||||
lc_number INTEGER PRIMARY KEY,
|
||||
issue INTEGER NOT NULL,
|
||||
topic_issue INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
difficulty TEXT NOT NULL, -- easy|medium|hard
|
||||
set_label TEXT NOT NULL, -- core|optional|deferred
|
||||
stage TEXT NOT NULL DEFAULT 'new', -- new|+2|+5|+10|retired
|
||||
next_review TEXT, -- YYYY-MM-DD ET, NULL when retired/unsolved
|
||||
defer_until TEXT -- deferred Hards: 2026-09-28+
|
||||
);
|
||||
|
||||
CREATE TABLE attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lc_number INTEGER NOT NULL REFERENCES problems(lc_number),
|
||||
date TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- first|review|drill|gate
|
||||
result TEXT NOT NULL, -- pass|fail
|
||||
source TEXT NOT NULL -- email|webhook|commit|import
|
||||
);
|
||||
|
||||
-- One-tap email links must be idempotent (same link twice = no-op), but
|
||||
-- /done webhook corrections (pass then fail, same day) must stay legal —
|
||||
-- so uniqueness applies to email-sourced attempts only. Commit-sourced
|
||||
-- attempts need no index: logAttempt() only accepts them for a stage-'new'
|
||||
-- problem, so a re-push cannot insert a second one.
|
||||
CREATE UNIQUE INDEX attempts_email_once
|
||||
ON attempts (lc_number, date, kind) WHERE source = 'email';
|
||||
CREATE INDEX attempts_by_date ON attempts (date);
|
||||
|
||||
CREATE TABLE topics (
|
||||
issue INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
misses INTEGER NOT NULL DEFAULT 0,
|
||||
boost INTEGER NOT NULL DEFAULT 0,
|
||||
milestone INTEGER -- phase; from the topic issue
|
||||
);
|
||||
|
||||
CREATE TABLE gates (
|
||||
week INTEGER PRIMARY KEY, -- ISO week
|
||||
issue INTEGER,
|
||||
problems TEXT NOT NULL, -- JSON array of lc_numbers
|
||||
pass_rate REAL,
|
||||
closed_on TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE drill_pool_used (lc_number INTEGER PRIMARY KEY);
|
||||
|
||||
-- Digest idempotency: one email per ET date.
|
||||
CREATE TABLE email_log (
|
||||
date TEXT PRIMARY KEY, -- YYYY-MM-DD ET
|
||||
sent_at TEXT NOT NULL
|
||||
);
|
||||
@@ -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';
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "srs-api",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev --test-scheduled",
|
||||
"deploy": "wrangler deploy",
|
||||
"types": "wrangler types",
|
||||
"test": "bun test src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.18",
|
||||
"wrangler": "^4.125.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "^1.0.12",
|
||||
"@react-email/render": "^2.1.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Nightly GitHub → D1 catalog reconcile. GitHub issues own the catalog
|
||||
* (topics, problems, set/diff labels, milestones); this recomputes desired
|
||||
* 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 { DIFFICULTIES, SETS, workingDay } from "./srs.ts";
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
||||
|
||||
/** Deferred Hards enter the queue from this date, two per working day. */
|
||||
const DEFER_FROM = "2026-09-28";
|
||||
|
||||
export interface ReconcileReport {
|
||||
topics: number;
|
||||
problems: number;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
title: string;
|
||||
milestone: number | null;
|
||||
}
|
||||
const topics: TopicIssue[] = [];
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if ("pull_request" in raw) continue;
|
||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||
let milestone: number | null = null;
|
||||
if (
|
||||
"milestone" in raw &&
|
||||
raw.milestone &&
|
||||
typeof raw.milestone === "object" &&
|
||||
"number" in raw.milestone &&
|
||||
typeof raw.milestone.number === "number"
|
||||
) {
|
||||
milestone = raw.milestone.number;
|
||||
}
|
||||
topics.push({ number: raw.number, title: raw.title, milestone });
|
||||
}
|
||||
|
||||
const statements: D1PreparedStatement[] = [];
|
||||
for (const t of topics) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO topics (issue, name, milestone) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(issue) DO UPDATE SET name = ?2, milestone = ?3`,
|
||||
)
|
||||
.bind(t.number, t.title.replace(/^Topic \d+ — /, ""), t.milestone),
|
||||
);
|
||||
}
|
||||
|
||||
let problems = 0;
|
||||
let deferredSeen = 0;
|
||||
for (const t of topics) {
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||
const labels = labelNames(raw);
|
||||
if (!labels.includes("problem")) continue; // non-curriculum sub-issue
|
||||
const m = raw.title.match(TITLE_RE);
|
||||
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]);
|
||||
// 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
|
||||
.prepare(
|
||||
`INSERT INTO problems (lc_number, issue, topic_issue, title, difficulty, set_label, defer_until, next_review)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
|
||||
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]!, difficulty, set, defer),
|
||||
);
|
||||
problems++;
|
||||
}
|
||||
}
|
||||
|
||||
// D1 batches are transactional; chunk to stay under statement limits.
|
||||
for (let i = 0; i < statements.length; i += 50) {
|
||||
await db.batch(statements.slice(i, i + 50));
|
||||
}
|
||||
return { topics: topics.length, problems };
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Hand-rolled SVG chart generation for the SRS Worker.
|
||||
*
|
||||
* Feeds five endpoints — /chart/progress.svg, /chart/ladder.svg,
|
||||
* /chart/heatmap.svg, /chart/heatmap.png, /badge/gate.svg — each served with
|
||||
* `Cache-Control: public, max-age=300`. The heatmap has a raster twin because
|
||||
* every major email client blocks SVG, so the digest cannot embed the vector.
|
||||
*
|
||||
* The SVG is string-built: no chart library, no dependencies, and the one
|
||||
* import is the hand-rolled PNG encoder next door. The only dynamic values
|
||||
* entering the markup are numbers, percentages, and dates the Worker itself
|
||||
* computes, plus the fixed phase/stage labels — so nothing here needs XML
|
||||
* escaping. Styling is shadcn dark zinc to match the README's shieldcn
|
||||
* 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.
|
||||
|
||||
const FONT = "Verdana,DejaVu Sans,sans-serif";
|
||||
|
||||
// shadcn dark-zinc palette, matching the README's shieldcn badges.
|
||||
const BG = "#09090b"; // zinc-950 card
|
||||
const FG = "#fafafa"; // zinc-50 text
|
||||
const MUTED = "#a1a1aa"; // zinc-400 secondary text
|
||||
const LINE = "#27272a"; // zinc-800 structure / empty
|
||||
const GREEN = "#16a34a"; // green-600 (core / pass)
|
||||
const BLUE = "#2563eb"; // blue-600 (optional)
|
||||
const RED = "#f87171"; // red-400 (fail, on dark)
|
||||
|
||||
// Green ramp shared by the heatmap and the ladder (GitHub dark-mode
|
||||
// contribution hues; level 0 is the empty zinc cell).
|
||||
const GREENS = ["#27272a", "#0e4429", "#006d32", "#26a641", "#39d353"];
|
||||
|
||||
// Labels are stored SVG-ready: phase II's "&" is pre-escaped since these
|
||||
// strings go straight into markup and nothing else here needs escaping.
|
||||
const PHASES = [
|
||||
{ milestone: 1, name: "I — Linear" },
|
||||
{ milestone: 2, name: "II — Nodal & Grid" },
|
||||
{ milestone: 3, name: "III — Hierarchical" },
|
||||
{ milestone: 4, name: "IV — Relational" },
|
||||
{ milestone: 5, name: "V — Decision Space" },
|
||||
];
|
||||
|
||||
// ── svg helpers ──────────────────────────────────────────────────
|
||||
|
||||
/** Opening tag plus the rounded dark card every chart starts with. */
|
||||
function svgOpen(width: number, height: number): string {
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
|
||||
`viewBox="0 0 ${width} ${height}" font-family="${FONT}">` +
|
||||
`<rect width="${width}" height="${height}" rx="6" fill="${BG}"/>`
|
||||
);
|
||||
}
|
||||
|
||||
/** A <text> element; charts place dozens of these with the same defaults. */
|
||||
function text(
|
||||
x: number,
|
||||
y: number,
|
||||
content: string,
|
||||
attrs: { size?: number; fill?: string; anchor?: string; weight?: string } = {},
|
||||
): string {
|
||||
const { size = 12, fill = FG, anchor = "start", weight } = attrs;
|
||||
const bold = weight ? ` font-weight="${weight}"` : "";
|
||||
return `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" text-anchor="${anchor}"${bold}>${content}</text>`;
|
||||
}
|
||||
|
||||
// ── progress: stacked bar per phase ──────────────────────────────
|
||||
|
||||
interface ProgressRow {
|
||||
milestone: number;
|
||||
set_label: string;
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function progressChart(db: D1Database): Promise<string> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT t.milestone AS milestone, p.set_label AS set_label,
|
||||
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done,
|
||||
COUNT(*) AS total
|
||||
FROM problems p JOIN topics t ON p.topic_issue = t.issue
|
||||
WHERE t.milestone IS NOT NULL
|
||||
GROUP BY t.milestone, p.set_label`,
|
||||
)
|
||||
.all<ProgressRow>();
|
||||
|
||||
// Per phase: core done / optional done / remaining (all-set total − all done).
|
||||
const phases = PHASES.map((phase) => {
|
||||
const rows = results.filter((r) => r.milestone === phase.milestone);
|
||||
const total = rows.reduce((n, r) => n + r.total, 0);
|
||||
const done = rows.reduce((n, r) => n + r.done, 0);
|
||||
return {
|
||||
name: phase.name,
|
||||
coreDone: rows.find((r) => r.set_label === "core")?.done ?? 0,
|
||||
optionalDone: rows.find((r) => r.set_label === "optional")?.done ?? 0,
|
||||
remaining: total - done,
|
||||
done,
|
||||
total,
|
||||
};
|
||||
});
|
||||
|
||||
const width = 640;
|
||||
const height = 300;
|
||||
const barX = 168;
|
||||
const barMaxW = 400;
|
||||
const barH = 22;
|
||||
const rowStep = 48;
|
||||
const scale = Math.max(1, ...phases.map((p) => p.total));
|
||||
|
||||
const parts = [svgOpen(width, height)];
|
||||
|
||||
// Legend on top.
|
||||
const legend: [string, string][] = [
|
||||
[GREEN, "core done"],
|
||||
[BLUE, "optional done"],
|
||||
[LINE, "remaining"],
|
||||
];
|
||||
let lx = barX;
|
||||
for (const [color, label] of legend) {
|
||||
parts.push(`<rect x="${lx}" y="12" width="12" height="12" rx="2" fill="${color}"/>`);
|
||||
parts.push(text(lx + 17, 22, label, { size: 11, fill: MUTED }));
|
||||
lx += 17 + label.length * 7 + 24;
|
||||
}
|
||||
|
||||
phases.forEach((p, i) => {
|
||||
const y = 52 + i * rowStep;
|
||||
const midY = y + barH / 2 + 4;
|
||||
parts.push(text(barX - 12, midY, p.name, { anchor: "end" }));
|
||||
|
||||
let x = barX;
|
||||
const segments: [number, string][] = [
|
||||
[p.coreDone, GREEN],
|
||||
[p.optionalDone, BLUE],
|
||||
[p.remaining, LINE],
|
||||
];
|
||||
for (const [count, color] of segments) {
|
||||
const w = (count / scale) * barMaxW;
|
||||
if (w > 0) {
|
||||
parts.push(`<rect x="${x.toFixed(1)}" y="${y}" width="${w.toFixed(1)}" height="${barH}" fill="${color}"/>`);
|
||||
// Count inside the segment when it fits; tiny slivers stay unlabeled.
|
||||
if (w >= 20) {
|
||||
const labelFill = color === LINE ? MUTED : FG;
|
||||
parts.push(text(x + w / 2, midY, String(count), { size: 11, fill: labelFill, anchor: "middle" }));
|
||||
}
|
||||
x += w;
|
||||
}
|
||||
}
|
||||
parts.push(text(x + 8, midY, `${p.done}/${p.total}`, { size: 11, fill: MUTED }));
|
||||
});
|
||||
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// ── 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`)
|
||||
.all<{ stage: string; count: number }>();
|
||||
|
||||
const counts = STAGES.map((s) => results.find((r) => r.stage === s)?.count ?? 0);
|
||||
|
||||
const width = 640;
|
||||
const height = 220;
|
||||
const plotTop = 26;
|
||||
const plotBottom = height - 32;
|
||||
const plotH = plotBottom - plotTop;
|
||||
const slotW = (width - 80) / STAGES.length;
|
||||
const barW = 64;
|
||||
const scale = Math.max(1, ...counts);
|
||||
|
||||
const parts = [svgOpen(width, height)];
|
||||
|
||||
counts.forEach((count, i) => {
|
||||
const cx = 40 + slotW * i + slotW / 2;
|
||||
const barH = (count / scale) * plotH;
|
||||
const y = plotBottom - barH;
|
||||
if (barH > 0) {
|
||||
parts.push(
|
||||
`<rect x="${(cx - barW / 2).toFixed(1)}" y="${y.toFixed(1)}" width="${barW}" height="${barH.toFixed(1)}" rx="3" fill="${GREENS[i]}"/>`,
|
||||
);
|
||||
}
|
||||
parts.push(text(cx, Math.max(y - 6, 16), String(count), { size: 12, anchor: "middle", weight: "bold" }));
|
||||
parts.push(text(cx, plotBottom + 18, STAGES[i]!, { size: 12, fill: MUTED, anchor: "middle" }));
|
||||
});
|
||||
|
||||
parts.push(`<line x1="40" y1="${plotBottom}" x2="${width - 40}" y2="${plotBottom}" stroke="${LINE}"/>`);
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// ── heatmap: attempts per campaign day ───────────────────────────
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
// Layout in CSS pixels. heatmapPng multiplies every one of these by its device
|
||||
// scale, so the vector and raster pictures cannot drift apart.
|
||||
const HEAT_W = 560;
|
||||
const HEAT_H = 160;
|
||||
const HEAT_GRID_X = 34;
|
||||
const HEAT_GRID_Y = 24;
|
||||
const HEAT_CELL = 16;
|
||||
const HEAT_STEP = 19; // cell + 3px gap
|
||||
|
||||
// Grid row → left-hand label; both renderers print only these three.
|
||||
const WEEKDAYS: [number, string][] = [
|
||||
[0, "Mon"],
|
||||
[2, "Wed"],
|
||||
[4, "Fri"],
|
||||
];
|
||||
|
||||
/** One grid square: column, Mon-based row, and index into GREENS. */
|
||||
interface HeatmapCell {
|
||||
col: number;
|
||||
row: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The heatmap's whole data model — one cell per campaign day, plus the week
|
||||
* count that positions the legend — shared by both renderers.
|
||||
*
|
||||
* Every per-day date derives from one UTC-midnight timestamp, so local-timezone
|
||||
* drift never shifts a cell. The campaign starts on a Monday, so day i sits at
|
||||
* column i/7; the row comes from the real weekday, so an off-Monday start still
|
||||
* lands correctly.
|
||||
*/
|
||||
async function heatmapCells(
|
||||
db: D1Database,
|
||||
start: string,
|
||||
days: number,
|
||||
): Promise<{ cells: HeatmapCell[]; weeks: number }> {
|
||||
const [sy, sm, sd] = start.split("-").map(Number);
|
||||
const base = Date.UTC(sy!, sm! - 1, sd!);
|
||||
const end = new Date(base + (days - 1) * DAY_MS).toISOString().slice(0, 10);
|
||||
|
||||
const { results } = await db
|
||||
.prepare(`SELECT date, COUNT(*) AS attempts FROM attempts WHERE date >= ?1 AND date <= ?2 GROUP BY date`)
|
||||
.bind(start, end)
|
||||
.all<{ date: string; attempts: number }>();
|
||||
|
||||
const byDate = new Map(results.map((r) => [r.date, r.attempts]));
|
||||
|
||||
const cells: HeatmapCell[] = [];
|
||||
for (let i = 0; i < days; i++) {
|
||||
const day = new Date(base + i * DAY_MS);
|
||||
const attempts = byDate.get(day.toISOString().slice(0, 10)) ?? 0;
|
||||
cells.push({
|
||||
col: Math.floor(i / 7),
|
||||
row: (day.getUTCDay() + 6) % 7, // Mon = 0
|
||||
level: Math.min(attempts, 4),
|
||||
});
|
||||
}
|
||||
|
||||
return { cells, weeks: Math.ceil(days / 7) };
|
||||
}
|
||||
|
||||
export async function heatmapChart(db: D1Database, start: string, days: number): Promise<string> {
|
||||
const { cells, weeks } = await heatmapCells(db, start, days);
|
||||
|
||||
const parts = [svgOpen(HEAT_W, HEAT_H)];
|
||||
|
||||
// Week numbers across the top, Mon/Wed/Fri down the left.
|
||||
for (let w = 0; w < weeks; w++) {
|
||||
parts.push(
|
||||
text(HEAT_GRID_X + w * HEAT_STEP + HEAT_CELL / 2, HEAT_GRID_Y - 7, `W${w + 1}`, {
|
||||
size: 10,
|
||||
fill: MUTED,
|
||||
anchor: "middle",
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const [row, label] of WEEKDAYS) {
|
||||
parts.push(
|
||||
text(HEAT_GRID_X - 6, HEAT_GRID_Y + row * HEAT_STEP + HEAT_CELL - 4, label, {
|
||||
size: 10,
|
||||
fill: MUTED,
|
||||
anchor: "end",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const { col, row, level } of cells) {
|
||||
parts.push(
|
||||
`<rect x="${HEAT_GRID_X + col * HEAT_STEP}" y="${HEAT_GRID_Y + row * HEAT_STEP}" width="${HEAT_CELL}" height="${HEAT_CELL}" rx="2" fill="${GREENS[level]}"/>`,
|
||||
);
|
||||
}
|
||||
|
||||
// Less → More ramp fills the space right of the grid.
|
||||
const legendX = HEAT_GRID_X + weeks * HEAT_STEP + 40;
|
||||
const legendY = HEAT_GRID_Y + 3 * HEAT_STEP;
|
||||
parts.push(text(legendX - 6, legendY + HEAT_CELL - 4, "Less", { size: 10, fill: MUTED, anchor: "end" }));
|
||||
GREENS.forEach((color, i) => {
|
||||
parts.push(
|
||||
`<rect x="${legendX + i * HEAT_STEP}" y="${legendY}" width="${HEAT_CELL}" height="${HEAT_CELL}" rx="2" fill="${color}"/>`,
|
||||
);
|
||||
});
|
||||
parts.push(text(legendX + GREENS.length * HEAT_STEP + 3, legendY + HEAT_CELL - 4, "More", { size: 10, fill: MUTED }));
|
||||
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// The PNG carries no alpha channel, so whatever the rounded card does not cover
|
||||
// has to be painted with the colour sitting behind the image: the digest
|
||||
// email's GitHub-dark card.
|
||||
const EMAIL_CARD = "#0d1117";
|
||||
|
||||
// Device-pixel multiplier. The email hands the image the full 552px card
|
||||
// width, so 3x lands a little over 2x density on a retina screen; the labels
|
||||
// stay 10 CSS px, the size of the SVG's. Flat colour compresses to a few KB
|
||||
// either way, so the extra resolution is close to free.
|
||||
const HEAT_SCALE = 3;
|
||||
|
||||
/**
|
||||
* The same picture as heatmapChart, rastered for email.
|
||||
*
|
||||
* Two deliberate departures from the SVG. The canvas is trimmed to the width
|
||||
* the content actually occupies instead of HEAT_W: the README's SVG sits in a
|
||||
* narrow table cell and scales up, whereas the email gives the image the full
|
||||
* 552px card, and the SVG's slack right margin would otherwise shrink the
|
||||
* grid to nothing. And the bitmap font hangs off a top-left origin rather than
|
||||
* a baseline, so each SVG baseline becomes "baseline minus one cap height".
|
||||
*/
|
||||
export async function heatmapPng(db: D1Database, start: string, days: number): Promise<Uint8Array> {
|
||||
const { cells, weeks } = await heatmapCells(db, start, days);
|
||||
|
||||
const s = HEAT_SCALE;
|
||||
const gridX = HEAT_GRID_X * s;
|
||||
const gridY = HEAT_GRID_Y * s;
|
||||
const cell = HEAT_CELL * s;
|
||||
const step = HEAT_STEP * s;
|
||||
const capH = 7 * s;
|
||||
|
||||
const legendX = gridX + weeks * step + 40 * s;
|
||||
const legendY = gridY + 3 * step;
|
||||
const moreX = legendX + GREENS.length * step + 3 * s;
|
||||
const width = moreX + textWidth("More", s) + gridX;
|
||||
const height = HEAT_H * s;
|
||||
|
||||
const canvas = new Canvas(width, height, EMAIL_CARD);
|
||||
canvas.rect(0, 0, width, height, BG, 6 * s);
|
||||
|
||||
for (let w = 0; w < weeks; w++) {
|
||||
const label = `W${w + 1}`;
|
||||
const middle = gridX + w * step + cell / 2;
|
||||
canvas.text(middle - textWidth(label, s) / 2, gridY - 7 * s - capH, label, MUTED, s);
|
||||
}
|
||||
for (const [row, label] of WEEKDAYS) {
|
||||
canvas.text(
|
||||
gridX - 6 * s - textWidth(label, s),
|
||||
gridY + row * step + cell - 4 * s - capH,
|
||||
label,
|
||||
MUTED,
|
||||
s,
|
||||
);
|
||||
}
|
||||
|
||||
for (const { col, row, level } of cells) {
|
||||
canvas.rect(gridX + col * step, gridY + row * step, cell, cell, GREENS[level]!, 2 * s);
|
||||
}
|
||||
|
||||
const legendBase = legendY + cell - 4 * s - capH;
|
||||
canvas.text(legendX - 6 * s - textWidth("Less", s), legendBase, "Less", MUTED, s);
|
||||
GREENS.forEach((color, i) => {
|
||||
canvas.rect(legendX + i * step, legendY, cell, cell, color, 2 * s);
|
||||
});
|
||||
canvas.text(moreX, legendBase, "More", MUTED, s);
|
||||
|
||||
return await canvas.encode();
|
||||
}
|
||||
|
||||
// ── gate badge: shieldcn-style dark pill ─────────────────────────
|
||||
|
||||
export async function gateBadge(db: D1Database): Promise<string> {
|
||||
const row = await db
|
||||
.prepare(`SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1`)
|
||||
.first<{ pass_rate: number }>();
|
||||
|
||||
const label = "gate";
|
||||
const value = row ? `${Math.round(row.pass_rate * 100)}%` : "none yet";
|
||||
const valueFill = row ? (row.pass_rate >= 0.7 ? "#4ade80" : RED) : MUTED;
|
||||
|
||||
// shieldcn geometry: height 32, rx 6, one flat zinc-900 pill. Verdana 13px
|
||||
// ≈ 7.5px per char; 12px outer padding, 8px between label and value.
|
||||
const labelW = Math.round(label.length * 7.5);
|
||||
const valueW = Math.round(value.length * 7.5);
|
||||
const total = 12 + labelW + 8 + valueW + 12;
|
||||
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="32" ` +
|
||||
`viewBox="0 0 ${total} 32" role="img" aria-label="${label}: ${value}" font-family="${FONT}">` +
|
||||
`<rect width="${total}" height="32" rx="6" fill="#18181b"/>` +
|
||||
`<g font-size="13">` +
|
||||
`<text x="12" y="21" fill="${FG}" fill-opacity=".7">${label}</text>` +
|
||||
`<text x="${12 + labelW + 8}" y="21" fill="${valueFill}" font-weight="bold">${value}</text>` +
|
||||
`</g>` +
|
||||
`</svg>`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* The daily digest email — built from D1 + the bundled schedule, sent at
|
||||
* 8 AM ET via the send_email binding. This module owns data only: it turns
|
||||
* D1 rows into a `DigestData` and hands it to email.tsx, which renders both
|
||||
* 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.
|
||||
*
|
||||
* Idempotency: email_log keys sends by ET date — a same-day re-send is a
|
||||
* no-op unless forced; the body itself is deterministic (drill picks are
|
||||
* seeded by the date). Levelling keeps that: it runs once, before the send,
|
||||
* and a re-run finds nothing left over the cap.
|
||||
*/
|
||||
import {
|
||||
CAMPAIGN_DAYS,
|
||||
type ProblemRow,
|
||||
REVIEW_CAP,
|
||||
SCHEDULE,
|
||||
addDays,
|
||||
campaignDay,
|
||||
campaignWeek,
|
||||
dueReviews,
|
||||
isoWeek,
|
||||
levelReviews,
|
||||
pickDrills,
|
||||
prettyDate,
|
||||
streak,
|
||||
weekdayOf,
|
||||
} from "./srs.ts";
|
||||
import { type DigestData, type DigestRow, renderDigest } from "./email.tsx";
|
||||
import { signLink } from "./links.ts";
|
||||
|
||||
/** Total daily ceiling: drills only fill what the whole due queue leaves. */
|
||||
const DAILY_CAP = 6;
|
||||
|
||||
export interface Digest {
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** A problem row plus its signed one-tap pass/fail URLs. */
|
||||
async function tapRow(env: Env, p: ProblemRow, date: string, staged: boolean): Promise<DigestRow> {
|
||||
const [pass, fail] = await Promise.all([
|
||||
signLink(env.LINK_KEY, p.lc_number, "pass", date),
|
||||
signLink(env.LINK_KEY, p.lc_number, "fail", date),
|
||||
]);
|
||||
const base = `${env.PUBLIC_URL}/log?p=${p.lc_number}&d=${date}&r=`;
|
||||
return {
|
||||
lc: p.lc_number,
|
||||
difficulty: p.difficulty,
|
||||
...(staged ? { stage: p.stage } : {}),
|
||||
passUrl: `${base}pass&sig=${pass}`,
|
||||
failUrl: `${base}fail&sig=${fail}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
// Reviews surface at most REVIEW_CAP; drills fill toward DAILY_CAP only
|
||||
// when the WHOLE due queue is light — a heavy backlog must never trade
|
||||
// review slots for brand-new drill problems.
|
||||
const due = rest ? [] : await dueReviews(db, date);
|
||||
const capped = due.slice(0, REVIEW_CAP);
|
||||
const drills = rest ? [] : 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,
|
||||
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))),
|
||||
yesterday: { total: 0, failed: [] },
|
||||
heatmapUrl: `${env.PUBLIC_URL}/chart/heatmap.png`,
|
||||
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) {
|
||||
const topic = await db
|
||||
.prepare("SELECT name FROM topics WHERE issue = ?")
|
||||
.bind(topicIssue)
|
||||
.first<{ name: string }>();
|
||||
const { results: core } = await db
|
||||
.prepare(
|
||||
"SELECT * FROM problems WHERE topic_issue = ? AND set_label = 'core' ORDER BY lc_number",
|
||||
)
|
||||
.bind(topicIssue)
|
||||
.all<ProblemRow>();
|
||||
data.topic = {
|
||||
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",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Saturday: the review issue already exists (created at midnight ET).
|
||||
if (weekdayOf(date) === 6) {
|
||||
const gate = await db
|
||||
.prepare("SELECT issue FROM gates WHERE week = ? AND issue IS NOT NULL")
|
||||
.bind(isoWeek(date))
|
||||
.first<{ issue: number }>();
|
||||
if (gate) {
|
||||
data.gate = {
|
||||
week: campaignWeek(date),
|
||||
url: `https://github.com/${env.REPO}/issues/${gate.issue}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Footer: yesterday's log summarised (failures named — they are the
|
||||
// actionable part) and the gate rate to date.
|
||||
const { results: logged } = await db
|
||||
.prepare("SELECT lc_number, result FROM attempts WHERE date = ? ORDER BY id")
|
||||
.bind(addDays(date, -1))
|
||||
.all<{ lc_number: number; result: string }>();
|
||||
data.yesterday = {
|
||||
total: logged.length,
|
||||
failed: logged.filter((a) => a.result !== "pass").map((a) => a.lc_number),
|
||||
};
|
||||
const lastGate = await db
|
||||
.prepare("SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1")
|
||||
.first<{ pass_rate: number }>();
|
||||
if (lastGate) data.gateRate = lastGate.pass_rate;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function buildDigest(env: Env, date: string): Promise<Digest> {
|
||||
const data = await collectDigest(env, date);
|
||||
const { html, text } = await renderDigest(data);
|
||||
return {
|
||||
subject: `(Day ${campaignDay(date)}/${CAMPAIGN_DAYS}) LeetCode Daily Digest`,
|
||||
html,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SendReport {
|
||||
sent: boolean;
|
||||
reason: string;
|
||||
digest: Digest;
|
||||
}
|
||||
|
||||
/** Send today's digest exactly once per ET date (unless forced). */
|
||||
export async function sendDigest(
|
||||
env: Env,
|
||||
date: string,
|
||||
opts: { force?: boolean; dry?: boolean } = {},
|
||||
): Promise<SendReport> {
|
||||
const digest = await buildDigest(env, date);
|
||||
if (opts.dry) return { sent: false, reason: "dry run", digest };
|
||||
|
||||
const already = await env.DB.prepare("SELECT sent_at FROM email_log WHERE date = ?")
|
||||
.bind(date)
|
||||
.first();
|
||||
if (already && !opts.force) return { sent: false, reason: "already sent today", digest };
|
||||
|
||||
// The digest above was built against the un-levelled queue (so `carried`
|
||||
// reports the real backlog); now give everything past the cap its future
|
||||
// date. Dry runs never reach here, and a forced re-send finds nothing left.
|
||||
await levelReviews(env.DB, date);
|
||||
|
||||
await env.EMAIL.send({
|
||||
to: env.TO_EMAIL,
|
||||
from: { email: env.FROM_EMAIL, name: "SRS" },
|
||||
subject: digest.subject,
|
||||
html: digest.html,
|
||||
text: digest.text,
|
||||
});
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO email_log (date, sent_at) VALUES (?, ?) ON CONFLICT(date) DO UPDATE SET sent_at = excluded.sent_at",
|
||||
)
|
||||
.bind(date, new Date().toISOString())
|
||||
.run();
|
||||
return { sent: true, reason: already ? "forced re-send" : "sent", digest };
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
/**
|
||||
* Presentation layer for the daily digest — React Email components rendered
|
||||
* to HTML (and to the plain-text alternative) inside the Worker.
|
||||
*
|
||||
* This file owns *only* layout and wording. Every number, URL and signature
|
||||
* is computed in digest.ts and handed over as `DigestData`, so the retrieval
|
||||
* rules (review/drill rows carry number + difficulty only — never the topic,
|
||||
* never a solution link) are enforced by what the data model can express:
|
||||
* `DigestRow` has no title and no issue field.
|
||||
*
|
||||
* Dark by design. The theme is hard-coded rather than left to the client's
|
||||
* dark-mode heuristics: `color-scheme: dark` tells Apple Mail and Outlook not
|
||||
* to re-invert it, and the canvas colour is painted by a full-width <Section>
|
||||
* table because Gmail drops styles on <body>.
|
||||
*
|
||||
* Other email constraints that shape the markup: inline styles only (clients
|
||||
* strip <style>), real <table> layout for the problem lists so Outlook and
|
||||
* Gmail agree on column alignment, React Email's <Button> for the one-tap
|
||||
* links (it emits the MSO padding conditionals a bare <a> lacks), and the
|
||||
* heatmap arrives as PNG — every major client refuses remote SVG.
|
||||
*/
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { render } from "@react-email/render";
|
||||
import type { CSSProperties } from "react";
|
||||
import { REVIEW_CAP } from "./srs.ts";
|
||||
|
||||
// ── palette (GitHub dark) ────────────────────────────────────────
|
||||
const CANVAS = "#010409";
|
||||
const CARD = "#0d1117";
|
||||
const BORDER = "#30363d";
|
||||
const RULE = "#21262d";
|
||||
const INK = "#e6edf3";
|
||||
const MUTED = "#8b949e";
|
||||
const LINK = "#58a6ff";
|
||||
const GREEN = "#3fb950";
|
||||
const AMBER = "#d29922";
|
||||
const RED = "#f85149";
|
||||
const PASS_BG = "#238636";
|
||||
const FAIL_BG = "#da3633";
|
||||
const GATE_BG = "#1f6feb";
|
||||
|
||||
const FONT = '-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif';
|
||||
|
||||
// Outlook's Word engine is unreliable with text-transform, so display labels
|
||||
// are table lookups rather than a CSS trick.
|
||||
const DIFFICULTY: Record<string, { label: string; color: string }> = {
|
||||
easy: { label: "Easy", color: GREEN },
|
||||
medium: { label: "Medium", color: AMBER },
|
||||
hard: { label: "Hard", color: RED },
|
||||
};
|
||||
|
||||
// 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",
|
||||
"+3": "3 days ago",
|
||||
"+7": "7 days ago",
|
||||
retired: "retired",
|
||||
};
|
||||
|
||||
// ── data model ───────────────────────────────────────────────────
|
||||
|
||||
/** A review or drill line: number + difficulty + one-tap links, nothing else. */
|
||||
export interface DigestRow {
|
||||
lc: number;
|
||||
difficulty: string;
|
||||
/** Reviews only — drills are unstaged by design. */
|
||||
stage?: string;
|
||||
passUrl: string;
|
||||
failUrl: string;
|
||||
}
|
||||
|
||||
/** A learning-day core problem — the only rows allowed a link. */
|
||||
export interface TopicRow {
|
||||
lc: number;
|
||||
difficulty: string;
|
||||
url: string;
|
||||
solved: boolean;
|
||||
}
|
||||
|
||||
export interface DigestData {
|
||||
/** "Tuesday, August 25" */
|
||||
day: string;
|
||||
/** "Day 9 of 56 · Week 2" */
|
||||
progress: string;
|
||||
streak: number;
|
||||
rest: boolean;
|
||||
topic?: { name: string; core: TopicRow[] };
|
||||
reviews: DigestRow[];
|
||||
/** Reviews past the daily cap; levelled onto the coming days. */
|
||||
carried: number;
|
||||
drills: DigestRow[];
|
||||
gate?: { week: number; url: string };
|
||||
yesterday: { total: number; failed: number[] };
|
||||
gateRate?: number;
|
||||
/** PNG, not SVG — email clients refuse the latter. */
|
||||
heatmapUrl: string;
|
||||
progressUrl: string;
|
||||
}
|
||||
|
||||
// ── styles ───────────────────────────────────────────────────────
|
||||
|
||||
const page: CSSProperties = { backgroundColor: CANVAS, padding: "28px 0" };
|
||||
const card: CSSProperties = {
|
||||
backgroundColor: CARD,
|
||||
border: `1px solid ${BORDER}`,
|
||||
borderRadius: "10px",
|
||||
margin: "0 auto",
|
||||
maxWidth: "600px",
|
||||
padding: "28px 24px",
|
||||
};
|
||||
const h1: CSSProperties = { color: INK, fontSize: "20px", fontWeight: 600, lineHeight: "26px", margin: 0 };
|
||||
const label: CSSProperties = {
|
||||
color: MUTED,
|
||||
fontSize: "11px",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.8px",
|
||||
margin: "0 0 10px",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
const hint: CSSProperties = { color: MUTED, fontSize: "13px", lineHeight: "19px", margin: "0 0 12px" };
|
||||
const th: CSSProperties = {
|
||||
borderBottom: `1px solid ${BORDER}`,
|
||||
color: MUTED,
|
||||
fontSize: "12px",
|
||||
fontWeight: 400,
|
||||
padding: "0 0 8px",
|
||||
textAlign: "left",
|
||||
};
|
||||
const td: CSSProperties = {
|
||||
borderBottom: `1px solid ${RULE}`,
|
||||
color: INK,
|
||||
fontSize: "14px",
|
||||
padding: "12px 0",
|
||||
textAlign: "left",
|
||||
};
|
||||
const tap: CSSProperties = {
|
||||
borderRadius: "6px",
|
||||
color: "#ffffff",
|
||||
display: "inline-block",
|
||||
fontSize: "12px",
|
||||
fontWeight: 600,
|
||||
lineHeight: "12px",
|
||||
padding: "9px 15px",
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
/**
|
||||
* A problem list as a real table. The "last seen" column appears only for
|
||||
* reviews, which keeps drills at three columns on a phone.
|
||||
*/
|
||||
function ProblemTable({ rows, staged }: { rows: DigestRow[]; staged: boolean }) {
|
||||
return (
|
||||
<table
|
||||
width="100%"
|
||||
border={0}
|
||||
cellPadding={0}
|
||||
cellSpacing={0}
|
||||
style={{ borderCollapse: "collapse", width: "100%" }}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={th}>Problem</th>
|
||||
<th style={th}>Level</th>
|
||||
{staged ? <th style={th}>Last seen</th> : null}
|
||||
<th style={{ ...th, textAlign: "right" }}>How did it go?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const difficulty = DIFFICULTY[row.difficulty];
|
||||
return (
|
||||
<tr key={row.lc}>
|
||||
<td style={{ ...td, fontWeight: 600 }}>LC {row.lc}</td>
|
||||
<td style={{ ...td, color: difficulty?.color ?? INK }}>{difficulty?.label ?? row.difficulty}</td>
|
||||
{staged ? (
|
||||
<td style={{ ...td, color: MUTED }}>{(row.stage && LAST_SEEN[row.stage]) ?? row.stage}</td>
|
||||
) : null}
|
||||
<td style={{ ...td, textAlign: "right", whiteSpace: "nowrap" }}>
|
||||
<Button href={row.passUrl} style={{ ...tap, backgroundColor: PASS_BG }}>
|
||||
got it
|
||||
</Button>
|
||||
<Button href={row.failUrl} style={{ ...tap, backgroundColor: FAIL_BG, marginLeft: "6px" }}>
|
||||
missed it
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The learning day's core list as the same table shape as reviews — this is
|
||||
* the one section allowed to link problems, and ticks replace tap buttons.
|
||||
*/
|
||||
function TopicTable({ rows }: { rows: TopicRow[] }) {
|
||||
return (
|
||||
<table
|
||||
width="100%"
|
||||
border={0}
|
||||
cellPadding={0}
|
||||
cellSpacing={0}
|
||||
style={{ borderCollapse: "collapse", width: "100%" }}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={th}>Problem</th>
|
||||
<th style={th}>Level</th>
|
||||
<th style={{ ...th, textAlign: "right" }}>Solved</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((p) => {
|
||||
const difficulty = DIFFICULTY[p.difficulty];
|
||||
return (
|
||||
<tr key={p.lc}>
|
||||
<td style={{ ...td, fontWeight: 600 }}>
|
||||
<Link href={p.url} style={{ color: p.solved ? MUTED : LINK, textDecoration: "none" }}>
|
||||
LC {p.lc}
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ ...td, color: difficulty?.color ?? INK }}>{difficulty?.label ?? p.difficulty}</td>
|
||||
<td style={{ ...td, color: p.solved ? GREEN : MUTED, textAlign: "right" }}>
|
||||
{p.solved ? "✓" : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function Block({
|
||||
title,
|
||||
note,
|
||||
rows,
|
||||
staged,
|
||||
empty,
|
||||
}: {
|
||||
title: string;
|
||||
note: string;
|
||||
rows: DigestRow[];
|
||||
staged: boolean;
|
||||
empty: string;
|
||||
}) {
|
||||
return (
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>{title}</Text>
|
||||
{rows.length === 0 ? (
|
||||
<Text style={{ ...hint, margin: 0 }}>{empty}</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={hint}>{note}</Text>
|
||||
<ProblemTable rows={rows} staged={staged} />
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 passed = data.yesterday.total - data.yesterday.failed.length;
|
||||
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
|
||||
|
||||
return (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Head>
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="supported-color-schemes" content="dark" />
|
||||
</Head>
|
||||
<Preview>{preview}</Preview>
|
||||
<Body style={{ backgroundColor: CANVAS, fontFamily: FONT, margin: 0, padding: 0 }}>
|
||||
<Section style={page}>
|
||||
<Container style={card}>
|
||||
<Heading as="h1" style={h1}>
|
||||
{data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`}
|
||||
</Heading>
|
||||
<Text style={{ color: MUTED, fontSize: "13px", margin: "8px 0 0" }}>
|
||||
{data.progress}
|
||||
{data.rest
|
||||
? " · 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.`}
|
||||
</Text>
|
||||
|
||||
{data.rest ? null : (
|
||||
<>
|
||||
{data.topic ? (
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Something new today</Text>
|
||||
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
||||
{data.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} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Block
|
||||
title="Time to see these again"
|
||||
note="Solve each one from scratch, and resist opening your old answer first."
|
||||
rows={data.reviews}
|
||||
staged
|
||||
empty="Nothing is due for review today."
|
||||
/>
|
||||
{data.carried > 0 ? (
|
||||
<Text style={{ ...hint, margin: "12px 0 0" }}>
|
||||
{data.carried} more {data.carried === 1 ? "is" : "are"} waiting — spread over the
|
||||
coming days, oldest first, never more than {REVIEW_CAP} a day.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Block
|
||||
title="Cold start, no hints"
|
||||
note="You are not told the topic. Say the pattern out loud before you write a line."
|
||||
rows={data.drills}
|
||||
staged={false}
|
||||
empty="No cold starts today."
|
||||
/>
|
||||
|
||||
{data.gate ? (
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Weekly checkpoint</Text>
|
||||
<Text style={hint}>Timed and blind. Close the issue when you are done.</Text>
|
||||
<Button
|
||||
href={data.gate.url}
|
||||
style={{ ...tap, backgroundColor: GATE_BG, fontSize: "13px", padding: "11px 18px" }}
|
||||
>
|
||||
Open week {data.gate.week} review
|
||||
</Button>
|
||||
</Section>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Eight weeks at a glance — the one thing here that is pure
|
||||
encouragement rather than instruction. */}
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Every day you showed up</Text>
|
||||
<Link href={data.progressUrl}>
|
||||
<Img
|
||||
src={data.heatmapUrl}
|
||||
alt="Attempt heatmap across the eight-week campaign"
|
||||
width="552"
|
||||
style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }}
|
||||
/>
|
||||
</Link>
|
||||
</Section>
|
||||
|
||||
<Hr style={{ borderColor: BORDER, margin: "28px 0 18px" }} />
|
||||
<Text style={{ color: MUTED, fontSize: "12px", lineHeight: "19px", margin: 0 }}>
|
||||
{data.yesterday.total === 0
|
||||
? "You did not log anything yesterday."
|
||||
: `Yesterday you logged ${data.yesterday.total} and got ${passed} of them` +
|
||||
(missed ? `; ${missed} got away.` : ".")}{" "}
|
||||
{data.gateRate === undefined
|
||||
? "No checkpoints scored yet."
|
||||
: `Checkpoints are running at ${Math.round(data.gateRate * 100)}%.`}{" "}
|
||||
<Link href={data.progressUrl} style={{ color: LINK, textDecoration: "none" }}>
|
||||
See the full picture →
|
||||
</Link>
|
||||
</Text>
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plain-text alternative. React Email's `plainText` mode flattens the
|
||||
* problem tables into one unreadable run, so text gets its own writer — fed
|
||||
* by the same `DigestData`, so the numbers and links can never disagree with
|
||||
* the HTML even though the wording lives in two places.
|
||||
*/
|
||||
function plainDigest(data: DigestData): string {
|
||||
const out: string[] = [
|
||||
data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`,
|
||||
data.progress +
|
||||
(data.rest
|
||||
? " · 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.`),
|
||||
];
|
||||
|
||||
if (!data.rest) {
|
||||
if (data.topic) {
|
||||
out.push(
|
||||
"",
|
||||
`SOMETHING NEW TODAY — ${data.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}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const lists: [string, string, DigestRow[], string][] = [
|
||||
[
|
||||
"TIME TO SEE THESE AGAIN",
|
||||
"Solve each one from scratch, and resist opening your old answer first.",
|
||||
data.reviews,
|
||||
"Nothing is due for review today.",
|
||||
],
|
||||
[
|
||||
"COLD START, NO HINTS",
|
||||
"You are not told the topic. Say the pattern out loud before you write a line.",
|
||||
data.drills,
|
||||
"No cold starts today.",
|
||||
],
|
||||
];
|
||||
for (const [title, note, rows, empty] of lists) {
|
||||
out.push("", title);
|
||||
if (rows.length === 0) {
|
||||
out.push(empty);
|
||||
continue;
|
||||
}
|
||||
out.push(note);
|
||||
for (const row of rows) {
|
||||
const seen = row.stage ? `, last seen ${LAST_SEEN[row.stage] ?? row.stage}` : "";
|
||||
out.push(
|
||||
` LC ${row.lc} — ${DIFFICULTY[row.difficulty]?.label ?? row.difficulty}${seen}`,
|
||||
` got it: ${row.passUrl}`,
|
||||
` missed it: ${row.failUrl}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.carried > 0) {
|
||||
out.push(
|
||||
"",
|
||||
`${data.carried} more ${data.carried === 1 ? "is" : "are"} waiting — spread over the coming days, oldest first, never more than ${REVIEW_CAP} a day.`,
|
||||
);
|
||||
}
|
||||
if (data.gate) {
|
||||
out.push(
|
||||
"",
|
||||
"WEEKLY CHECKPOINT",
|
||||
"Timed and blind. Close the issue when you are done.",
|
||||
` Week ${data.gate.week} review — ${data.gate.url}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const passed = data.yesterday.total - data.yesterday.failed.length;
|
||||
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
|
||||
out.push(
|
||||
"",
|
||||
"─".repeat(48),
|
||||
data.yesterday.total === 0
|
||||
? "You did not log anything yesterday."
|
||||
: `Yesterday you logged ${data.yesterday.total} and got ${passed} of them` +
|
||||
(missed ? `; ${missed} got away.` : "."),
|
||||
data.gateRate === undefined
|
||||
? "No checkpoints scored yet."
|
||||
: `Checkpoints are running at ${Math.round(data.gateRate * 100)}%.`,
|
||||
`Every day you showed up: ${data.heatmapUrl}`,
|
||||
`See the full picture: ${data.progressUrl}`,
|
||||
);
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTML body plus its plain-text alternative — always sent as a pair so a
|
||||
* client that refuses HTML still gets the same problems and the same links.
|
||||
*/
|
||||
export async function renderDigest(data: DigestData): Promise<{ html: string; text: string }> {
|
||||
return { html: await render(<DigestEmail data={data} />), text: plainDigest(data) };
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Saturday review issue: created at midnight ET so the 8 AM digest can link
|
||||
* to it; scored when the issue closes (webhook).
|
||||
*
|
||||
* The gate half is a topic-blind quiz — one unsolved optional problem per
|
||||
* topic scheduled this week (which is why daily drills only draw from
|
||||
* earlier weeks). The recap half is generated data, deliberately NOT a task
|
||||
* list: the week's solved problems are already scheduled by the +3/+7 ladder,
|
||||
* and re-assigning them on Saturday would be massed practice.
|
||||
*
|
||||
* Label is `review` (the retired Actions system owned `gate`). Creation is
|
||||
* idempotent by week; deferred Hards never appear.
|
||||
*/
|
||||
import {
|
||||
type ProblemRow,
|
||||
type Result,
|
||||
SCHEDULE,
|
||||
campaignWeek,
|
||||
isoWeek,
|
||||
rng,
|
||||
sample,
|
||||
streak,
|
||||
weekMonday,
|
||||
} from "./srs.ts";
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
const PASS_TARGET = 0.7;
|
||||
const REVIEW_LABEL = "review";
|
||||
const MAX_GATE_PROBLEMS = 5;
|
||||
|
||||
function capitalized(difficulty: string): string {
|
||||
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
|
||||
}
|
||||
|
||||
/** Unsolved problems of a topic in a set — never attempted, never drilled. */
|
||||
async function freshPool(db: D1Database, topic: number, set: string): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM problems
|
||||
WHERE topic_issue = ?1 AND set_label = ?2 AND stage = 'new' AND defer_until IS NULL
|
||||
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = problems.lc_number)
|
||||
ORDER BY lc_number`,
|
||||
)
|
||||
.bind(topic, set)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface CreateReport {
|
||||
created: boolean;
|
||||
issue?: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export async function createReviewIssue(env: Env, gh: GitHub, date: string): Promise<CreateReport> {
|
||||
const db = env.DB;
|
||||
const week = campaignWeek(date);
|
||||
const iso = isoWeek(date);
|
||||
const title = `Review — Week ${week}`;
|
||||
|
||||
const existing = await db
|
||||
.prepare("SELECT issue FROM gates WHERE week = ? AND issue IS NOT NULL")
|
||||
.bind(iso)
|
||||
.first<{ issue: number }>();
|
||||
if (existing) {
|
||||
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}`);
|
||||
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 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` };
|
||||
|
||||
// Recap: everything attempted this week, plus streak — data, not tasks.
|
||||
const monday = weekMonday(date);
|
||||
const { results: attempts } = await db
|
||||
.prepare(
|
||||
`SELECT a.lc_number, a.date, a.kind, a.result FROM attempts a
|
||||
WHERE a.date >= ? AND a.date < ? AND a.source != 'import'
|
||||
ORDER BY a.date, a.id`,
|
||||
)
|
||||
.bind(monday, date)
|
||||
.all<{ lc_number: number; date: string; kind: string; result: string }>();
|
||||
const reviewsDone = attempts.filter((a) => a.kind === "review" && a.result === "pass").length;
|
||||
const currentStreak = await streak(db, date);
|
||||
|
||||
const gateLines = picks.map(
|
||||
({ p, fallback }) =>
|
||||
`- LC ${p.lc_number} — ${capitalized(p.difficulty)}${fallback ? " *(core fallback — optional pool exhausted)*" : ""}`,
|
||||
);
|
||||
const recapLines = attempts.length
|
||||
? attempts.map(
|
||||
(a) => `| LC ${a.lc_number} | ${a.kind} | ${a.result === "pass" ? "✅" : "❌"} | ${a.date} |`,
|
||||
)
|
||||
: ["| — | no attempts logged this week | | |"];
|
||||
const body = [
|
||||
"## Gate — blind set",
|
||||
`**Target: ${Math.round(PASS_TARGET * 100)}% first-attempt pass rate. Timed. Narrate out loud.**`,
|
||||
"No topics given. Log with `/done <n> pass|fail`, then close this issue.",
|
||||
...gateLines,
|
||||
"",
|
||||
`## Week ${week} recap`,
|
||||
`${attempts.length} attempts · ${reviewsDone} reviews passed · streak ${currentStreak}`,
|
||||
"",
|
||||
"| Problem | Kind | Result | Day |",
|
||||
"| --- | --- | --- | --- |",
|
||||
...recapLines,
|
||||
].join("\n");
|
||||
|
||||
// Ensure the `review` label exists (422 = already there).
|
||||
try {
|
||||
await gh.rest(`/repos/${gh.repo}/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: REVIEW_LABEL,
|
||||
color: "5319e7",
|
||||
description: "Weekly blind gate + recap",
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!String(err).includes("422")) throw err;
|
||||
}
|
||||
|
||||
const milestone = await db
|
||||
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
|
||||
.bind(topics[0])
|
||||
.first<{ milestone: number }>();
|
||||
|
||||
const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
body,
|
||||
labels: [REVIEW_LABEL],
|
||||
milestone: milestone?.milestone,
|
||||
}),
|
||||
})) as { number: number };
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO gates (week, issue, problems) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(week) DO UPDATE SET issue = ?2, problems = ?3`,
|
||||
)
|
||||
.bind(iso, created.number, JSON.stringify(picks.map(({ p }) => p.lc_number)))
|
||||
.run();
|
||||
|
||||
// Last week's boosted topics had their remedial week — clear the flags.
|
||||
await db.prepare("UPDATE topics SET boost = 0 WHERE boost = 1").run();
|
||||
|
||||
return { created: true, issue: created.number, reason: `created #${created.number}` };
|
||||
}
|
||||
|
||||
export interface ScoreReport {
|
||||
scored: boolean;
|
||||
rate?: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grade a closed review issue: first-attempt pass rate over its gate set.
|
||||
* Unlogged problems count as failures — skipping a gate problem is not a
|
||||
* pass. Boosts topics that failed a gate problem or reached 2 drill misses;
|
||||
* miss counters reset once consumed. The badge/charts read D1 live, so
|
||||
* "refreshing the badge" is this row update.
|
||||
*/
|
||||
export async function scoreReview(
|
||||
env: Env,
|
||||
gh: GitHub,
|
||||
issueNumber: number,
|
||||
date: string,
|
||||
): Promise<ScoreReport> {
|
||||
const db = env.DB;
|
||||
const gate = await db
|
||||
.prepare("SELECT week, problems, pass_rate FROM gates WHERE issue = ?")
|
||||
.bind(issueNumber)
|
||||
.first<{ week: number; problems: string; pass_rate: number | null }>();
|
||||
if (!gate) return { scored: false, reason: `issue #${issueNumber} has no gate record` };
|
||||
if (gate.pass_rate !== null) return { scored: false, rate: gate.pass_rate, reason: "already scored" };
|
||||
|
||||
const lcs: number[] = JSON.parse(gate.problems);
|
||||
const results: Record<number, Result | undefined> = {};
|
||||
for (const lc of lcs) {
|
||||
const row = await db
|
||||
.prepare("SELECT result FROM attempts WHERE lc_number = ? AND kind = 'gate' ORDER BY id LIMIT 1")
|
||||
.bind(lc)
|
||||
.first<{ result: Result }>();
|
||||
results[lc] = row?.result;
|
||||
}
|
||||
const passes = lcs.filter((lc) => results[lc] === "pass");
|
||||
const unlogged = lcs.filter((lc) => results[lc] === undefined);
|
||||
const rate = passes.length / lcs.length;
|
||||
|
||||
await db
|
||||
.prepare("UPDATE gates SET pass_rate = ?, closed_on = ? WHERE issue = ?")
|
||||
.bind(rate, date, issueNumber)
|
||||
.run();
|
||||
|
||||
const boosted: number[] = [];
|
||||
for (const lc of lcs) {
|
||||
if (results[lc] !== "fail") continue;
|
||||
const p = await db
|
||||
.prepare("SELECT topic_issue FROM problems WHERE lc_number = ?")
|
||||
.bind(lc)
|
||||
.first<{ topic_issue: number }>();
|
||||
if (p) boosted.push(p.topic_issue);
|
||||
}
|
||||
const { results: missed } = await db
|
||||
.prepare("SELECT issue FROM topics WHERE misses >= 2")
|
||||
.all<{ issue: number }>();
|
||||
boosted.push(...missed.map((m) => m.issue));
|
||||
const boostSet = [...new Set(boosted)];
|
||||
if (boostSet.length) {
|
||||
await db.batch(
|
||||
boostSet.map((t) => db.prepare("UPDATE topics SET boost = 1, misses = 0 WHERE issue = ?").bind(t)),
|
||||
);
|
||||
}
|
||||
|
||||
const passed = rate >= PASS_TARGET;
|
||||
const summary = [
|
||||
`## Gate — Week ${gate.week}: **${Math.round(rate * 100)}%** first-attempt (target ${Math.round(PASS_TARGET * 100)}%) — ${passed ? "✅ pass" : "❌ fail"}`,
|
||||
"",
|
||||
...lcs.map((lc) => {
|
||||
const r = results[lc];
|
||||
return `- LC ${lc} — ${r === "pass" ? "✅ pass" : r === "fail" ? "❌ fail" : "⬜ not logged (counted as fail)"}`;
|
||||
}),
|
||||
"",
|
||||
unlogged.length ? `${unlogged.length} problem(s) were never logged.` : "",
|
||||
boostSet.length
|
||||
? `Boosted topics for next week's drills: ${boostSet.map((t) => `#${t}`).join(", ")}.`
|
||||
: "No topics boosted.",
|
||||
"",
|
||||
`Live charts: ${env.PUBLIC_URL}/chart/progress.svg · progress: ${env.DOCS_URL}/progress`,
|
||||
]
|
||||
.filter((l) => l !== "")
|
||||
.join("\n");
|
||||
await gh.comment(issueNumber, summary);
|
||||
|
||||
return { scored: true, rate, reason: `scored ${Math.round(rate * 100)}%` };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* GitHub REST + GraphQL client on GH_PAT. Hand-rolled fetch, no SDK —
|
||||
* matching the repo's dependency-free automation rule.
|
||||
*/
|
||||
|
||||
export interface GitHub {
|
||||
repo: string;
|
||||
rest(path: string, init?: RequestInit): Promise<unknown>;
|
||||
graphql(query: string, variables?: Record<string, unknown>): Promise<unknown>;
|
||||
list(path: string): AsyncGenerator<unknown, void, void>;
|
||||
/** Comment first, then close — a failed close still leaves a visible note. */
|
||||
closeIssue(issue: number, comment: string): Promise<void>;
|
||||
comment(issue: number, body: string): Promise<void>;
|
||||
react(commentId: number, content: string): Promise<void>;
|
||||
}
|
||||
|
||||
const PER_PAGE = 100;
|
||||
|
||||
export function github(token: string, repo: string): GitHub {
|
||||
async function rest(path: string, init: RequestInit = {}): Promise<unknown> {
|
||||
const res = await fetch(`https://api.github.com${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
accept: "application/vnd.github+json",
|
||||
authorization: `Bearer ${token}`,
|
||||
"user-agent": "srs-api",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
...(init.body ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`);
|
||||
}
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
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",
|
||||
"user-agent": "srs-api",
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
async function* list(path: string): AsyncGenerator<unknown, void, void> {
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
for (let page = 1; ; page++) {
|
||||
const batch = await rest(`${path}${sep}per_page=${PER_PAGE}&page=${page}`);
|
||||
if (!Array.isArray(batch)) throw new Error(`unexpected ${path} payload: not an array`);
|
||||
yield* batch;
|
||||
if (batch.length < PER_PAGE) return;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repo,
|
||||
rest,
|
||||
graphql,
|
||||
list,
|
||||
async closeIssue(issue, comment) {
|
||||
await rest(`/repos/${repo}/issues/${issue}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: comment }),
|
||||
});
|
||||
await rest(`/repos/${repo}/issues/${issue}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
|
||||
});
|
||||
},
|
||||
async comment(issue, body) {
|
||||
await rest(`/repos/${repo}/issues/${issue}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
},
|
||||
async react(commentId, content) {
|
||||
await rest(`/repos/${repo}/issues/comments/${commentId}/reactions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* SRS Worker entry: fetch routes + DST-proof cron dispatch.
|
||||
*
|
||||
* Direction of truth: D1 owns SRS state; GitHub issues own the catalog;
|
||||
* the bundled schedule.json owns the calendar. Every handler writes D1
|
||||
* first and runs GitHub/Project side effects afterwards via ctx.waitUntil —
|
||||
* a mirror failure logs a warning and never loses a state write.
|
||||
*
|
||||
* Writes are owner-only (webhook author check, HMAC one-tap links, bearer
|
||||
* admin routes); everything else is read-only public.
|
||||
*/
|
||||
import { reconcileCatalog } from "./catalog.ts";
|
||||
import { gateBadge, heatmapChart, heatmapPng, ladderChart, progressChart } from "./charts.ts";
|
||||
import { sendDigest } from "./digest.ts";
|
||||
import { createReviewIssue, scoreReview } from "./gate.ts";
|
||||
import { type GitHub, github } from "./github.ts";
|
||||
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
|
||||
import { type Mirror, projectMirror } from "./mirror.ts";
|
||||
import {
|
||||
CAMPAIGN_START,
|
||||
type LogOutcome,
|
||||
type Stage,
|
||||
daysBetween,
|
||||
dueToday,
|
||||
etDate,
|
||||
etHour,
|
||||
getProblem,
|
||||
logAttempt,
|
||||
weekdayOf,
|
||||
} from "./srs.ts";
|
||||
import { buildStats } from "./stats.ts";
|
||||
|
||||
// ── shared side effects after a state write ──────────────────────
|
||||
|
||||
/**
|
||||
* Mirror one logged attempt into GitHub: the Project's Target Date always; on a
|
||||
* first-ever pass also close the problem's sub-issue (comment first — repo
|
||||
* convention). Runs inside ctx.waitUntil; failures are warnings.
|
||||
*/
|
||||
async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Promise<void> {
|
||||
if (outcome.error || outcome.duplicate) return;
|
||||
const gh = github(env.GH_PAT, env.REPO);
|
||||
const mirror = 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 +3. Logged by the SRS Worker.`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`close #${outcome.issue}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function outcomeLine(o: LogOutcome): string {
|
||||
if (o.error) return o.error;
|
||||
if (o.duplicate) return `LC ${o.lc}: already logged today — nothing changed`;
|
||||
return (
|
||||
`LC ${o.lc}: ${o.kind} ${o.result} → stage ${o.stage}` +
|
||||
(o.next_review ? `, review ${o.next_review}` : " — retired 🎉")
|
||||
);
|
||||
}
|
||||
|
||||
// ── one-tap email links ──────────────────────────────────────────
|
||||
|
||||
function page(title: string, body: string, status = 200): Response {
|
||||
return new Response(
|
||||
`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<body style="font-family:-apple-system,Segoe UI,sans-serif;max-width:420px;margin:15vh auto;padding:0 16px;text-align:center">
|
||||
<h2>${title}</h2><p style="color:#57606a">${body}</p></body>`,
|
||||
{ status, headers: { "content-type": "text/html; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
|
||||
async function handleTap(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const lc = Number(url.searchParams.get("p"));
|
||||
const result = url.searchParams.get("r");
|
||||
const date = url.searchParams.get("d") ?? "";
|
||||
const sig = url.searchParams.get("sig") ?? "";
|
||||
if (!Number.isFinite(lc) || (result !== "pass" && result !== "fail") || !date) {
|
||||
return page("Bad link", "Missing or malformed parameters.", 400);
|
||||
}
|
||||
if (!(await verifyLink(env.LINK_KEY, lc, result, date, sig))) {
|
||||
return page("Bad signature", "This link was not signed by the SRS.", 403);
|
||||
}
|
||||
const today = etDate(new Date());
|
||||
if (daysBetween(date, today) > 3 || daysBetween(today, date) > 1) {
|
||||
return page("Link expired", "Older than 3 days — log it with a /done comment instead.", 410);
|
||||
}
|
||||
|
||||
const outcome = await logAttempt(env.DB, { lc, date, result, source: "email" });
|
||||
if (outcome.error) return page("Not logged", outcome.error, 422);
|
||||
if (outcome.duplicate) {
|
||||
return page("Already logged ✓", `LC ${lc} was already recorded today. Nothing changed.`);
|
||||
}
|
||||
ctx.waitUntil(mirrorOutcome(env, outcome, "email"));
|
||||
return page(
|
||||
result === "pass" ? "Logged ✅" : "Logged — back to +3",
|
||||
outcomeLine(outcome),
|
||||
);
|
||||
}
|
||||
|
||||
// ── GitHub webhook ───────────────────────────────────────────────
|
||||
|
||||
interface CommentEvent {
|
||||
action: string;
|
||||
issue: { number: number; title: string; labels: { name: string }[] };
|
||||
comment: { id: number; body: string; user: { login: string } };
|
||||
}
|
||||
|
||||
async function handleWebhook(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const raw = await request.arrayBuffer();
|
||||
const ok = await verifyWebhook(env.WEBHOOK_SECRET, raw, request.headers.get("x-hub-signature-256"));
|
||||
if (!ok) return new Response("bad signature", { status: 401 });
|
||||
|
||||
const event = request.headers.get("x-github-event");
|
||||
const payload: unknown = JSON.parse(new TextDecoder().decode(raw));
|
||||
const owner = env.REPO.split("/")[0]!;
|
||||
const today = etDate(new Date());
|
||||
|
||||
if (event === "issues") {
|
||||
if (!payload || typeof payload !== "object" || !("action" in payload) || !("issue" in payload)) {
|
||||
return new Response("ignored");
|
||||
}
|
||||
const p = payload as { action: string; issue: { number: number; labels: { name: string }[] } };
|
||||
if (p.action === "closed" && p.issue.labels.some((l) => l.name === "review")) {
|
||||
const gh = github(env.GH_PAT, env.REPO);
|
||||
const report = await scoreReview(env, gh, p.issue.number, today);
|
||||
return Response.json(report);
|
||||
}
|
||||
return new Response("ignored");
|
||||
}
|
||||
|
||||
if (event !== "issue_comment") return new Response("ignored");
|
||||
const p = payload as CommentEvent;
|
||||
if (p.action !== "created") return new Response("ignored");
|
||||
if (p.comment.user.login !== owner) return new Response("ignored (owner only)");
|
||||
|
||||
const commands: { lc: number; result: "pass" | "fail" }[] = [];
|
||||
const errors: string[] = [];
|
||||
for (const rawLine of p.comment.body.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith("/done")) continue;
|
||||
const m = line.match(/^\/done\s+(\d+)\s+(pass|fail)\s*$/);
|
||||
if (m) commands.push({ lc: Number(m[1]), result: m[2] as "pass" | "fail" });
|
||||
else errors.push(`cannot parse \`${line}\` — expected \`/done <number> <pass|fail>\``);
|
||||
}
|
||||
if (commands.length === 0 && errors.length === 0) return new Response("no commands");
|
||||
|
||||
// Comments on a review issue log its gate problems as kind='gate'.
|
||||
const gateRow = await env.DB.prepare("SELECT problems FROM gates WHERE issue = ?")
|
||||
.bind(p.issue.number)
|
||||
.first<{ problems: string }>();
|
||||
const gateLcs = new Set<number>(gateRow ? JSON.parse(gateRow.problems) : []);
|
||||
|
||||
const outcomes: LogOutcome[] = [];
|
||||
for (const cmd of commands) {
|
||||
const outcome = await logAttempt(env.DB, {
|
||||
lc: cmd.lc,
|
||||
date: today,
|
||||
result: cmd.result,
|
||||
source: "webhook",
|
||||
gate: gateLcs.has(cmd.lc),
|
||||
});
|
||||
if (outcome.error) errors.push(outcome.error);
|
||||
else outcomes.push(outcome);
|
||||
}
|
||||
|
||||
const gh = github(env.GH_PAT, env.REPO);
|
||||
ctx.waitUntil(
|
||||
(async () => {
|
||||
for (const outcome of outcomes) await mirrorOutcome(env, outcome, "webhook");
|
||||
try {
|
||||
if (errors.length === 0) {
|
||||
await gh.react(p.comment.id, "+1");
|
||||
} else {
|
||||
await gh.comment(
|
||||
p.issue.number,
|
||||
`Could not log everything:\n\n${errors.map((e) => `- ${e}`).join("\n")}` +
|
||||
(outcomes.length
|
||||
? `\n\nApplied anyway:\n\n${outcomes.map((o) => `- ${outcomeLine(o)}`).join("\n")}`
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`webhook feedback: ${err}`);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
return Response.json({ applied: outcomes.map(outcomeLine), errors });
|
||||
}
|
||||
|
||||
// ── admin (bearer LINK_KEY, timing-safe) ─────────────────────────
|
||||
|
||||
async function adminAuthorized(request: Request, env: Env): Promise<boolean> {
|
||||
const header = request.headers.get("authorization") ?? "";
|
||||
if (!header.startsWith("Bearer ")) return false;
|
||||
return timingSafeEqual(header.slice(7), env.LINK_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Solutions landing in `work/` are the third way a problem gets solved, after
|
||||
* the digest tap and a /done comment. `close-solved` posts its whole
|
||||
* implemented set here on every push to main, each entry tagged with the
|
||||
* BUCKET the file sits in — 1 for the first solve, 3 and 7 for the blind
|
||||
* re-solves the picker scaffolds. The bucket names the ladder rung the file
|
||||
* settles, and logAttempt writes only when the problem is standing on that
|
||||
* rung, so this is a total recompute like the reconcilers that call it: a
|
||||
* re-run changes nothing, and a pushed re-solve advances +3 → +7 instead of
|
||||
* being invisible.
|
||||
*
|
||||
* Closing the sub-issue stays with close-solved: it is the side that knows
|
||||
* which files at which commit, so this path mirrors Project fields only.
|
||||
* Charts read D1 live, so a push moves them within the 5-minute cache.
|
||||
*/
|
||||
const RUNG_OF_BUCKET: Record<number, Stage> = { 1: "new", 3: "+3", 7: "+7" };
|
||||
|
||||
async function handleSolved(request: Request, env: Env, date: string): Promise<Response> {
|
||||
const body: unknown = await request.json().catch(() => null);
|
||||
const raw: unknown = body && typeof body === "object" && "solved" in body ? body.solved : null;
|
||||
const shape = 'expected {"solved": [{"lc": <number>, "bucket": 1|3|7}, ...]}';
|
||||
if (!Array.isArray(raw)) return new Response(shape, { status: 400 });
|
||||
const entries: { lc: number; rung: Stage; bucket: number }[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== "object") return new Response(shape, { status: 400 });
|
||||
const lc: unknown = "lc" in item ? item.lc : null;
|
||||
const bucket: unknown = "bucket" in item ? item.bucket : null;
|
||||
if (typeof lc !== "number" || !Number.isFinite(lc)) {
|
||||
return new Response(`not an LC number: ${JSON.stringify(lc)}`, { status: 400 });
|
||||
}
|
||||
const rung = typeof bucket === "number" ? RUNG_OF_BUCKET[bucket] : undefined;
|
||||
if (rung === undefined) return new Response(`not a work/ bucket: ${JSON.stringify(bucket)}`, { status: 400 });
|
||||
entries.push({ lc, rung, bucket });
|
||||
}
|
||||
const dry = new URL(request.url).searchParams.get("dry") === "1";
|
||||
|
||||
const logged: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
let mirror: Mirror | undefined;
|
||||
for (const { lc, rung, bucket } of entries) {
|
||||
if (dry) {
|
||||
const p = await getProblem(env.DB, lc);
|
||||
if (!p) skipped.push(`LC ${lc} is not in the curriculum`);
|
||||
else if (p.stage !== rung) skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${p.stage}`);
|
||||
else if (rung === "new") logged.push(`LC ${lc}: would enter the ladder at +3`);
|
||||
else logged.push(`LC ${lc}: would pass ${rung} and move on`);
|
||||
continue;
|
||||
}
|
||||
const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit", rung });
|
||||
if (outcome.error) skipped.push(outcome.error);
|
||||
else if (outcome.duplicate) {
|
||||
skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${outcome.stage}`);
|
||||
} else {
|
||||
logged.push(outcomeLine(outcome));
|
||||
// One mirror for the batch: field IDs resolve once, not per problem.
|
||||
mirror ??= projectMirror(github(env.GH_PAT, env.REPO), env.REPO.split("/")[0]!);
|
||||
await mirror.setTargetDate(outcome.issue, outcome.next_review);
|
||||
}
|
||||
}
|
||||
return Response.json({ date, logged, skipped, warnings: mirror?.warnings ?? [] });
|
||||
}
|
||||
|
||||
async function handleAdmin(request: Request, env: Env, path: string): Promise<Response> {
|
||||
if (!(await adminAuthorized(request, env))) return new Response("unauthorized", { status: 401 });
|
||||
const url = new URL(request.url);
|
||||
const date = url.searchParams.get("date") ?? etDate(new Date());
|
||||
const gh = () => github(env.GH_PAT, env.REPO);
|
||||
|
||||
if (path === "/admin/reconcile") {
|
||||
return Response.json(await reconcileCatalog(env.DB, gh()));
|
||||
}
|
||||
if (path === "/admin/solved") return await handleSolved(request, env, date);
|
||||
if (path === "/admin/digest") {
|
||||
const report = await sendDigest(env, date, {
|
||||
dry: url.searchParams.get("dry") === "1",
|
||||
force: url.searchParams.get("force") === "1",
|
||||
});
|
||||
if (url.searchParams.get("dry") === "1") {
|
||||
return new Response(report.digest.html, { headers: { "content-type": "text/html" } });
|
||||
}
|
||||
return Response.json({ sent: report.sent, reason: report.reason, subject: report.digest.subject });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
// ── router ───────────────────────────────────────────────────────
|
||||
|
||||
const SVG_HEADERS = {
|
||||
"content-type": "image/svg+xml",
|
||||
// GitHub Camo honors this; 5 minutes is the freshness floor for README charts.
|
||||
"cache-control": "public, max-age=300",
|
||||
};
|
||||
|
||||
// The digest embeds the raster heatmap; email clients refuse SVG entirely.
|
||||
const PNG_HEADERS = {
|
||||
"content-type": "image/png",
|
||||
"cache-control": "public, max-age=300",
|
||||
};
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
try {
|
||||
if (path === "/log" && request.method === "GET") return await handleTap(request, env, ctx);
|
||||
if (path === "/webhook/github" && request.method === "POST") {
|
||||
return await handleWebhook(request, env, ctx);
|
||||
}
|
||||
if (path.startsWith("/admin/") && request.method === "POST") {
|
||||
return await handleAdmin(request, env, path);
|
||||
}
|
||||
|
||||
if (path === "/chart/progress.svg") {
|
||||
return new Response(await progressChart(env.DB), { headers: SVG_HEADERS });
|
||||
}
|
||||
if (path === "/chart/ladder.svg") {
|
||||
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
|
||||
}
|
||||
if (path === "/chart/heatmap.svg") {
|
||||
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, 56), { headers: SVG_HEADERS });
|
||||
}
|
||||
if (path === "/chart/heatmap.png") {
|
||||
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, 56), { headers: PNG_HEADERS });
|
||||
}
|
||||
if (path === "/badge/gate.svg") {
|
||||
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
|
||||
}
|
||||
|
||||
if (path === "/api/stats") {
|
||||
// The docs serve from both the Pages origin and the custom domain;
|
||||
// reflect the requesting origin only when it is on the allowlist.
|
||||
const allowed = env.DOCS_ORIGIN.split(",").map((o) => o.trim());
|
||||
const origin = request.headers.get("origin");
|
||||
const cors = {
|
||||
"access-control-allow-origin":
|
||||
origin && allowed.includes(origin) ? origin : allowed[0]!,
|
||||
"access-control-allow-methods": "GET",
|
||||
vary: "Origin",
|
||||
};
|
||||
if (request.method === "OPTIONS") return new Response(null, { headers: cors });
|
||||
const stats = await buildStats(env.DB, etDate(new Date()));
|
||||
return Response.json(stats, { headers: cors });
|
||||
}
|
||||
|
||||
return new Response("srs-api", { status: path === "/" ? 200 : 404 });
|
||||
} catch (err) {
|
||||
console.error(`unhandled ${request.method} ${path}: ${err}`);
|
||||
return new Response("internal error", { status: 500 });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* DST-proof cron dispatch: crons fire at both possible UTC hours; the
|
||||
* computed ET hour decides. 8 AM daily → catalog reconcile + digest;
|
||||
* midnight Saturday → review issue (so the 8 AM digest can link to it).
|
||||
*/
|
||||
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||
const now = new Date(controller.scheduledTime);
|
||||
const hour = etHour(now);
|
||||
const date = etDate(now);
|
||||
|
||||
if (hour === 8) {
|
||||
try {
|
||||
const report = await reconcileCatalog(env.DB, github(env.GH_PAT, env.REPO));
|
||||
console.log(`catalog: ${report.topics} topics, ${report.problems} problems`);
|
||||
} catch (err) {
|
||||
console.warn(`catalog reconcile failed (digest still goes out): ${err}`);
|
||||
}
|
||||
const report = await sendDigest(env, date);
|
||||
console.log(`digest ${date}: ${report.reason}`);
|
||||
return;
|
||||
}
|
||||
if (hour === 0 && weekdayOf(date) === 6) {
|
||||
const report = await createReviewIssue(env, github(env.GH_PAT, env.REPO), date);
|
||||
console.log(`review issue ${date}: ${report.reason}`);
|
||||
return;
|
||||
}
|
||||
console.log(`cron at ET hour ${hour} on ${date}: no-op (DST guard)`);
|
||||
void ctx;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* One-tap email links: `GET /log?p=704&r=pass&d=2026-08-31&sig=<hmac>`.
|
||||
* The sig is HMAC-SHA256 over `p|r|d` with LINK_KEY (WebCrypto, hex).
|
||||
* Verification is constant-time; links older than 3 days are rejected.
|
||||
*/
|
||||
|
||||
async function hmacKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
}
|
||||
|
||||
export async function signLink(
|
||||
key: string,
|
||||
p: number,
|
||||
r: string,
|
||||
d: string,
|
||||
): Promise<string> {
|
||||
const mac = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
await hmacKey(key),
|
||||
new TextEncoder().encode(`${p}|${r}|${d}`),
|
||||
);
|
||||
return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function verifyLink(
|
||||
key: string,
|
||||
p: number,
|
||||
r: string,
|
||||
d: string,
|
||||
sig: string,
|
||||
): Promise<boolean> {
|
||||
if (!/^[0-9a-f]{64}$/.test(sig)) return false;
|
||||
const bytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) bytes[i] = Number.parseInt(sig.slice(i * 2, i * 2 + 2), 16);
|
||||
// crypto.subtle.verify is constant-time; never compare hex strings directly.
|
||||
return crypto.subtle.verify(
|
||||
"HMAC",
|
||||
await hmacKey(key),
|
||||
bytes,
|
||||
new TextEncoder().encode(`${p}|${r}|${d}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Constant-time equality for webhook signatures and admin bearer keys. */
|
||||
export async function timingSafeEqual(a: string, b: string): Promise<boolean> {
|
||||
// HMAC both sides with a random key: unequal-length inputs and content
|
||||
// differences are equally invisible to timing.
|
||||
const key = await crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, false, [
|
||||
"sign",
|
||||
]);
|
||||
const enc = new TextEncoder();
|
||||
const [ma, mb] = await Promise.all([
|
||||
crypto.subtle.sign("HMAC", key, enc.encode(a)),
|
||||
crypto.subtle.sign("HMAC", key, enc.encode(b)),
|
||||
]);
|
||||
const va = new Uint8Array(ma);
|
||||
const vb = new Uint8Array(mb);
|
||||
let diff = 0;
|
||||
for (let i = 0; i < va.length; i++) diff |= va[i]! ^ vb[i]!;
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/** GitHub webhook `X-Hub-Signature-256: sha256=<hex>` verification. */
|
||||
export async function verifyWebhook(
|
||||
secret: string,
|
||||
body: ArrayBuffer,
|
||||
header: string | null,
|
||||
): Promise<boolean> {
|
||||
if (!header?.startsWith("sha256=")) return false;
|
||||
const mac = await crypto.subtle.sign("HMAC", await hmacKey(secret), body);
|
||||
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return timingSafeEqual(header.slice(7), expected);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Best-effort mirror into the "Interview Prep" user Project — ported from
|
||||
* scripts/srs-project.ts. D1 is the truth; a GraphQL failure here becomes a
|
||||
* console warning and never blocks a state write. Only problem issues are
|
||||
* ever passed in, so topic rows' Target Date is never written.
|
||||
*
|
||||
* 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 ProjectInfo {
|
||||
id: string;
|
||||
targetDate: string;
|
||||
}
|
||||
|
||||
export interface Mirror {
|
||||
setTargetDate(issue: number, date: string | null): Promise<void>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function projectMirror(gh: GitHub, owner: string): Mirror {
|
||||
const warnings: string[] = [];
|
||||
let info: ProjectInfo | undefined;
|
||||
const itemIds = new Map<number, string | undefined>();
|
||||
|
||||
async function resolve(): Promise<ProjectInfo> {
|
||||
if (info) return info;
|
||||
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 ProjectV2FieldCommon { id name dataType }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner, title: PROJECT_TITLE },
|
||||
)) as {
|
||||
user: {
|
||||
projectsV2: {
|
||||
nodes: {
|
||||
id: string;
|
||||
title: string;
|
||||
fields: {
|
||||
nodes: { id: string; name: string }[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
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 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 };
|
||||
return info;
|
||||
}
|
||||
|
||||
async function itemId(issue: number): Promise<string> {
|
||||
if (itemIds.has(issue)) {
|
||||
const cached = itemIds.get(issue);
|
||||
if (!cached) throw new Error(`issue #${issue} is not in the project`);
|
||||
return cached;
|
||||
}
|
||||
const project = await resolve();
|
||||
const [repoOwner, repoName] = gh.repo.split("/");
|
||||
const data = (await gh.graphql(
|
||||
`query($owner: String!, $name: String!, $issue: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issue(number: $issue) {
|
||||
projectItems(first: 10, includeArchived: true) {
|
||||
nodes { id project { id } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner: repoOwner, name: repoName, issue },
|
||||
)) as {
|
||||
repository: { issue: { projectItems: { nodes: { id: string; project: { id: string } }[] } } };
|
||||
};
|
||||
const item = data.repository.issue.projectItems.nodes.find((n) => n.project.id === project.id);
|
||||
itemIds.set(issue, item?.id);
|
||||
if (!item) throw new Error(`issue #${issue} is not in the project`);
|
||||
return item.id;
|
||||
}
|
||||
|
||||
async function setField(issue: number, fieldId: string, value: object): Promise<void> {
|
||||
const project = await resolve();
|
||||
await gh.graphql(
|
||||
`mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
|
||||
updateProjectV2ItemFieldValue(
|
||||
input: { projectId: $project, itemId: $item, fieldId: $field, value: $value }
|
||||
) { projectV2Item { id } }
|
||||
}`,
|
||||
{ project: project.id, item: await itemId(issue), field: fieldId, value },
|
||||
);
|
||||
}
|
||||
|
||||
async function attempt(what: string, op: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await op();
|
||||
} catch (err) {
|
||||
const message = `mirror ${what}: ${err instanceof Error ? err.message : String(err)}`;
|
||||
warnings.push(message);
|
||||
console.warn(message); // observability picks this up; never rethrow
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
warnings,
|
||||
setTargetDate: (issue, date) =>
|
||||
attempt(`Target Date #${issue}`, async () => {
|
||||
const project = await resolve();
|
||||
if (date === null) {
|
||||
await gh.graphql(
|
||||
`mutation($project: ID!, $item: ID!, $field: ID!) {
|
||||
clearProjectV2ItemFieldValue(
|
||||
input: { projectId: $project, itemId: $item, fieldId: $field }
|
||||
) { projectV2Item { id } }
|
||||
}`,
|
||||
{ project: project.id, item: await itemId(issue), field: project.targetDate },
|
||||
);
|
||||
} else {
|
||||
await setField(issue, project.targetDate, { date });
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Hand-rolled PNG encoder for the SRS Worker.
|
||||
*
|
||||
* Every major email client — Gmail, Outlook, Apple Mail — refuses to render
|
||||
* SVG, inline or remote, so the daily digest cannot reuse /chart/heatmap.svg;
|
||||
* it needs raster bytes. The Worker ships with no runtime dependencies by
|
||||
* design (SVG, HMAC and GraphQL are all hand-rolled here), so this file
|
||||
* encodes PNG itself instead of pulling in a codec.
|
||||
*
|
||||
* The format reduces to four things the runtime already provides:
|
||||
* - an 8-byte signature plus chunks (IHDR, IDAT, IEND), each one
|
||||
* length + type + payload + CRC32, every integer big-endian;
|
||||
* - one filter byte per scanline, always 0 here: these images are flat
|
||||
* rectangles, so a predictor would buy nothing;
|
||||
* - zlib-wrapped deflate for IDAT, which is exactly what
|
||||
* `new CompressionStream("deflate")` emits ("deflate-raw" would not);
|
||||
* - CRC32, a 256-entry table built lazily on the first chunk.
|
||||
*
|
||||
* Colour type 2 (truecolour, 8-bit, no alpha): these charts are opaque cards,
|
||||
* so an alpha channel would cost a third more bytes for nothing. That does mean
|
||||
* transparency is unavailable — a caller wanting rounded corners paints the
|
||||
* colour that sits behind the image first, then the rounded card on top.
|
||||
*/
|
||||
|
||||
// ── crc32 ────────────────────────────────────────────────────────
|
||||
|
||||
// One table for every chunk of every image; built on first use.
|
||||
let crcTable: Uint32Array | undefined;
|
||||
|
||||
function crc32(bytes: Uint8Array): number {
|
||||
let table = crcTable;
|
||||
if (!table) {
|
||||
table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c;
|
||||
}
|
||||
crcTable = table;
|
||||
}
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) crc = table[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
// ── chunks ───────────────────────────────────────────────────────
|
||||
|
||||
const SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
/** length, 4-char type, payload, then CRC32 over the type and payload. */
|
||||
function chunk(type: string, data: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(data.length + 12);
|
||||
const view = new DataView(out.buffer);
|
||||
view.setUint32(0, data.length);
|
||||
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
|
||||
out.set(data, 8);
|
||||
view.setUint32(data.length + 8, crc32(out.subarray(4, data.length + 8)));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 5x7 bitmap font ──────────────────────────────────────────────
|
||||
|
||||
const GLYPH_W = 5;
|
||||
const GLYPH_H = 7;
|
||||
|
||||
/**
|
||||
* Seven row bitmasks per glyph, five low bits each, MSB (0b10000) leftmost.
|
||||
* Uppercase only: chart labels are short and mechanical, and a lowercase set
|
||||
* would double the table for no gain. Unlisted characters render blank.
|
||||
*/
|
||||
const GLYPHS: Record<string, number[]> = {
|
||||
A: [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
|
||||
B: [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110],
|
||||
C: [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110],
|
||||
D: [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110],
|
||||
E: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
|
||||
F: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000],
|
||||
G: [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110],
|
||||
H: [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
|
||||
I: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111],
|
||||
J: [0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100],
|
||||
K: [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001],
|
||||
L: [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
|
||||
M: [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001],
|
||||
N: [0b10001, 0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001],
|
||||
O: [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
|
||||
P: [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
|
||||
Q: [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10011, 0b01101],
|
||||
R: [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001],
|
||||
S: [0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110],
|
||||
T: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100],
|
||||
U: [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
|
||||
V: [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100],
|
||||
W: [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001],
|
||||
X: [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001],
|
||||
Y: [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100],
|
||||
Z: [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111],
|
||||
"0": [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
|
||||
"1": [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
|
||||
"2": [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
|
||||
"3": [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
|
||||
"4": [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
|
||||
"5": [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
|
||||
"6": [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
|
||||
"7": [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
|
||||
"8": [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
|
||||
"9": [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
|
||||
" ": [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000],
|
||||
"-": [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000],
|
||||
"/": [0b00001, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b10000],
|
||||
":": [0b00000, 0b00100, 0b00100, 0b00000, 0b00100, 0b00100, 0b00000],
|
||||
};
|
||||
|
||||
/**
|
||||
* Device pixels `content` occupies at `scale`, inter-glyph gaps included.
|
||||
* Exported because callers need it to size a canvas before constructing one.
|
||||
*/
|
||||
export function textWidth(content: string, scale = 1): number {
|
||||
return content.length === 0 ? 0 : (content.length * (GLYPH_W + 1) - 1) * scale;
|
||||
}
|
||||
|
||||
// ── canvas ───────────────────────────────────────────────────────
|
||||
|
||||
/** "#rrggbb" to its three channel bytes; called once per draw, not per pixel. */
|
||||
function rgb(color: string): [number, number, number] {
|
||||
const packed = Number.parseInt(color.slice(1), 16);
|
||||
return [(packed >> 16) & 0xff, (packed >> 8) & 0xff, packed & 0xff];
|
||||
}
|
||||
|
||||
/** Fixed-size RGB canvas: filled rectangles plus 5x7 bitmap text. */
|
||||
export class Canvas {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
/** Row-major RGB triples, unpadded; encode() interleaves the filter bytes. */
|
||||
private readonly pixels: Uint8Array;
|
||||
|
||||
constructor(width: number, height: number, background: string) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.pixels = new Uint8Array(width * height * 3);
|
||||
const [r, g, b] = rgb(background);
|
||||
for (let i = 0; i < this.pixels.length; i += 3) {
|
||||
this.pixels[i] = r;
|
||||
this.pixels[i + 1] = g;
|
||||
this.pixels[i + 2] = b;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filled rectangle, clipped to the canvas. `radius` rounds all four corners
|
||||
* by dropping the pixels whose centre falls outside the corner circle —
|
||||
* enough for the small cell roundings and the card these charts draw.
|
||||
*/
|
||||
rect(x: number, y: number, w: number, h: number, color: string, radius = 0): void {
|
||||
const [r, g, b] = rgb(color);
|
||||
// Device pixels only: a fractional origin would index the buffer between
|
||||
// bytes and silently drop the whole rectangle.
|
||||
const left = Math.round(x);
|
||||
const top = Math.round(y);
|
||||
const rw = Math.round(w);
|
||||
const rh = Math.round(h);
|
||||
const rad = Math.min(radius, rw / 2, rh / 2);
|
||||
|
||||
const y1 = Math.min(this.height, top + rh);
|
||||
const x1 = Math.min(this.width, left + rw);
|
||||
for (let py = Math.max(0, top); py < y1; py++) {
|
||||
// How far this row lies past the nearer corner centre; 0 in between.
|
||||
const cy = py + 0.5;
|
||||
const dy = cy < top + rad ? top + rad - cy : cy > top + rh - rad ? cy - (top + rh - rad) : 0;
|
||||
const rowBase = py * this.width * 3;
|
||||
for (let px = Math.max(0, left); px < x1; px++) {
|
||||
const cx = px + 0.5;
|
||||
const dx = cx < left + rad ? left + rad - cx : cx > left + rw - rad ? cx - (left + rw - rad) : 0;
|
||||
if (dx * dx + dy * dy > rad * rad) continue;
|
||||
const at = rowBase + px * 3;
|
||||
this.pixels[at] = r;
|
||||
this.pixels[at + 1] = g;
|
||||
this.pixels[at + 2] = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitmap text from a top-left origin (not a baseline), `scale` device pixels
|
||||
* per glyph pixel, 1 glyph pixel of advance between characters. Input is
|
||||
* uppercased; unknown characters advance without drawing.
|
||||
*/
|
||||
text(x: number, y: number, content: string, color: string, scale = 1): void {
|
||||
const [r, g, b] = rgb(color);
|
||||
const left = Math.round(x);
|
||||
const top = Math.round(y);
|
||||
const upper = content.toUpperCase();
|
||||
|
||||
for (let i = 0; i < upper.length; i++) {
|
||||
const glyph = GLYPHS[upper[i]!];
|
||||
if (!glyph) continue;
|
||||
const originX = left + i * (GLYPH_W + 1) * scale;
|
||||
for (let gy = 0; gy < GLYPH_H; gy++) {
|
||||
const bits = glyph[gy]!;
|
||||
if (bits === 0) continue;
|
||||
for (let gx = 0; gx < GLYPH_W; gx++) {
|
||||
if ((bits & (1 << (GLYPH_W - 1 - gx))) === 0) continue;
|
||||
// Every set glyph pixel is a scale x scale block of device pixels.
|
||||
const blockX = originX + gx * scale;
|
||||
const blockY = top + gy * scale;
|
||||
const y1 = Math.min(this.height, blockY + scale);
|
||||
const x1 = Math.min(this.width, blockX + scale);
|
||||
for (let py = Math.max(0, blockY); py < y1; py++) {
|
||||
const rowBase = py * this.width * 3;
|
||||
for (let px = Math.max(0, blockX); px < x1; px++) {
|
||||
const at = rowBase + px * 3;
|
||||
this.pixels[at] = r;
|
||||
this.pixels[at + 1] = g;
|
||||
this.pixels[at + 2] = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** PNG bytes: 8-bit truecolour, one IDAT, zlib via CompressionStream. */
|
||||
async encode(): Promise<Uint8Array> {
|
||||
const stride = this.width * 3;
|
||||
const raw = new Uint8Array((stride + 1) * this.height);
|
||||
for (let y = 0; y < this.height; y++) {
|
||||
// Leading 0 of each scanline slot is the "no filter" byte.
|
||||
raw.set(this.pixels.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
||||
}
|
||||
const deflated = new Response(raw).body!.pipeThrough(new CompressionStream("deflate"));
|
||||
const idat = new Uint8Array(await new Response(deflated).arrayBuffer());
|
||||
|
||||
const ihdr = new Uint8Array(13);
|
||||
const header = new DataView(ihdr.buffer);
|
||||
header.setUint32(0, this.width);
|
||||
header.setUint32(4, this.height);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 2; // colour type 2: truecolour RGB
|
||||
// Bytes 10-12 stay 0: deflate, adaptive filtering, no interlace.
|
||||
|
||||
const chunks = [chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0))];
|
||||
let total = SIGNATURE.length;
|
||||
for (const c of chunks) total += c.length;
|
||||
|
||||
const png = new Uint8Array(total);
|
||||
png.set(SIGNATURE);
|
||||
let at = SIGNATURE.length;
|
||||
for (const c of chunks) {
|
||||
png.set(c, at);
|
||||
at += c.length;
|
||||
}
|
||||
return png;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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 {
|
||||
INTERVAL,
|
||||
STAGES,
|
||||
WINDOWS,
|
||||
addDays,
|
||||
campaignDay,
|
||||
campaignWeek,
|
||||
etDate,
|
||||
etHour,
|
||||
isRestDay,
|
||||
isoWeek,
|
||||
rng,
|
||||
sample,
|
||||
TEMPERATURES,
|
||||
temperatureOf,
|
||||
weekdayOf,
|
||||
workingDay,
|
||||
} from "./srs.ts";
|
||||
|
||||
/** Every ET date of the campaign, plus the deferred-Hard tail. */
|
||||
const CALENDAR = Array.from({ length: 70 }, (_, i) => addDays("2026-08-17", i));
|
||||
|
||||
describe("DST-proof cron guard", () => {
|
||||
test("digest fires only at ET hour 8 — EDT (UTC-4)", () => {
|
||||
// Cron pair 12:00 / 13:00 UTC during EDT (2026-08-29 is EDT).
|
||||
expect(etHour(new Date("2026-08-29T12:00:00Z"))).toBe(8); // fires
|
||||
expect(etHour(new Date("2026-08-29T13:00:00Z"))).toBe(9); // no-op
|
||||
});
|
||||
test("digest fires only at ET hour 8 — EST (UTC-5)", () => {
|
||||
// DST ends 2026-11-01; 2026-11-05 is EST.
|
||||
expect(etHour(new Date("2026-11-05T12:00:00Z"))).toBe(7); // no-op
|
||||
expect(etHour(new Date("2026-11-05T13:00:00Z"))).toBe(8); // fires
|
||||
});
|
||||
test("saturday review fires only at ET midnight — both offsets", () => {
|
||||
// EDT Saturday: 4 UTC = 0 ET fires, 5 UTC = 1 ET no-op.
|
||||
expect(etHour(new Date("2026-08-29T04:00:00Z"))).toBe(0);
|
||||
expect(weekdayOf(etDate(new Date("2026-08-29T04:00:00Z")))).toBe(6);
|
||||
expect(etHour(new Date("2026-08-29T05:00:00Z"))).toBe(1);
|
||||
// EST Saturday (2026-11-07): 5 UTC = 0 ET fires, 4 UTC = 11 PM FRIDAY ET.
|
||||
expect(etHour(new Date("2026-11-07T05:00:00Z"))).toBe(0);
|
||||
expect(weekdayOf(etDate(new Date("2026-11-07T05:00:00Z")))).toBe(6);
|
||||
expect(etHour(new Date("2026-11-07T04:00:00Z"))).toBe(23);
|
||||
expect(etDate(new Date("2026-11-07T04:00:00Z"))).toBe("2026-11-06"); // still Friday ET
|
||||
});
|
||||
});
|
||||
|
||||
describe("date math (noon-UTC anchored)", () => {
|
||||
test("addDays crosses the DST-end boundary without drift", () => {
|
||||
expect(addDays("2026-10-31", 2)).toBe("2026-11-02");
|
||||
expect(addDays("2026-08-24", 2)).toBe("2026-08-26");
|
||||
});
|
||||
test("campaign math", () => {
|
||||
expect(campaignDay("2026-08-17")).toBe(1);
|
||||
expect(campaignDay("2026-08-24")).toBe(8);
|
||||
expect(campaignWeek("2026-08-24")).toBe(2);
|
||||
expect(campaignWeek("2026-08-30")).toBe(2);
|
||||
expect(campaignWeek("2026-08-31")).toBe(3);
|
||||
});
|
||||
test("iso week is stable across a week", () => {
|
||||
expect(isoWeek("2026-08-24")).toBe(isoWeek("2026-08-29"));
|
||||
expect(isoWeek("2026-08-30")).toBe(isoWeek("2026-08-24")); // Sun ends the ISO week
|
||||
expect(isoWeek("2026-08-31")).toBe(isoWeek("2026-08-24") + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rest day", () => {
|
||||
test("Sunday is the only rest day", () => {
|
||||
expect(isRestDay("2026-08-30")).toBe(true); // Sunday
|
||||
expect(isRestDay("2026-08-29")).toBe(false); // Saturday: gate day, still work
|
||||
expect(isRestDay("2026-08-31")).toBe(false);
|
||||
});
|
||||
|
||||
test("workingDay never returns a Sunday, from any day and any offset", () => {
|
||||
for (const date of CALENDAR) {
|
||||
for (let offset = 0; offset < 10; offset++) {
|
||||
expect(isRestDay(workingDay(date, offset))).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("workingDay(date) leaves a working day alone and slides Sunday to Monday", () => {
|
||||
expect(workingDay("2026-09-04")).toBe("2026-09-04"); // Friday
|
||||
expect(workingDay("2026-09-05")).toBe("2026-09-05"); // Saturday
|
||||
expect(workingDay("2026-09-06")).toBe("2026-09-07"); // Sunday → Monday
|
||||
});
|
||||
|
||||
test("offsets count working days, so a spill skips the rest day", () => {
|
||||
// Levelling from Saturday: tomorrow is Sunday, so the spill starts Monday.
|
||||
expect(workingDay("2026-09-05", 1)).toBe("2026-09-07");
|
||||
expect(workingDay("2026-09-05", 2)).toBe("2026-09-08");
|
||||
// Two deferred Hards per working day from a Monday.
|
||||
expect(workingDay("2026-09-28", 5)).toBe("2026-10-03"); // Saturday
|
||||
expect(workingDay("2026-09-28", 6)).toBe("2026-10-05"); // Sunday skipped
|
||||
});
|
||||
|
||||
test("every ladder interval lands on a working day, from every learning day", () => {
|
||||
for (const date of CALENDAR) {
|
||||
if (isRestDay(date)) continue;
|
||||
for (const days of WINDOWS) {
|
||||
const review = workingDay(addDays(date, days));
|
||||
expect(isRestDay(review)).toBe(false);
|
||||
// Never earlier than the interval: a review may slip, never shorten.
|
||||
expect(review >= addDays(date, days)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("only Thursday's +3 needs the slide", () => {
|
||||
const slid = CALENDAR.filter(
|
||||
(date) => !isRestDay(date) && workingDay(addDays(date, 3)) !== addDays(date, 3),
|
||||
);
|
||||
expect(new Set(slid.map(weekdayOf))).toEqual(new Set([4])); // Thursday only
|
||||
for (const date of CALENDAR) {
|
||||
if (isRestDay(date)) continue;
|
||||
expect(workingDay(addDays(date, 7))).toBe(addDays(date, 7)); // +7 keeps the weekday
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ladder", () => {
|
||||
test("two rungs, named after the windows", () => {
|
||||
expect(STAGES).toEqual(["new", "+3", "+7", "retired"]);
|
||||
expect(WINDOWS.map((w) => INTERVAL[`+${w}`])).toEqual([...WINDOWS]);
|
||||
// Every non-terminal stage has an interval, and only those.
|
||||
expect(Object.keys(INTERVAL).sort()).toEqual(STAGES.slice(1, -1).sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("concept temperature", () => {
|
||||
test("the bands are the ladder's own windows", () => {
|
||||
// Boundaries are inclusive, and the bands tile the whole age axis with no
|
||||
// gap: whatever the windows become, every age still lands in exactly one.
|
||||
expect(TEMPERATURES).toEqual(["hot", "fresh", "fading", "cold"]);
|
||||
expect(temperatureOf(0)).toBe("hot");
|
||||
expect(temperatureOf(WINDOWS[0])).toBe("hot");
|
||||
expect(temperatureOf(WINDOWS[0] + 1)).toBe("fresh");
|
||||
expect(temperatureOf(WINDOWS[1])).toBe("fresh");
|
||||
expect(temperatureOf(WINDOWS[1] + 1)).toBe("fading");
|
||||
expect(temperatureOf(WINDOWS[1] * 2)).toBe("fading");
|
||||
expect(temperatureOf(WINDOWS[1] * 2 + 1)).toBe("cold");
|
||||
});
|
||||
|
||||
test("never attempted is cold, not unknown", () => {
|
||||
expect(temperatureOf(null)).toBe("cold");
|
||||
});
|
||||
|
||||
test("every age of the campaign has exactly one band", () => {
|
||||
for (let age = 0; age <= CALENDAR.length; age++) {
|
||||
expect(TEMPERATURES).toContain(temperatureOf(age));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deterministic sampling", () => {
|
||||
test("same seed, same picks", () => {
|
||||
const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
expect(sample(pool, 3, rng("drill-2026-08-31"))).toEqual(sample(pool, 3, rng("drill-2026-08-31")));
|
||||
expect(sample(pool, 3, rng("drill-2026-09-01"))).not.toEqual(sample(pool, 3, rng("drill-2026-08-31")));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,551 @@
|
||||
/**
|
||||
* SRS domain: ET dates, the work week, the interval ladder, concept
|
||||
* temperature, deterministic sampling, and the one write path for attempts.
|
||||
*
|
||||
* D1 is the single source of truth. The stage names the review a problem must
|
||||
* pass NEXT (`+3` = due 3 working days after the last clean solve). Passing
|
||||
* 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.
|
||||
*/
|
||||
import scheduleJson from "../data/schedule.json";
|
||||
|
||||
// ── dates (America/New_York) ─────────────────────────────────────
|
||||
|
||||
export const CAMPAIGN_START = "2026-08-17";
|
||||
export const CAMPAIGN_DAYS = 56;
|
||||
|
||||
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "America/New_York",
|
||||
dateStyle: "short",
|
||||
});
|
||||
const ET_HOUR = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "America/New_York",
|
||||
hour: "numeric",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
|
||||
/** ET calendar date of an instant. */
|
||||
export function etDate(now: Date): string {
|
||||
return ET_DATE.format(now);
|
||||
}
|
||||
|
||||
/** ET hour 0–23 of an instant — the DST-proof cron guard. */
|
||||
export function etHour(now: Date): number {
|
||||
return Number(ET_HOUR.format(now));
|
||||
}
|
||||
|
||||
/** Noon-UTC anchor: date-only arithmetic immune to DST edges. */
|
||||
function atNoon(date: string): Date {
|
||||
return new Date(`${date}T12:00:00Z`);
|
||||
}
|
||||
|
||||
export function addDays(date: string, days: number): string {
|
||||
return new Date(atNoon(date).getTime() + days * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function daysBetween(from: string, to: string): number {
|
||||
return Math.round((atNoon(to).getTime() - atNoon(from).getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/** 0 = Sunday … 6 = Saturday. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** 1-based campaign week (Mon–Sun), aligned to CAMPAIGN_START. */
|
||||
export function campaignWeek(date: string): number {
|
||||
return Math.floor(daysBetween(CAMPAIGN_START, date) / 7) + 1;
|
||||
}
|
||||
|
||||
/** Monday of the date's campaign week. */
|
||||
export function weekMonday(date: string): string {
|
||||
return addDays(CAMPAIGN_START, (campaignWeek(date) - 1) * 7);
|
||||
}
|
||||
|
||||
export function isoWeek(date: string): number {
|
||||
const d = atNoon(date);
|
||||
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||
const jan1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((d.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7);
|
||||
}
|
||||
|
||||
export function prettyDate(date: string): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "UTC",
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(atNoon(date));
|
||||
}
|
||||
|
||||
// ── the schedule (bundled; humans edit apps/api/data/schedule.json) ───
|
||||
|
||||
export const SCHEDULE: Record<string, number> = scheduleJson;
|
||||
|
||||
/** Date the schedule teaches a topic on, if it teaches it at all. */
|
||||
export function topicDate(topic: number): string | undefined {
|
||||
for (const [date, t] of Object.entries(SCHEDULE)) {
|
||||
if (t === topic) return date;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Week in which a topic was (or will be) taught. */
|
||||
export function topicWeek(topic: number): number | undefined {
|
||||
const date = topicDate(topic);
|
||||
return date === undefined ? undefined : campaignWeek(date);
|
||||
}
|
||||
|
||||
// ── deterministic sampling ───────────────────────────────────────
|
||||
|
||||
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
|
||||
export function rng(seed: string): () => number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return () => {
|
||||
h = Math.imul(h ^ (h >>> 15), h | 1);
|
||||
h ^= h + Math.imul(h ^ (h >>> 7), h | 61);
|
||||
return ((h ^ (h >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** Up to n elements, Fisher–Yates order driven by the seeded PRNG. */
|
||||
export function sample<T>(pool: T[], n: number, random: () => number): T[] {
|
||||
const copy = [...pool];
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j]!, copy[i]!];
|
||||
}
|
||||
return copy.slice(0, n);
|
||||
}
|
||||
|
||||
// ── rows ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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;
|
||||
topic_issue: number;
|
||||
title: string;
|
||||
difficulty: string;
|
||||
set_label: string;
|
||||
stage: Stage;
|
||||
next_review: string | null;
|
||||
defer_until: string | null;
|
||||
}
|
||||
|
||||
export const INTERVAL: Record<string, number> = { "+3": 3, "+7": 7 };
|
||||
const NEXT_STAGE: Record<string, Stage> = { new: "+3", "+3": "+7", "+7": "retired" };
|
||||
|
||||
// ── queries ──────────────────────────────────────────────────────
|
||||
|
||||
export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow | null> {
|
||||
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
||||
}
|
||||
|
||||
/**
|
||||
* What "due" means, in one place: not retired, scheduled on/before ?1, and
|
||||
* past its deferral if it has one. Every caller that asks "what is open on
|
||||
* this day" — the digest, the review issue, the docs queue chart — shares
|
||||
* this clause so they cannot answer differently.
|
||||
*/
|
||||
export const DUE_WHERE = `stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||
AND (defer_until IS NULL OR defer_until <= ?1)`;
|
||||
|
||||
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
||||
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(`SELECT * FROM problems WHERE ${DUE_WHERE} ORDER BY next_review, lc_number`)
|
||||
.bind(date)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
}
|
||||
|
||||
/** One problem the ladder wants re-solved today. */
|
||||
export interface DueRow {
|
||||
lc: number;
|
||||
issue: number;
|
||||
title: string;
|
||||
difficulty: string;
|
||||
/** The rung being settled: also the `work/<n>` bucket the re-solve goes in. */
|
||||
stage: Stage;
|
||||
bucket: number;
|
||||
/** Scheduled day; earlier than `date` when a day was missed. */
|
||||
due: string;
|
||||
/** Days late (0 = due today), so a renderer can say so. */
|
||||
late: number;
|
||||
/** Last logged attempt, and how long ago — the "last seen" line. */
|
||||
last_seen: string | null;
|
||||
days_since: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the ladder wants re-solved on `date`, oldest first.
|
||||
*
|
||||
* The read side of "which problems are open today": the same rows the digest
|
||||
* mails, shaped for whoever renders them (the daily Spaced Repetition issue).
|
||||
* Nothing here writes, so it is safe to call repeatedly, and there is no
|
||||
* second schedule to drift — D1's ladder is the schedule.
|
||||
*/
|
||||
export async function dueToday(db: D1Database, date: string): Promise<{ date: string; due: DueRow[] }> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT lc_number, issue, title, difficulty, stage, next_review,
|
||||
(SELECT MAX(a.date) FROM attempts a WHERE a.lc_number = problems.lc_number) AS last_seen
|
||||
FROM problems WHERE ${DUE_WHERE} ORDER BY next_review, lc_number`,
|
||||
)
|
||||
.bind(date)
|
||||
.all<{
|
||||
lc_number: number;
|
||||
issue: number;
|
||||
title: string;
|
||||
difficulty: string;
|
||||
stage: Stage;
|
||||
next_review: string;
|
||||
last_seen: string | null;
|
||||
}>();
|
||||
return {
|
||||
date,
|
||||
due: results.map((r) => ({
|
||||
lc: r.lc_number,
|
||||
issue: r.issue,
|
||||
title: r.title,
|
||||
difficulty: r.difficulty,
|
||||
stage: r.stage,
|
||||
// "+3" -> 3: the rung's number IS its work/ bucket.
|
||||
bucket: Number(r.stage.slice(1)),
|
||||
due: r.next_review,
|
||||
late: daysBetween(r.next_review, date),
|
||||
last_seen: r.last_seen,
|
||||
days_since: r.last_seen === null ? null : daysBetween(r.last_seen, date),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reviews surfaced per day; the digest levels everything past this forward. */
|
||||
export const REVIEW_CAP = 3;
|
||||
|
||||
/**
|
||||
* Load-level an overloaded review queue: everything due beyond today's
|
||||
* REVIEW_CAP is pushed to a concrete future WORKING day — at most REVIEW_CAP
|
||||
* per day, oldest first — instead of piling up as "due today". Runs every
|
||||
* digest morning, so a future day that grows past the cap (spill plus newly
|
||||
* maturing reviews) is simply re-levelled when it arrives. Idempotent within
|
||||
* a date: after one pass at most REVIEW_CAP problems remain due today, so a
|
||||
* second pass moves nothing. The spill starts tomorrow, or Monday when
|
||||
* tomorrow is the rest day.
|
||||
*/
|
||||
export async function levelReviews(db: D1Database, date: string): Promise<number> {
|
||||
const overflow = (await dueReviews(db, date)).slice(REVIEW_CAP);
|
||||
if (overflow.length === 0) return 0;
|
||||
await db.batch(
|
||||
overflow.map((p, i) =>
|
||||
db
|
||||
.prepare("UPDATE problems SET next_review = ? WHERE lc_number = ?")
|
||||
.bind(workingDay(addDays(date, 1), Math.floor(i / REVIEW_CAP)), p.lc_number),
|
||||
),
|
||||
);
|
||||
return overflow.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blind drills: unsolved optional problems from topics ALREADY LEARNED —
|
||||
* scheduled in an earlier week (the current week's optional pool is reserved
|
||||
* 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.
|
||||
*/
|
||||
export async function pickDrills(
|
||||
db: D1Database,
|
||||
date: string,
|
||||
budget: number,
|
||||
): Promise<ProblemRow[]> {
|
||||
if (budget <= 0) return [];
|
||||
const week = campaignWeek(date);
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT p.*, t.boost AS boost FROM problems p
|
||||
JOIN topics t ON t.issue = p.topic_issue
|
||||
WHERE p.set_label = 'optional' AND p.stage = 'new'
|
||||
AND p.lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = p.lc_number)
|
||||
AND EXISTS (SELECT 1 FROM problems c
|
||||
WHERE c.topic_issue = p.topic_issue
|
||||
AND c.set_label = 'core' AND c.stage != 'new')
|
||||
ORDER BY p.lc_number`,
|
||||
)
|
||||
.all<ProblemRow & { boost: number }>();
|
||||
const pool = results.filter((p) => {
|
||||
const w = topicWeek(p.topic_issue);
|
||||
return w !== undefined && w < week;
|
||||
});
|
||||
const boosted = pool.filter((p) => p.boost === 1);
|
||||
const regular = pool.filter((p) => p.boost !== 1);
|
||||
const boostPicks = sample(boosted, Math.min(2, budget), rng(`boost-${date}`));
|
||||
const regularPicks = sample(
|
||||
regular,
|
||||
Math.min(2, Math.max(0, budget - boostPicks.length)),
|
||||
rng(`drill-${date}`),
|
||||
);
|
||||
return [...boostPicks, ...regularPicks].slice(0, budget);
|
||||
}
|
||||
|
||||
/** Consecutive days with ≥1 attempt, ending today or yesterday. */
|
||||
export async function streak(db: D1Database, today: string): Promise<number> {
|
||||
const { results } = await db
|
||||
.prepare("SELECT DISTINCT date FROM attempts ORDER BY date DESC LIMIT 90")
|
||||
.all<{ date: string }>();
|
||||
const days = new Set(results.map((r) => r.date));
|
||||
let cursor = days.has(today) ? today : addDays(today, -1);
|
||||
let n = 0;
|
||||
while (days.has(cursor)) {
|
||||
n++;
|
||||
cursor = addDays(cursor, -1);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── the one write path ───────────────────────────────────────────
|
||||
|
||||
export interface LogOutcome {
|
||||
lc: number;
|
||||
title: string;
|
||||
kind: Kind;
|
||||
result: Result;
|
||||
stage: Stage;
|
||||
next_review: string | null;
|
||||
/** First-ever attempt — caller closes the sub-issue on pass. */
|
||||
first: boolean;
|
||||
issue: number;
|
||||
/** Email one-tap replay: nothing changed. */
|
||||
duplicate: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an attempt and move the ladder. Ladder semantics live here and only
|
||||
* here — email one-taps, webhook /done lines, gate scoring, and solutions
|
||||
* landing in work/ all converge.
|
||||
*
|
||||
* Email idempotency comes from the partial unique index on
|
||||
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
||||
* nothing and must not touch the ladder. Webhook corrections (pass then fail
|
||||
* on the same day) remain legal — every webhook attempt appends.
|
||||
*
|
||||
* Commit idempotency needs no index either. A commit names the rung the file
|
||||
* it landed in settles — `work/1` settles `new`, `work/3` settles `+3`,
|
||||
* `work/7` settles `+7` — and the stage check below writes only when the
|
||||
* ladder is actually standing on that rung. So re-pushing a solution, or the
|
||||
* reconciler re-sending its whole implemented set on every push, moves
|
||||
* nothing: the rung it names has already been left behind.
|
||||
*/
|
||||
export async function logAttempt(
|
||||
db: D1Database,
|
||||
opts: {
|
||||
lc: number;
|
||||
date: string;
|
||||
result: Result;
|
||||
source: "email" | "webhook" | "commit";
|
||||
/** Commit only: the stage this file settles. Default `new` = first solve. */
|
||||
rung?: Stage;
|
||||
gate?: boolean;
|
||||
},
|
||||
): Promise<LogOutcome> {
|
||||
const p = await getProblem(db, opts.lc);
|
||||
const nothing: LogOutcome = {
|
||||
lc: opts.lc,
|
||||
title: "",
|
||||
kind: "review",
|
||||
result: opts.result,
|
||||
stage: "new",
|
||||
next_review: null,
|
||||
first: false,
|
||||
issue: 0,
|
||||
duplicate: false,
|
||||
};
|
||||
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
|
||||
// A commit reports a file, not an event: it may only settle the rung that
|
||||
// file's bucket IS. Any other stage means the push is old news (the ladder
|
||||
// already moved past it) or premature — no write either way, and a retired
|
||||
// problem can never be woken. Fresh reviews otherwise come from the digest
|
||||
// tap, a /done comment, or a gate.
|
||||
if (opts.source === "commit" && p.stage !== (opts.rung ?? "new")) {
|
||||
return {
|
||||
...nothing,
|
||||
title: p.title,
|
||||
stage: p.stage,
|
||||
next_review: p.next_review,
|
||||
issue: p.issue,
|
||||
duplicate: true,
|
||||
};
|
||||
}
|
||||
if (p.stage === "retired") {
|
||||
return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` };
|
||||
}
|
||||
|
||||
const attempted = await db
|
||||
.prepare("SELECT 1 AS x FROM attempts WHERE lc_number = ? LIMIT 1")
|
||||
.bind(opts.lc)
|
||||
.first();
|
||||
const first = !attempted && p.stage === "new";
|
||||
const kind: Kind = opts.gate ? "gate" : first ? (p.set_label === "optional" ? "drill" : "first") : "review";
|
||||
|
||||
const inserted = await db
|
||||
.prepare(
|
||||
`INSERT INTO attempts (lc_number, date, kind, result, source) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
)
|
||||
.bind(opts.lc, opts.date, kind, opts.result, opts.source)
|
||||
.run();
|
||||
if (opts.source === "email" && inserted.meta.changes === 0) {
|
||||
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 +3 for pass AND fail.
|
||||
let stage: Stage;
|
||||
if (first) {
|
||||
stage = "+3";
|
||||
} else if (opts.result === "pass") {
|
||||
stage = NEXT_STAGE[p.stage] ?? "retired";
|
||||
} else {
|
||||
stage = "+3";
|
||||
}
|
||||
// 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(
|
||||
"UPDATE problems SET stage = ?, next_review = ?, defer_until = NULL WHERE lc_number = ?",
|
||||
).bind(stage, next, opts.lc),
|
||||
];
|
||||
if (first && kind === "drill") {
|
||||
writes.push(
|
||||
db.prepare("INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (?)").bind(opts.lc),
|
||||
);
|
||||
if (opts.result === "fail") {
|
||||
writes.push(
|
||||
db.prepare("UPDATE topics SET misses = misses + 1 WHERE issue = ?").bind(p.topic_issue),
|
||||
);
|
||||
}
|
||||
}
|
||||
await db.batch(writes);
|
||||
|
||||
return {
|
||||
lc: opts.lc,
|
||||
title: p.title,
|
||||
kind,
|
||||
result: opts.result,
|
||||
stage,
|
||||
next_review: next,
|
||||
first,
|
||||
issue: p.issue,
|
||||
duplicate: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 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> = {
|
||||
1: "I — Linear",
|
||||
2: "II — Nodal & Grid",
|
||||
3: "III — Hierarchical",
|
||||
4: "IV — Relational",
|
||||
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(
|
||||
`SELECT t.milestone AS milestone, p.set_label AS set_label,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done
|
||||
FROM problems p JOIN topics t ON t.issue = p.topic_issue
|
||||
WHERE t.milestone IS NOT NULL
|
||||
GROUP BY t.milestone, p.set_label
|
||||
ORDER BY t.milestone`,
|
||||
)
|
||||
.all<{ milestone: number; set_label: string; total: number; done: number }>();
|
||||
const phases = new Map<
|
||||
number,
|
||||
{ milestone: number; name: string } & Record<string, number | string>
|
||||
>();
|
||||
for (const row of phaseRows) {
|
||||
const phase =
|
||||
phases.get(row.milestone) ??
|
||||
({
|
||||
milestone: row.milestone,
|
||||
name: PHASE_NAMES[row.milestone] ?? `Phase ${row.milestone}`,
|
||||
core_done: 0,
|
||||
core_total: 0,
|
||||
optional_done: 0,
|
||||
optional_total: 0,
|
||||
deferred_done: 0,
|
||||
deferred_total: 0,
|
||||
} as { milestone: number; name: string } & Record<string, number | string>);
|
||||
phase[`${row.set_label}_done`] = row.done;
|
||||
phase[`${row.set_label}_total`] = row.total;
|
||||
phases.set(row.milestone, phase);
|
||||
}
|
||||
|
||||
const { results: ladderRows } = await db
|
||||
.prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage")
|
||||
.all<{ stage: string; n: number }>();
|
||||
// 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;
|
||||
|
||||
// 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. 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 }));
|
||||
|
||||
// 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,
|
||||
heat,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["esnext"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["./worker-configuration.d.ts", "bun"]
|
||||
},
|
||||
"include": ["src", "scripts"]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// SRS Worker — daily digest email, one-tap logging, weekly review issues,
|
||||
// live SVG charts, and the /api/stats feed for the docs progress page.
|
||||
//
|
||||
// D1 is the source of truth for SRS state; GitHub issues own the catalog;
|
||||
// apps/api/data/schedule.json (bundled) owns the schedule. JSONC over TOML per
|
||||
// Workers best practices (newer features are JSON-only).
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "srs-api",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2026-08-24",
|
||||
"observability": { "enabled": true },
|
||||
|
||||
"d1_databases": [
|
||||
{ "binding": "DB", "database_name": "srs", "database_id": "2ee2c34a-c2e2-4ebb-934e-c9e7dc8aa4f1" }
|
||||
],
|
||||
|
||||
// Sender domain prdlk.com is onboarded to Email Service; the binding is
|
||||
// restricted to the one verified destination inbox.
|
||||
"send_email": [
|
||||
{ "name": "EMAIL", "allowed_destination_addresses": ["prnk28@gmail.com"] }
|
||||
],
|
||||
|
||||
// Double-cron per event makes each DST-proof: the code fires only when the
|
||||
// computed ET hour matches (8 AM daily digest, midnight-Saturday review).
|
||||
// 8 AM ET = 12:00 UTC in EDT, 13:00 UTC in EST; midnight ET = 4/5 UTC.
|
||||
"triggers": {
|
||||
"crons": ["0 12 * * *", "0 13 * * *", "0 4 * * 6", "0 5 * * 6"]
|
||||
},
|
||||
|
||||
"vars": {
|
||||
"REPO": "prdlk/leetcode",
|
||||
"FROM_EMAIL": "srs@prdlk.com",
|
||||
"TO_EMAIL": "prnk28@gmail.com",
|
||||
"DOCS_URL": "https://prdlk.github.io/leetcode",
|
||||
"DOCS_ORIGIN": "https://prdlk.github.io,https://lc.prad.nu",
|
||||
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev"
|
||||
}
|
||||
|
||||
// Secrets (wrangler secret put): GH_PAT, WEBHOOK_SECRET, LINK_KEY.
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+232
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Close the GitHub issue for every LeetCode problem actually solved under work/,
|
||||
* then report the solved set to the SRS Worker so D1 agrees.
|
||||
*
|
||||
* Reconciles state instead of reacting to a push diff: any `problem`-labelled
|
||||
* issue whose LC number has an *implemented* solution file in work/ gets
|
||||
* closed. Only open issues are touched, so re-runs are no-ops and backfilling
|
||||
* needs no special casing. Removing a solution never reopens an issue.
|
||||
*
|
||||
* `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() 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
|
||||
*
|
||||
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
||||
* SRS_ADMIN_KEY for the D1 push (unset = skip it); SRS_API overrides
|
||||
* the Worker URL for local `wrangler dev` runs.
|
||||
*/
|
||||
import { join } from "node:path";
|
||||
|
||||
import { SRS_API } from "./api.ts";
|
||||
import { github } from "./github.ts";
|
||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
import { isImplemented } from "./source.ts";
|
||||
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";
|
||||
|
||||
// ── repo + auth ──────────────────────────────────────────────────
|
||||
|
||||
const gh = await github();
|
||||
|
||||
// ── work/ inventory ──────────────────────────────────────────────
|
||||
|
||||
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 -> 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 file = parseWorkPath(rel);
|
||||
if (!file) {
|
||||
console.warn(`skip (off-layout): work/${rel}`);
|
||||
continue;
|
||||
}
|
||||
const src = await Bun.file(join(WORK, rel)).text();
|
||||
const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js");
|
||||
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 ──────────────────────────────────────────
|
||||
|
||||
interface ProblemIssue {
|
||||
number: number;
|
||||
set: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow one element of the /issues payload to the fields this script needs.
|
||||
* Returns undefined for pull requests and for anything whose title is not a
|
||||
* `LC <num> ...` problem, which is how non-curriculum rows get skipped.
|
||||
*/
|
||||
function readProblemIssue(
|
||||
value: unknown,
|
||||
): { lc: number; issue: ProblemIssue } | undefined {
|
||||
if (!value || typeof value !== "object") return;
|
||||
if ("pull_request" in value) return; // the /issues route also lists PRs
|
||||
if (!("number" in value) || typeof value.number !== "number") return;
|
||||
if (!("title" in value) || typeof value.title !== "string") return;
|
||||
const lc = value.title.match(/^LC (\d+) /);
|
||||
if (!lc) return;
|
||||
|
||||
let set = "—";
|
||||
if ("labels" in value && Array.isArray(value.labels)) {
|
||||
for (const label of value.labels) {
|
||||
if (
|
||||
label &&
|
||||
typeof label === "object" &&
|
||||
"name" in label &&
|
||||
typeof label.name === "string" &&
|
||||
label.name.startsWith("set:")
|
||||
) {
|
||||
set = label.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { lc: Number(lc[1]), issue: { number: value.number, set } };
|
||||
}
|
||||
|
||||
/** LC number -> open issue carrying the `problem` label. */
|
||||
const open = new Map<number, ProblemIssue>();
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=open`)) {
|
||||
const parsed = readProblemIssue(raw);
|
||||
if (parsed) open.set(parsed.lc, parsed.issue);
|
||||
}
|
||||
|
||||
// ── reconcile ────────────────────────────────────────────────────
|
||||
|
||||
const sha = process.env.GITHUB_SHA;
|
||||
const report: string[][] = [];
|
||||
let closed = 0;
|
||||
|
||||
for (const num of [...work.keys()].sort((a, b) => a - b)) {
|
||||
const entry = work.get(num)!;
|
||||
const issue = open.get(num);
|
||||
|
||||
// Warm-ups and out-of-curriculum practice have no issue. Not an error.
|
||||
if (!issue) {
|
||||
report.push([`${num}`, "—", "—", "no open issue"]);
|
||||
continue;
|
||||
}
|
||||
if (!entry.implemented) {
|
||||
report.push([`${num}`, `#${issue.number}`, issue.set, "stub — skipped"]);
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
report.push([`${num}`, `#${issue.number}`, issue.set, "would close"]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const links = entry.files
|
||||
.map((f) =>
|
||||
sha ? `[\`${f}\`](https://github.com/${gh.repo}/blob/${sha}/${encodeURI(f)})` : `\`${f}\``,
|
||||
)
|
||||
.join(", ");
|
||||
await gh.closeIssue(
|
||||
issue.number,
|
||||
`Solved — solution committed at ${links}.\n\n` +
|
||||
"Closed automatically by `close-solved`. Fill in the close-out block above if you have not already.",
|
||||
);
|
||||
report.push([`${num}`, `#${issue.number}`, issue.set, "closed"]);
|
||||
closed++;
|
||||
}
|
||||
|
||||
// ── push the solved set into D1 ───────────────────────────────────
|
||||
|
||||
// Closing an issue is invisible to the Worker: its catalog reconcile reads
|
||||
// GitHub for titles, labels and milestones and never looks at issue state,
|
||||
// and D1's SRS columns only ever move through logAttempt(). So a solution
|
||||
// pushed here — rather than tapped in the digest email — has to be reported,
|
||||
// or the charts, the digest's "already solved" ticks, and the drill/gate
|
||||
// pools all keep treating it as untouched.
|
||||
//
|
||||
// Each entry carries the BUCKET the file sits in, which is the rung it
|
||||
// settles: work/1 is the first solve, work/3 the 3-day review, work/7 the
|
||||
// 7-day one. That is what makes a pushed re-solve advance the ladder instead
|
||||
// of vanishing — and the Worker writes only when a problem is standing on the
|
||||
// rung named, so the whole implemented set can go over on every push (a total
|
||||
// recompute that backfills whatever earlier runs missed) and re-runs write
|
||||
// nothing.
|
||||
// SRS_API (apps/cli/api.ts) is the switch that points this at a wrangler dev
|
||||
// instance instead of production.
|
||||
const solved = [...work.entries()]
|
||||
.flatMap(([lc, entry]) => [...entry.solved].sort().map((bucket) => ({ lc, bucket })))
|
||||
.sort((a, b) => a.lc - b.lc || a.bucket - b.bucket);
|
||||
|
||||
let syncNote: string;
|
||||
let syncFailed = false;
|
||||
if (!process.env.SRS_ADMIN_KEY) {
|
||||
syncNote = "SRS_ADMIN_KEY unset — D1 not touched";
|
||||
} else if (solved.length === 0) {
|
||||
syncNote = "nothing implemented — D1 not touched";
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch(`${SRS_API}/admin/solved${DRY ? "?dry=1" : ""}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${process.env.SRS_ADMIN_KEY}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ solved }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
||||
// Narrow the report payload instead of casting it; a shape change should
|
||||
// read as "logged nothing", not throw inside the reconciler.
|
||||
const payload: unknown = await res.json();
|
||||
const fields = payload && typeof payload === "object" ? payload : {};
|
||||
const strings = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : []);
|
||||
const logged = strings("logged" in fields ? fields.logged : null);
|
||||
const skipped = strings("skipped" in fields ? fields.skipped : null);
|
||||
syncNote =
|
||||
`${DRY ? "would log" : "logged"} ${logged.length} of ${solved.length} ` +
|
||||
`(${skipped.length} already on the ladder or off-curriculum)`;
|
||||
for (const line of logged) console.log(` d1: ${line}`);
|
||||
} catch (err) {
|
||||
// The closes above already landed; surface the failure instead of
|
||||
// letting D1 drift silently until the next push.
|
||||
syncFailed = true;
|
||||
syncNote = `D1 push FAILED: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── report ───────────────────────────────────────────────────────
|
||||
|
||||
const rows = [["lc", "issue", "set", "status"], ...report];
|
||||
printTable(rows);
|
||||
|
||||
const actionable = report.filter((r) => r[3] !== "no open issue");
|
||||
console.log(
|
||||
`\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}`,
|
||||
);
|
||||
|
||||
await writeStepSummary(
|
||||
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
|
||||
`${work.size} problems in \`work/\`, ${solved.length} solved files across buckets, ` +
|
||||
`${actionable.length} matched an open issue.\n\n` +
|
||||
(actionable.length
|
||||
? `${markdownTable([rows[0]!, ...actionable])}\n`
|
||||
: "Nothing to close.\n") +
|
||||
`\nD1: ${syncNote}\n`,
|
||||
);
|
||||
|
||||
// Non-zero only for the D1 push: the closes are already reported above.
|
||||
if (syncFailed) process.exitCode = 1;
|
||||
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Close the GitHub issue for every topic whose required (core) problems are done.
|
||||
*
|
||||
* Each `topic`-labelled issue owns its problems as GitHub sub-issues, and each
|
||||
* problem carries exactly one `set:` label — `set:core` is required, while
|
||||
* `set:optional` and `set:deferred` are extra credit. A topic is finished when
|
||||
* every one of its core sub-issues is closed; optional/deferred state is
|
||||
* ignored, matching the day rule in each topic body ("Core problems first").
|
||||
*
|
||||
* Like close-solved.ts this reconciles state instead of reacting to an event
|
||||
* payload: re-runs are no-ops, backfilling needs no special casing, and only
|
||||
* open topics are touched, so reopening a problem never reopens its topic.
|
||||
*
|
||||
* bun apps/cli/close-topics.ts # close finished topics
|
||||
* bun apps/cli/close-topics.ts --dry-run # report only, touch nothing
|
||||
*
|
||||
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
||||
*/
|
||||
import { 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";
|
||||
|
||||
const gh = await github();
|
||||
|
||||
// ── open topic issues ────────────────────────────────────────────
|
||||
|
||||
interface Topic {
|
||||
number: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow one element of the /issues payload to the fields this script needs.
|
||||
* The `topic` label filter cannot exclude pull requests, so drop those here.
|
||||
*/
|
||||
function readTopic(value: unknown): Topic | undefined {
|
||||
if (!value || typeof value !== "object") return;
|
||||
if ("pull_request" in value) return; // the /issues route also lists PRs
|
||||
if (!("number" in value) || typeof value.number !== "number") return;
|
||||
if (!("title" in value) || typeof value.title !== "string") return;
|
||||
return { number: value.number, title: value.title };
|
||||
}
|
||||
|
||||
const topics: Topic[] = [];
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=open`)) {
|
||||
const topic = readTopic(raw);
|
||||
if (topic) topics.push(topic);
|
||||
}
|
||||
topics.sort((a, b) => a.number - b.number);
|
||||
|
||||
// ── core sub-issue state per topic ───────────────────────────────
|
||||
|
||||
interface Core {
|
||||
/** Core sub-issues, closed and open alike, lowest number first. */
|
||||
all: number[];
|
||||
/** The core sub-issues still open — non-empty means the topic stays open. */
|
||||
pending: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a topic's core sub-issue state. The sub_issues payload carries the full
|
||||
* issue objects (state + labels), so no per-problem follow-up request is needed.
|
||||
*/
|
||||
async function readCore(topic: number): Promise<Core> {
|
||||
const all: number[] = [];
|
||||
const pending: number[] = [];
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||
if (!("state" in raw) || typeof raw.state !== "string") continue;
|
||||
if (!("labels" in raw) || !Array.isArray(raw.labels)) continue;
|
||||
|
||||
const core = raw.labels.some(
|
||||
(label) =>
|
||||
label &&
|
||||
typeof label === "object" &&
|
||||
"name" in label &&
|
||||
label.name === "set:core",
|
||||
);
|
||||
if (!core) continue;
|
||||
|
||||
all.push(raw.number);
|
||||
if (raw.state === "open") pending.push(raw.number);
|
||||
}
|
||||
all.sort((a, b) => a - b);
|
||||
pending.sort((a, b) => a - b);
|
||||
return { all, pending };
|
||||
}
|
||||
|
||||
// ── reconcile ────────────────────────────────────────────────────
|
||||
|
||||
const report: string[][] = [];
|
||||
let closed = 0;
|
||||
|
||||
for (const topic of topics) {
|
||||
const core = await readCore(topic.number);
|
||||
const progress = `${core.all.length - core.pending.length}/${core.all.length}`;
|
||||
|
||||
// A topic with no core sub-issues has nothing to complete: never close it,
|
||||
// since that would be indistinguishable from a mis-labelled problem set.
|
||||
if (core.all.length === 0) {
|
||||
report.push([`#${topic.number}`, topic.title, progress, "no core set"]);
|
||||
continue;
|
||||
}
|
||||
if (core.pending.length > 0) {
|
||||
report.push([
|
||||
`#${topic.number}`,
|
||||
topic.title,
|
||||
progress,
|
||||
`open: ${core.pending.map((n) => `#${n}`).join(" ")}`,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
report.push([`#${topic.number}`, topic.title, progress, "would close"]);
|
||||
continue;
|
||||
}
|
||||
|
||||
await gh.closeIssue(
|
||||
topic.number,
|
||||
`Core set complete — all ${core.all.length} core problems closed ` +
|
||||
`(${core.all.map((n) => `#${n}`).join(", ")}).\n\n` +
|
||||
"Closed automatically by `close-topics`. Optional and deferred problems " +
|
||||
"stay open as extra credit.",
|
||||
);
|
||||
report.push([`#${topic.number}`, topic.title, progress, "closed"]);
|
||||
closed++;
|
||||
}
|
||||
|
||||
// ── report ───────────────────────────────────────────────────────
|
||||
|
||||
const rows = [["topic", "title", "core", "status"], ...report];
|
||||
printTable(rows);
|
||||
|
||||
const finished = report.filter((r) => r[3] === "closed" || r[3] === "would close");
|
||||
console.log(
|
||||
`\n${topics.length} open topics · ` +
|
||||
`${DRY ? `would close ${finished.length}` : `closed ${closed}`}`,
|
||||
);
|
||||
|
||||
await writeStepSummary(
|
||||
`### close-topics${DRY ? " (dry run)" : ""}\n\n` +
|
||||
`${topics.length} open topic issues, ${finished.length} with a complete core set.\n\n` +
|
||||
(report.length ? `${markdownTable(rows)}\n` : "No open topics.\n"),
|
||||
);
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Local LeetCode problem cache backed by SQLite at
|
||||
* ~/.local/share/leetcode/data.db (XDG_DATA_HOME honored).
|
||||
*
|
||||
* One `problems` table, two freshness rules:
|
||||
* - the full problem index (id / title / slug / difficulty / paid flag) is
|
||||
* refreshed from leetcode.com at most once per 24h (`meta.index_fetched_at`);
|
||||
* - descriptions (HTML from GraphQL `question.content`) are fetched once per
|
||||
* slug and kept forever. Only the campaign problems linked from README.md
|
||||
* are ever fetched, and paid-only problems are skipped because GraphQL
|
||||
* returns null content for them.
|
||||
*
|
||||
* A `solutions` table tracks the lifecycle of each work/ file by LC number:
|
||||
* `created_at` (backfilled from the file's birthtime on every picker run),
|
||||
* `last_tested` and `last_submitted` (stamped on exit 0 of leetcode-cli).
|
||||
* The test picker sorts by last_tested, the submit picker by created_at, and
|
||||
* submit hides anything with a last_submitted.
|
||||
*
|
||||
* Library: side-effect-free on import — nothing is opened or fetched until
|
||||
* problemDb() is called. Index upserts never touch the description column, so
|
||||
* a daily refresh cannot evict cached statements.
|
||||
*/
|
||||
import { Database } from "bun:sqlite";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
export interface Problem {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
difficulty: "Easy" | "Medium" | "Hard";
|
||||
paidOnly: boolean;
|
||||
}
|
||||
|
||||
/** Lifecycle of one work/ solution, keyed by LC number. ISO timestamps. */
|
||||
export interface SolutionActivity {
|
||||
createdAt: string;
|
||||
lastTested: string | null;
|
||||
lastSubmitted: string | null;
|
||||
}
|
||||
|
||||
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
|
||||
const INDEX_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const DB_PATH = join(
|
||||
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
|
||||
"leetcode",
|
||||
"data.db",
|
||||
);
|
||||
|
||||
// ── README parsing ───────────────────────────────────────────────
|
||||
|
||||
/** Campaign problem slugs linked from README.md, in order, deduplicated. */
|
||||
export function readmeSlugs(markdown: string): string[] {
|
||||
const slugs = new Set<string>();
|
||||
for (const m of markdown.matchAll(/https:\/\/leetcode\.com\/problems\/([\w-]+)\//g)) {
|
||||
slugs.add(m[1]!);
|
||||
}
|
||||
return [...slugs];
|
||||
}
|
||||
|
||||
// ── remote fetches ───────────────────────────────────────────────
|
||||
|
||||
async function fetchIndex(): Promise<Problem[]> {
|
||||
const res = await fetch("https://leetcode.com/api/problems/all/", {
|
||||
headers: { "user-agent": "Mozilla/5.0" },
|
||||
});
|
||||
if (!res.ok) throw new Error(`GET /api/problems/all -> ${res.status} ${res.statusText}`);
|
||||
const data = (await res.json()) as {
|
||||
stat_status_pairs: Array<{
|
||||
stat: {
|
||||
frontend_question_id: number;
|
||||
question__title: string;
|
||||
question__title_slug: string;
|
||||
};
|
||||
difficulty: { level: 1 | 2 | 3 };
|
||||
paid_only: boolean;
|
||||
}>;
|
||||
};
|
||||
return data.stat_status_pairs
|
||||
.map((p) => ({
|
||||
id: p.stat.frontend_question_id,
|
||||
title: p.stat.question__title,
|
||||
slug: p.stat.question__title_slug,
|
||||
difficulty: DIFFICULTY[p.difficulty.level] as Problem["difficulty"],
|
||||
paidOnly: p.paid_only,
|
||||
}))
|
||||
.sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
/** HTML problem statement, or null when LeetCode withholds it (paid-only). */
|
||||
export async function fetchDescription(slug: string): Promise<string | null> {
|
||||
const res = await fetch("https://leetcode.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "Mozilla/5.0",
|
||||
referer: `https://leetcode.com/problems/${slug}/`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: "query questionContent($titleSlug: String!) { question(titleSlug: $titleSlug) { content } }",
|
||||
variables: { titleSlug: slug },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`POST /graphql (${slug}) -> ${res.status} ${res.statusText}`);
|
||||
const data = (await res.json()) as { data?: { question?: { content?: string | null } | null } };
|
||||
return data.data?.question?.content ?? null;
|
||||
}
|
||||
|
||||
// ── the cache ────────────────────────────────────────────────────
|
||||
|
||||
export function problemDb(path = DB_PATH) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const db = new Database(path);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS problems (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
difficulty TEXT NOT NULL,
|
||||
paid_only INTEGER NOT NULL,
|
||||
description TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS solutions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
last_tested TEXT,
|
||||
last_submitted TEXT
|
||||
);
|
||||
DROP TABLE IF EXISTS submissions;
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
const getMeta = db.query<{ value: string }, [string]>("SELECT value FROM meta WHERE key = ?1");
|
||||
const setMeta = db.query<never, [string, string]>(
|
||||
"INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
);
|
||||
const upsert = db.query<never, [number, string, string, string, number]>(
|
||||
`INSERT INTO problems (id, title, slug, difficulty, paid_only)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title, slug = excluded.slug,
|
||||
difficulty = excluded.difficulty, paid_only = excluded.paid_only`,
|
||||
);
|
||||
const descState = db.query<{ paid_only: number; described: number }, [string]>(
|
||||
"SELECT paid_only, description IS NOT NULL AS described FROM problems WHERE slug = ?1",
|
||||
);
|
||||
|
||||
return {
|
||||
path,
|
||||
|
||||
/** True when the index has never been fetched or is older than 24h. */
|
||||
indexStale(): boolean {
|
||||
const row = getMeta.get("index_fetched_at");
|
||||
return !row || Date.now() - Number(row.value) > INDEX_TTL_MS;
|
||||
},
|
||||
|
||||
/** Pull the full index from leetcode.com and upsert it. Returns the count. */
|
||||
async refreshIndex(): Promise<number> {
|
||||
const problems = await fetchIndex();
|
||||
db.transaction(() => {
|
||||
for (const p of problems) upsert.run(p.id, p.title, p.slug, p.difficulty, p.paidOnly ? 1 : 0);
|
||||
setMeta.run("index_fetched_at", String(Date.now()));
|
||||
})();
|
||||
return problems.length;
|
||||
},
|
||||
|
||||
/** Every cached problem, ordered by LC number. */
|
||||
all(): Problem[] {
|
||||
const rows = db
|
||||
.query<{ id: number; title: string; slug: string; difficulty: string; paid_only: number }, []>(
|
||||
"SELECT id, title, slug, difficulty, paid_only FROM problems ORDER BY id",
|
||||
)
|
||||
.all();
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
slug: r.slug,
|
||||
difficulty: r.difficulty as Problem["difficulty"],
|
||||
paidOnly: r.paid_only === 1,
|
||||
}));
|
||||
},
|
||||
|
||||
/** Free (non-premium) slugs among `slugs` with no cached description yet. */
|
||||
missingDescriptions(slugs: string[]): string[] {
|
||||
return slugs.filter((slug) => {
|
||||
const row = descState.get(slug);
|
||||
return row !== null && !row.paid_only && !row.described;
|
||||
});
|
||||
},
|
||||
|
||||
saveDescription(slug: string, html: string): void {
|
||||
db.query("UPDATE problems SET description = ?2 WHERE slug = ?1").run(slug, html);
|
||||
},
|
||||
|
||||
/** Per-solution lifecycle rows keyed by LC number. */
|
||||
solutionActivity(): Map<number, SolutionActivity> {
|
||||
const rows = db
|
||||
.query<{ id: number; created_at: string; last_tested: string | null; last_submitted: string | null }, []>(
|
||||
"SELECT id, created_at, last_tested, last_submitted FROM solutions",
|
||||
)
|
||||
.all();
|
||||
return new Map(
|
||||
rows.map((r) => [r.id, { createdAt: r.created_at, lastTested: r.last_tested, lastSubmitted: r.last_submitted }]),
|
||||
);
|
||||
},
|
||||
|
||||
/** Register a solution's creation time; existing rows are left alone. */
|
||||
ensureSolution(id: number, createdAt: string): void {
|
||||
db.query("INSERT INTO solutions (id, created_at) VALUES (?1, ?2) ON CONFLICT(id) DO NOTHING").run(id, createdAt);
|
||||
},
|
||||
|
||||
markTested(id: number): void {
|
||||
db.query(
|
||||
`INSERT INTO solutions (id, created_at, last_tested) VALUES (?1, ?2, ?2)
|
||||
ON CONFLICT(id) DO UPDATE SET last_tested = excluded.last_tested`,
|
||||
).run(id, new Date().toISOString());
|
||||
},
|
||||
|
||||
markSubmitted(id: number): void {
|
||||
db.query(
|
||||
`INSERT INTO solutions (id, created_at, last_submitted) VALUES (?1, ?2, ?2)
|
||||
ON CONFLICT(id) DO UPDATE SET last_submitted = excluded.last_submitted`,
|
||||
).run(id, new Date().toISOString());
|
||||
},
|
||||
|
||||
close(): void {
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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
|
||||
* without inheriting another script's pipeline.
|
||||
*
|
||||
* Repo: GITHUB_REPOSITORY, else the `origin` remote.
|
||||
* Auth: GH_TOKEN / GITHUB_TOKEN, else `gh auth token`.
|
||||
*/
|
||||
import { $ } from "bun";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..", "..");
|
||||
|
||||
/** Page size used for every list endpoint; also the "more pages" threshold. */
|
||||
const PER_PAGE = 100;
|
||||
|
||||
export interface GitHub {
|
||||
/** `owner/name`. */
|
||||
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. */
|
||||
closeIssue(issue: number, comment: string): Promise<void>;
|
||||
}
|
||||
|
||||
async function resolveRepo(): Promise<string> {
|
||||
if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY;
|
||||
const url = (await $`git -C ${ROOT} remote get-url origin`.text()).trim();
|
||||
const m = url.match(/github\.com[:/](.+?)(?:\.git)?$/);
|
||||
if (!m) throw new Error(`cannot derive owner/repo from remote: ${url}`);
|
||||
return m[1]!;
|
||||
}
|
||||
|
||||
async function resolveToken(): Promise<string> {
|
||||
const env = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
||||
if (env) return env;
|
||||
const token = (await $`gh auth token`.text()).trim();
|
||||
if (!token) throw new Error("no credentials: set GH_TOKEN or run `gh auth login`");
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function github(): Promise<GitHub> {
|
||||
const repo = await resolveRepo();
|
||||
const token = await resolveToken();
|
||||
|
||||
async function api(path: string, init: RequestInit = {}): Promise<unknown> {
|
||||
const res = await fetch(`https://api.github.com${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
accept: "application/vnd.github+json",
|
||||
authorization: `Bearer ${token}`,
|
||||
"x-github-api-version": "2022-11-28",
|
||||
...(init.body ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function* list(path: string): AsyncGenerator<unknown, void, void> {
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
for (let page = 1; ; page++) {
|
||||
const batch = await api(`${path}${sep}per_page=${PER_PAGE}&page=${page}`);
|
||||
if (!Array.isArray(batch)) throw new Error(`unexpected ${path} payload: not an array`);
|
||||
yield* batch;
|
||||
if (batch.length < PER_PAGE) return;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
async function closeIssue(issue: number, comment: string): Promise<void> {
|
||||
await api(`/repos/${repo}/issues/${issue}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: comment }),
|
||||
});
|
||||
await api(`/repos/${repo}/issues/${issue}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
|
||||
});
|
||||
}
|
||||
|
||||
return { repo, api, graphql, list, closeIssue };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "cli",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"pick": "bun ./pick.ts",
|
||||
"stats": "bun ./stats.ts",
|
||||
"test": "bun ./test.ts",
|
||||
"submit": "bun ./submit.ts",
|
||||
"sync": "bun ./sync.ts",
|
||||
"close-solved": "bun ./close-solved.ts",
|
||||
"close-topics": "bun ./close-topics.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^1.7.0",
|
||||
"@night-slayer18/leetcode-cli": "^3.5.0",
|
||||
"@types/bun": "^1.3.14"
|
||||
}
|
||||
}
|
||||
Executable
+255
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Fuzzy-pick a problem and scaffold it via leetcode-cli, in one of the three
|
||||
* work/ buckets (see work.ts): a first solve under work/1, or the 3-day and
|
||||
* 7-day blind re-solves under work/3 and work/7. Tab cycles the sections.
|
||||
*
|
||||
* The problem index lives in the local SQLite cache (see db.ts), refreshed at
|
||||
* most once per 24h; campaign problem descriptions from README.md are
|
||||
* backfilled into the same db on the way. The new-problem section hides
|
||||
* anything that already has a numbered file under work/1 — re-picking it would
|
||||
* only clobber the existing scaffold — and each review section hides whatever
|
||||
* its own bucket already holds, so a finished re-solve leaves the list.
|
||||
*/
|
||||
import { autocomplete, cancel, isCancel, 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();
|
||||
|
||||
// ── problem index (24h TTL) ──────────────────────────────────────
|
||||
|
||||
if (db.indexStale()) {
|
||||
const s = spinner();
|
||||
s.start("Fetching problem index from leetcode.com");
|
||||
try {
|
||||
s.stop(`Indexed ${await db.refreshIndex()} problems`);
|
||||
} catch (err) {
|
||||
if (db.all().length === 0) {
|
||||
s.error(`Fetch failed: ${err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
s.stop(`Index refresh failed (${err}) — using cached copy`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── description backfill for the campaign problems in README.md ──
|
||||
|
||||
const missing = db.missingDescriptions(readmeSlugs(await Bun.file(join(ROOT, "README.md")).text()));
|
||||
if (missing.length > 0) {
|
||||
const s = spinner();
|
||||
s.start(`Caching ${missing.length} problem descriptions`);
|
||||
let fetched = 0;
|
||||
let failed = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: 6 }, async () => {
|
||||
for (let slug = missing.shift(); slug; slug = missing.shift()) {
|
||||
try {
|
||||
const html = await fetchDescription(slug);
|
||||
if (html !== null) db.saveDescription(slug, html);
|
||||
s.message(`Caching problem descriptions (${++fetched})`);
|
||||
} catch {
|
||||
failed++; // left NULL — retried on the next run
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
s.stop(failed ? `Cached ${fetched} descriptions (${failed} failed, will retry next run)` : `Cached ${fetched} problem descriptions`);
|
||||
}
|
||||
|
||||
// ── the three sections ───────────────────────────────────────────
|
||||
|
||||
/** What Enter scaffolds: this problem, into this bucket. */
|
||||
interface Choice {
|
||||
bucket: Bucket;
|
||||
lc: number;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
label: string;
|
||||
options: { value: Choice; label: string; hint: string }[];
|
||||
}
|
||||
|
||||
const first = scanBucket(1);
|
||||
const problems = db.all();
|
||||
const titles = new Map(problems.map((p) => [p.id, p.title] as const));
|
||||
db.close();
|
||||
|
||||
const queues = reviewQueues(etDate(new Date()));
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
label: "new",
|
||||
options: problems
|
||||
.filter((p) => !first.has(p.id))
|
||||
.map((p) => ({
|
||||
value: { bucket: 1 as Bucket, lc: p.id, slug: p.slug },
|
||||
label: `${p.id}. ${p.title}`,
|
||||
hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty,
|
||||
})),
|
||||
},
|
||||
...WINDOWS.map((window) => ({
|
||||
label: `${window}-day`,
|
||||
options: queues.get(window)!.map((owed) => ({
|
||||
value: { bucket: window as Bucket, lc: owed.lc, slug: owed.slug },
|
||||
label: `${owed.lc}. ${titles.get(owed.lc) ?? owed.slug}`,
|
||||
hint: `${owed.difficulty} · ${owed.category} · solved ${owed.solved}, ${owed.days}d ago`,
|
||||
})),
|
||||
})),
|
||||
];
|
||||
|
||||
let active = 0;
|
||||
|
||||
/**
|
||||
* The slice of clack's AutocompletePrompt this picker drives. @clack/prompts
|
||||
* re-exports neither the class nor its type, and apps/cli does not depend on
|
||||
* @clack/core directly, so the contract is spelled out structurally.
|
||||
*/
|
||||
interface Autocomplete {
|
||||
userInput: string;
|
||||
on(event: "key", cb: (char: string | undefined, key: { name?: string }) => void): void;
|
||||
emit(event: "userInput", input: string): void;
|
||||
}
|
||||
|
||||
/** Search text that cannot match any option — see refresh() below. */
|
||||
const NO_MATCH = "\u0000";
|
||||
|
||||
let hooked = false;
|
||||
|
||||
/**
|
||||
* Bind Tab to the section switch, once per prompt.
|
||||
*
|
||||
* clack has no hook for extra keys, but it calls `options` with the prompt as
|
||||
* `this`, which is all a subscription needs. Switching sections also has to
|
||||
* re-filter the list, and the prompt only recomputes `filteredOptions` when
|
||||
* the search text *changes* — hence the pair of userInput events: an
|
||||
* unmatchable string, then the real one, which recomputes against the new
|
||||
* section, resets the cursor onto its first match and leaves the typed query
|
||||
* on screen untouched. clack repaints as soon as the key handlers return.
|
||||
*
|
||||
* No `placeholder` is passed to the prompt on purpose: clack's own Tab binding
|
||||
* (fill the input with the placeholder) is inert without one.
|
||||
*/
|
||||
function hookTab(prompt: Autocomplete): void {
|
||||
if (hooked) return;
|
||||
hooked = true;
|
||||
prompt.on("key", (_char, key) => {
|
||||
if (key.name !== "tab") return;
|
||||
active = (active + 1) % sections.length;
|
||||
prompt.emit("userInput", NO_MATCH);
|
||||
prompt.emit("userInput", prompt.userInput);
|
||||
});
|
||||
}
|
||||
|
||||
// Widened, not asserted: clack resolves `undefined` when Enter lands on an
|
||||
// empty list, which its own types do not admit.
|
||||
const picked: Choice | symbol | undefined = await autocomplete<Choice>({
|
||||
// Getter, not a string: clack reads `message` on every repaint, so the tab
|
||||
// bar tracks the active section.
|
||||
get message(): string {
|
||||
const bar = sections
|
||||
.map((section, i) => {
|
||||
const text = `${section.label} (${section.options.length})`;
|
||||
return i === active ? styleText("cyan", `▸ ${text}`) : styleText("dim", text);
|
||||
})
|
||||
.join(styleText("dim", " · "));
|
||||
return `Pick a problem ${bar} ${styleText("dim", "Tab: next section")}`;
|
||||
},
|
||||
maxItems: 12,
|
||||
options(this: Autocomplete) {
|
||||
hookTab(this);
|
||||
return sections[active]!.options;
|
||||
},
|
||||
filter: (search, option) => {
|
||||
const haystack = option.label!.toLowerCase();
|
||||
return search
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.every((token) => haystack.includes(token));
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Console + GitHub Actions reporting shared by the issue reconcilers.
|
||||
*
|
||||
* Both reconcilers end the same way: an aligned table on stdout, and the same
|
||||
* table as Markdown in the step summary when running under Actions.
|
||||
*/
|
||||
|
||||
/** Print rows[0] as a header, a rule, then the body — every column padded. */
|
||||
export function printTable(rows: string[][]): void {
|
||||
const widths = rows[0]!.map((_, i) => Math.max(...rows.map((r) => r[i]!.length)));
|
||||
const render = (r: string[]) =>
|
||||
r
|
||||
.map((cell, i) => cell.padEnd(widths[i]!))
|
||||
.join(" ")
|
||||
.trimEnd();
|
||||
|
||||
console.log(render(rows[0]!));
|
||||
console.log(widths.map((n) => "─".repeat(n)).join(" "));
|
||||
for (const row of rows.slice(1)) console.log(render(row));
|
||||
}
|
||||
|
||||
/** Same rows as a GitHub-flavoured Markdown table; rows[0] is the header. */
|
||||
export function markdownTable(rows: string[][]): string {
|
||||
const [header, ...body] = rows;
|
||||
return [
|
||||
`| ${header!.join(" | ")} |`,
|
||||
`| ${header!.map(() => "---").join(" | ")} |`,
|
||||
...body.map((r) => `| ${r.join(" | ")} |`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Append to the Actions step summary; a no-op outside Actions. */
|
||||
export async function writeStepSummary(markdown: string): Promise<void> {
|
||||
const path = process.env.GITHUB_STEP_SUMMARY;
|
||||
if (!path) return;
|
||||
await Bun.write(path, markdown);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Shared work/ solution picker for the test and submit entries: fuzzy-select
|
||||
* a solution file, then hand it to leetcode-cli.
|
||||
*
|
||||
* Every bucket is listed — a 3-day or 7-day re-solve is tested and submitted
|
||||
* exactly like a first solve — and the hint says which one a file belongs to.
|
||||
* The db's per-solution timestamps are keyed by LC number, so the buckets of
|
||||
* one problem share them.
|
||||
*
|
||||
* Every run backfills the db's solutions table with created_at (from the
|
||||
* file's birthtime, mtime when the filesystem has none) so ordering works for
|
||||
* files that predate the db. Ordering is per command — test lists the most
|
||||
* recently tested first (never-tested after, by name); submit lists the most
|
||||
* recently created first. Nothing is ever hidden. Exit 0 of leetcode-cli
|
||||
* stamps last_tested / last_submitted.
|
||||
*
|
||||
* Library: side-effect-free on import — nothing scans the filesystem until
|
||||
* runOnSolution() is called.
|
||||
*/
|
||||
import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
|
||||
import { statSync } from "node:fs";
|
||||
import { basename, join, relative } from "node:path";
|
||||
import { problemDb } from "./db";
|
||||
import { ROOT, WORK as WORK_DIR, parseWorkPath } from "./work";
|
||||
|
||||
/** Leading LC number of a work/ file, or undefined for unnumbered names. */
|
||||
function lcNumber(rel: string): number | undefined {
|
||||
const m = basename(rel).match(/^(\d+)\./);
|
||||
return m ? Number(m[1]) : undefined;
|
||||
}
|
||||
|
||||
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
|
||||
export async function runOnSolution(command: "test" | "submit", message: string): Promise<never> {
|
||||
const db = problemDb();
|
||||
|
||||
const scanned = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })];
|
||||
|
||||
// register creation times so files older than the db still sort correctly
|
||||
for (const f of scanned) {
|
||||
const num = lcNumber(f);
|
||||
if (num === undefined) continue;
|
||||
const stat = statSync(join(WORK_DIR, f));
|
||||
db.ensureSolution(num, new Date(stat.birthtimeMs || stat.mtimeMs).toISOString());
|
||||
}
|
||||
const activity = db.solutionActivity();
|
||||
|
||||
// test → most recently tested first; submit → most recently created first
|
||||
const sortKey = (f: string) => {
|
||||
const num = lcNumber(f);
|
||||
const row = num === undefined ? undefined : activity.get(num);
|
||||
return (command === "test" ? row?.lastTested : row?.createdAt) ?? undefined;
|
||||
};
|
||||
|
||||
const files = scanned
|
||||
.sort((a, b) => {
|
||||
const ka = sortKey(a);
|
||||
const kb = sortKey(b);
|
||||
if (ka !== kb) {
|
||||
if (ka === undefined) return 1;
|
||||
if (kb === undefined) return -1;
|
||||
return kb.localeCompare(ka); // ISO timestamps: lexicographic = chronological
|
||||
}
|
||||
return a.localeCompare(b, undefined, { numeric: true });
|
||||
});
|
||||
|
||||
if (files.length === 0) {
|
||||
log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const picked = await autocomplete<string>({
|
||||
message,
|
||||
placeholder: "Type to search...",
|
||||
maxItems: 12,
|
||||
options: files.map((f) => {
|
||||
const parsed = parseWorkPath(f);
|
||||
const stage = parsed?.bucket === 1 ? "first solve" : `${parsed?.bucket}-day review`;
|
||||
return {
|
||||
value: f,
|
||||
label: basename(f),
|
||||
hint: parsed ? `${stage} · ${parsed.difficulty} · ${parsed.category}` : f,
|
||||
};
|
||||
}),
|
||||
filter: (search, option) => {
|
||||
const haystack = option.value.toLowerCase();
|
||||
return search
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.every((token) => haystack.includes(token));
|
||||
},
|
||||
});
|
||||
|
||||
if (isCancel(picked)) {
|
||||
cancel("Nothing selected.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
// bun installs workspace-dep bins into this package's node_modules, not the root's
|
||||
join(import.meta.dir, "node_modules", ".bin", "leetcode"),
|
||||
command,
|
||||
join(WORK_DIR, picked),
|
||||
],
|
||||
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
|
||||
);
|
||||
const code = await proc.exited;
|
||||
const num = lcNumber(picked);
|
||||
if (code === 0 && num !== undefined) {
|
||||
if (command === "test") db.markTested(num);
|
||||
else db.markSubmitted(num);
|
||||
}
|
||||
db.close();
|
||||
process.exit(code);
|
||||
}
|
||||
@@ -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
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Fuzzy-select a solution file from work/ and submit it to LeetCode's judge.
|
||||
*/
|
||||
import { runOnSolution } from "./solutions";
|
||||
|
||||
await runOnSolution("submit", "Submit which solution?");
|
||||
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));
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Sync work/ leetcode solutions into docs/solutions/ 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,13 +9,17 @@
|
||||
* - 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 DOCS = join(ROOT, "docs", "solutions");
|
||||
const ROOT = join(import.meta.dir, "..", "..");
|
||||
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));
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Fuzzy-select a solution file from work/ and run leetcode-cli tests on it.
|
||||
*/
|
||||
import { runOnSolution } from "./solutions";
|
||||
|
||||
await runOnSolution("test", "Test which solution?");
|
||||
+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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.blume-verify/
|
||||
@@ -7,11 +7,14 @@ export default defineConfig({
|
||||
site: "https://prdlk.github.io",
|
||||
base: "/leetcode",
|
||||
},
|
||||
content: {
|
||||
root: "content",
|
||||
},
|
||||
navigation: {
|
||||
tabs: [
|
||||
{
|
||||
label: "Solutions",
|
||||
path: "/solutions",
|
||||
label: "Reference",
|
||||
path: "/reference",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -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" />
|
||||
+20
@@ -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" />
|
||||
+22
@@ -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
|
||||
+12
@@ -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
|
||||
+13
@@ -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
|
||||
+11
@@ -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" />
|
||||
+16
@@ -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" />
|
||||
+20
@@ -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" />
|
||||
+17
@@ -27,6 +27,19 @@ 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
|
||||
@@ -45,3 +58,7 @@ var containsDuplicate = function(nums) {
|
||||
return false;
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="3OamzN90kPg" title="Contains Duplicate - Leetcode 217 - Python" />
|
||||
+15
@@ -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" />
|
||||
+16
@@ -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" />
|
||||
+19
@@ -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" />
|
||||
+27
@@ -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" />
|
||||
+14
@@ -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" />
|
||||
@@ -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()
|
||||
```
|
||||
+19
@@ -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
|
||||
```
|
||||
+19
@@ -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" />
|
||||
+20
@@ -29,6 +29,22 @@ You must write an algorithm with O(log n) runtime complexity.
|
||||
- All the integers in `nums` are unique.
|
||||
- `nums` is sorted in ascending order.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["search(nums, target)"]) --> I["left = 0, right = n - 1"]
|
||||
I --> W{"left <= right?"}
|
||||
W -- no --> E(["return -1"])
|
||||
W -- yes --> M["mid = left + floor((right - left) / 2)"]
|
||||
M --> Q{"nums[mid] vs target"}
|
||||
Q -- equal --> R(["return mid"])
|
||||
Q -- "nums[mid] < target" --> L["left = mid + 1"]
|
||||
L --> W
|
||||
Q -- "nums[mid] > target" --> H["right = mid - 1"]
|
||||
H --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```js
|
||||
@@ -54,3 +70,7 @@ var search = function(nums, target) {
|
||||
return -1;
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="s4DPM8ct1pI" title="Binary Search - Leetcode 704 - Python" />
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: '73. Set Matrix Zeroes'
|
||||
description: Given an m x n integer matrix matrix, if an element is 0, set its entire row and column to 0's
|
||||
sidebar:
|
||||
label: 'Set Matrix Zeroes'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Matrix Index Math</Badge>
|
||||
|
||||
::::warning
|
||||
You must do it in place.
|
||||
::::
|
||||
|
||||
### Example 1:
|
||||
- Input: `matrix = [[1,1,1],[1,0,1],[1,1,1]]`
|
||||
- Output: `[[1,0,1],[0,0,0],[1,0,1]]`
|
||||
|
||||
### Example 2:
|
||||
- Input: `matrix = [[0,1,2,0],[3,4,5,2],[1,3,1,5]]`
|
||||
- Output: `[[0,0,0,0],[0,4,5,0],[0,3,1,0]]`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `m == matrix.length`
|
||||
- `n == matrix[0].length`
|
||||
- `1 <= m, n <= 200`
|
||||
- `-2^31 <= matrix[i][j] <= 2^31 - 1`
|
||||
- A straightforward solution using O(mn) space is probably a bad idea.
|
||||
- A simple improvement uses O(m + n) space, but still not the best solution.
|
||||
- Could you devise a constant space solution?
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def setZeroes(self, matrix: List[List[int]]) -> None:
|
||||
"""
|
||||
Do not return anything, modify matrix in-place instead.
|
||||
"""
|
||||
m, n = len(matrix), len(matrix[0])
|
||||
|
||||
# 1. remember if row 0 or column 0 have their own zeros
|
||||
row0_zero = any(matrix[0][c] == 0 for c in range(n))
|
||||
col0_zero = any(matrix[r][0] == 0 for r in range(m))
|
||||
|
||||
# 2. use row 0 and column 0 as marker lists
|
||||
for r in range(1, m):
|
||||
for c in range(1, n):
|
||||
if matrix[r][c] == 0:
|
||||
matrix[r][0] = 0
|
||||
matrix[0][c] = 0
|
||||
|
||||
# 3. zero the inner cells from the markers
|
||||
for r in range(1, m):
|
||||
for c in range(1, n):
|
||||
if matrix[r][0] == 0 or matrix[0][c] == 0:
|
||||
matrix[r][c] = 0
|
||||
|
||||
# 4. handle row 0 and column 0 last
|
||||
if row0_zero:
|
||||
for c in range(n):
|
||||
matrix[0][c] = 0
|
||||
if col0_zero:
|
||||
for r in range(m):
|
||||
matrix[r][0] = 0
|
||||
```
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: '739. Daily Temperatures'
|
||||
description: Given an array of integers temperatures represents the daily temperatures, return an array answer such that answer[i] is the number of days you have to wait after the i^th day to get a warmer temperature. If there is no future day for which this is possible, keep answer[i] == 0 instead
|
||||
sidebar:
|
||||
label: 'Daily Temperatures'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Monotonic Stack</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `temperatures = [73,74,75,71,69,72,76,73]`
|
||||
- Output: `[1,1,4,2,1,1,0,0]`
|
||||
|
||||
### Example 2:
|
||||
- Input: `temperatures = [30,40,50,60]`
|
||||
- Output: `[1,1,1,0]`
|
||||
|
||||
### Example 3:
|
||||
- Input: `temperatures = [30,60,90]`
|
||||
- Output: `[1,1,0]`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= temperatures.length <= 10^5`
|
||||
- `30 <= temperatures[i] <= 100`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["dailyTemperatures(temperatures)"]) --> I["ans = zeros(n), stack = [] — indices of days still waiting"]
|
||||
I --> L{"more (i, temp)?"}
|
||||
L -- no --> E(["return ans"])
|
||||
L -- yes --> W{"stack non-empty and temperatures[stack[-1]] < temp?"}
|
||||
W -- yes --> P["prev = stack.pop(); ans[prev] = i - prev — today resolves that day"]
|
||||
P --> W
|
||||
W -- no --> A["stack.append(i)"]
|
||||
A --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
|
||||
ans = [0] * len(temperatures)
|
||||
stack = []
|
||||
for i, temp in enumerate(temperatures):
|
||||
while stack and temperatures[stack[-1]] < temp:
|
||||
prev = stack.pop()
|
||||
ans[prev] = i - prev
|
||||
stack.append(i)
|
||||
return ans
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="cTBiBSnjO3c" title="Daily Temperatures - Monotonic Stack - Leetcode 739 - Python" />
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
title: '74. Search a 2D Matrix'
|
||||
description: 'You are given an m x n integer matrix matrix with the following two properties:'
|
||||
sidebar:
|
||||
label: 'Search a 2D Matrix'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search</Badge>
|
||||
|
||||
::::warning
|
||||
You must write a solution in O(log(m * n)) time complexity.
|
||||
::::
|
||||
|
||||
### Example 1:
|
||||
- Input: `matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 3`
|
||||
- Output: `true`
|
||||
|
||||
### Example 2:
|
||||
- Input: `matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 13`
|
||||
- Output: `false`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `m == matrix.length`
|
||||
- `n == matrix[i].length`
|
||||
- `1 <= m, n <= 100`
|
||||
- `-10^4 <= matrix[i][j], target <= 10^4`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["searchMatrix(matrix, target)"]) --> I["top = 0, bot = ROWS - 1"]
|
||||
I --> W1{"top <= bot?"}
|
||||
W1 -- yes --> R1["row = (top + bot) // 2"]
|
||||
R1 --> C1{"target vs that row's range"}
|
||||
C1 -- "above matrix[row][-1]" --> U["top = row + 1"]
|
||||
C1 -- "below matrix[row][0]" --> V["bot = row - 1"]
|
||||
C1 -- inside --> B(["break — row found"])
|
||||
U --> W1
|
||||
V --> W1
|
||||
W1 -- no --> F(["return False — no row can hold it"])
|
||||
B --> P["row = (top + bot) // 2; l = 0, r = COLS - 1"]
|
||||
P --> W2{"l <= r?"}
|
||||
W2 -- no --> G(["return False"])
|
||||
W2 -- yes --> M["m = (l + r) // 2"]
|
||||
M --> C2{"target vs matrix[row][m]"}
|
||||
C2 -- greater --> X["l = m + 1"]
|
||||
C2 -- smaller --> Y["r = m - 1"]
|
||||
C2 -- equal --> T(["return True"])
|
||||
X --> W2
|
||||
Y --> W2
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
|
||||
ROWS, COLS = len(matrix), len(matrix[0])
|
||||
|
||||
top, bot = 0, ROWS - 1
|
||||
while top <= bot:
|
||||
row = (top + bot) // 2
|
||||
if target > matrix[row][-1]:
|
||||
top = row + 1
|
||||
elif target < matrix[row][0]:
|
||||
bot = row - 1
|
||||
else:
|
||||
break
|
||||
|
||||
if not (top <= bot):
|
||||
return False
|
||||
|
||||
row = (top + bot) // 2
|
||||
l, r = 0, COLS - 1
|
||||
while l <= r:
|
||||
m = (l + r) // 2
|
||||
if target > matrix[row][m]:
|
||||
l = m + 1
|
||||
elif target < matrix[row][m]:
|
||||
r = m - 1
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="Ber2pi2C0j0" title="Search a 2D Matrix - Leetcode 74 - Python" />
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: '853. Car Fleet'
|
||||
description: There are n cars at given miles away from the starting mile 0, traveling to reach the mile target
|
||||
sidebar:
|
||||
label: 'Car Fleet'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Monotonic Stack</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `target = 12, position = [10,8,0,5,3], speed = [2,4,1,1,3]`
|
||||
- Output: `3`
|
||||
- Explanation: • The cars starting at `10 (speed 2`) and `8 (speed 4`) become a fleet, meeting each other at `12`. The fleet forms at `target`. • The car starting at `0 (speed 1`) does not catch up to any other car, so it is a fleet by itself. • The cars starting at `5 (speed 1`) and `3 (speed 3`) become a fleet, meeting each other at `6`. The fleet moves at `speed 1` until it reaches `target`.
|
||||
|
||||
### Example 2:
|
||||
- Input: `target = 10, position = [3], speed = [3]`
|
||||
- Output: `1`
|
||||
- Explanation: There is only one car, hence there is only one fleet.
|
||||
|
||||
### Example 3:
|
||||
- Input: `target = 100, position = [0,2,4], speed = [4,2,1]`
|
||||
- Output: `1`
|
||||
- Explanation: • The cars starting at `0 (speed 4`) and `2 (speed 2`) become a fleet, meeting each other at `4`. The car starting at `4 (speed 1`) travels to `5`. • Then, the fleet at `4 (speed 2`) and the car at `position 5 (speed 1`) become one fleet, meeting each other at `6`. The fleet moves at `speed 1` until it reaches `target`.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `n == position.length == speed.length`
|
||||
- `1 <= n <= 10^5`
|
||||
- `0 < target <= 10^6`
|
||||
- `0 <= position[i] < target`
|
||||
- All the values of `position` are unique.
|
||||
- `0 < speed[i] <= 10^6`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["carFleet(target, position, speed)"]) --> O["zip and sort by position, descending — nearest the target first"]
|
||||
O --> I["stack = [] — arrival time of each fleet's leader"]
|
||||
I --> L{"more (pos, spd)?"}
|
||||
L -- no --> E(["return len(stack) — one entry per fleet"])
|
||||
L -- yes --> T["time = (target - pos) / spd"]
|
||||
T --> Q{"stack empty or time > stack[-1]?"}
|
||||
Q -- yes --> A["stack.append(time) — slower, so it starts a new fleet"]
|
||||
Q -- no --> B["discard — it catches the car ahead and joins that fleet"]
|
||||
A --> L
|
||||
B --> L
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def carFleet(self, target: int, position: List[int], speed: List[int]) -> int:
|
||||
stack = []
|
||||
for pos, spd in sorted(zip(position, speed), reverse=True):
|
||||
time = (target - pos) / spd
|
||||
if not stack or time > stack[-1]:
|
||||
stack.append(time)
|
||||
|
||||
return len(stack)
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="Pr6T-3yB9RM" title="Car Fleet - Leetcode 853 - Python" />
|
||||
+20
@@ -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" />
|
||||
+19
@@ -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" />
|
||||
+13
@@ -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
|
||||
+17
@@ -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" />
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: '3. Longest Substring Without Repeating Characters'
|
||||
description: Given a string s, find the length of the longest substring without duplicate characters
|
||||
sidebar:
|
||||
label: 'Longest Substring Without Repeating Characters'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Sliding Window</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "abcabcbb"`
|
||||
- Output: `3`
|
||||
- Explanation: The answer is "abc", with the length of `3`. Note that "bca" and "cab" are also correct answers.
|
||||
|
||||
### Example 2:
|
||||
- Input: `s = "bbbbb"`
|
||||
- Output: `1`
|
||||
- Explanation: The answer is "b", with the length of `1`.
|
||||
|
||||
### Example 3:
|
||||
- Input: `s = "pwwkew"`
|
||||
- Output: `3`
|
||||
- Explanation: The answer is "wke", with the length of `3`. Notice that the answer must be a substring, "pwke" is a subsequence and not a substring.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `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
|
||||
class Solution:
|
||||
def lengthOfLongestSubstring(self, s: str) -> int:
|
||||
left = 0
|
||||
ans = 0
|
||||
window = set()
|
||||
for right, c in enumerate(s):
|
||||
while c in window:
|
||||
window.remove(s[left])
|
||||
left += 1
|
||||
window.add(c)
|
||||
ans = max(ans, right - left + 1)
|
||||
|
||||
return ans
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="wiGpQwVHdE0" title="Longest Substring Without Repeating Characters - Leetcode 3 - Python" />
|
||||
+16
@@ -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" />
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: '424. Longest Repeating Character Replacement'
|
||||
description: You are given a string s and an integer k. You can choose any character of the string and change it to any other uppercase English character. You can perform this operation at most k times
|
||||
sidebar:
|
||||
label: 'Longest Repeating Character Replacement'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Sliding Window</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "ABAB", k = 2`
|
||||
- Output: `4`
|
||||
- Explanation: Replace the two 'A's with two 'B's or vice versa.
|
||||
|
||||
### Example 2:
|
||||
- Input: `s = "AABABBA", k = 1`
|
||||
- Output: `4`
|
||||
- Explanation: Replace the one 'A' in the middle with 'B' and form "AABBBBA". The substring "BBBB" has the longest repeating letters, which is `4`. There may exists other ways to achieve this answer too.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= s.length <= 10^5`
|
||||
- `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
|
||||
class Solution:
|
||||
def characterReplacement(self, s: str, k: int) -> int:
|
||||
count = {}
|
||||
res = 0
|
||||
|
||||
l = 0
|
||||
maxF = 0
|
||||
for r, c in enumerate(s):
|
||||
count[c] = 1 + count.get(c, 0)
|
||||
maxF = max(maxF, count[c])
|
||||
|
||||
while (r - l + 1) - maxF > k:
|
||||
count[s[l]] -= 1
|
||||
l += 1
|
||||
|
||||
res = max(res, r - l + 1)
|
||||
return res
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="gqXU1UyA8pk" title="Longest Repeating Character Replacement - Leetcode 424 - Python" />
|
||||
+16
@@ -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" />
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: '567. Permutation in String'
|
||||
description: Given two strings s1 and s2, return true if s2 contains a permutation of s1, or false otherwise
|
||||
sidebar:
|
||||
label: 'Permutation in String'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Sliding Window</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s1 = "ab", s2 = "eidbaooo"`
|
||||
- Output: `true`
|
||||
- Explanation: `s2` contains one permutation of `s1` ("ba").
|
||||
|
||||
### Example 2:
|
||||
- Input: `s1 = "ab", s2 = "eidboaoo"`
|
||||
- Output: `false`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `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
|
||||
from collections import Counter
|
||||
|
||||
|
||||
class Solution:
|
||||
def checkInclusion(self, s1: str, s2: str) -> bool:
|
||||
k = len(s1)
|
||||
if k > len(s2):
|
||||
return False
|
||||
|
||||
need = Counter(s1)
|
||||
window = Counter()
|
||||
|
||||
for right, c in enumerate(s2):
|
||||
window[c] += 1
|
||||
if right >= k:
|
||||
left_char = s2[right - k]
|
||||
window[left_char] -= 1
|
||||
if window[left_char] == 0:
|
||||
del window[left_char]
|
||||
if window == need:
|
||||
return True
|
||||
|
||||
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,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" />
|
||||
+18
@@ -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" />
|
||||
+16
@@ -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" />
|
||||
+20
@@ -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" />
|
||||
File renamed without changes.
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user