mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
# No install step: the script only uses Bun builtins + fetch.
|
# No install step: the script only uses Bun builtins + fetch.
|
||||||
|
#
|
||||||
|
# SRS_ADMIN_KEY lets the script report the solved set to the Worker
|
||||||
|
# (POST /admin/solved). Closing an issue is invisible to D1 — the
|
||||||
|
# catalog reconcile never reads issue state, and only logAttempt() moves
|
||||||
|
# the ladder — so without this a pushed solution would never reach the
|
||||||
|
# charts, the digest's solved ticks, or the drill/gate pools.
|
||||||
- run: bun apps/cli/close-solved.ts
|
- run: bun apps/cli/close-solved.ts
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
DRY_RUN: ${{ inputs.dry_run }}
|
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
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
name: Close Topics
|
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:
|
on:
|
||||||
# Close Solved closes problem issues with GITHUB_TOKEN, and token-driven
|
# Close Solved closes problem issues with GITHUB_TOKEN, and token-driven
|
||||||
# closes never fire `issues` events — so chain off its run instead.
|
# closes never fire `issues` events — so chain off its run instead.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: Spaced Repetition
|
||||||
|
|
||||||
|
# Opens (or refreshes) today's `Spaced Repetition — <Month D, YYYY>` issue from
|
||||||
|
# whatever the SRS ladder has due (`POST /admin/due`, the same rows the 8 AM
|
||||||
|
# digest mails — so the two cannot disagree). The script stores nothing and
|
||||||
|
# schedules nothing: it asks D1 what is standing today, keyed by the ET date in
|
||||||
|
# the title, so a re-run rewrites one body instead of stacking duplicates.
|
||||||
|
# Sunday is the rest day and the ladder never books it, so nothing is written.
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# 10:00 UTC = 06:00 ET while the campaign runs (EDT through Nov 1), 05:00 ET
|
||||||
|
# after. No DST guard and no second cron: the issue is keyed by ET calendar
|
||||||
|
# date, not by the hour it was created, so the drift is cosmetic.
|
||||||
|
- cron: "0 10 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dry_run:
|
||||||
|
description: Print the issue body without creating or updating anything
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
date:
|
||||||
|
description: ET date to treat as today (YYYY-MM-DD) — backfill a missed day
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: spaced-repetition
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
drill:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
# No install step: the script only uses Bun builtins + fetch.
|
||||||
|
#
|
||||||
|
# SRS_ADMIN_KEY reads today's due list from the Worker. Unset is fatal
|
||||||
|
# here, unlike in close-solved: without D1 there is no schedule to render,
|
||||||
|
# and inventing one from issue close dates is exactly the second source of
|
||||||
|
# truth this replaced.
|
||||||
|
- run: bun apps/cli/spaced-repetition.ts
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
DRY_RUN: ${{ inputs.dry_run }}
|
||||||
|
SRS_TODAY: ${{ inputs.date }}
|
||||||
|
SRS_ADMIN_KEY: ${{ secrets.SRS_ADMIN_KEY }}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
node_modules
|
node_modules
|
||||||
tmp/
|
tmp/
|
||||||
|
*.log
|
||||||
|
.crush
|
||||||
|
|
||||||
# output
|
# output
|
||||||
out
|
out
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues own ~161 `problem` sub-issues (`set:core|optional|deferred`, `diff:*`); milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
|
LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
|
||||||
|
|
||||||
## Architecture & Data Flow
|
## Architecture & Data Flow
|
||||||
|
|
||||||
@@ -12,18 +12,26 @@ Three owners, one direction of truth:
|
|||||||
flowchart LR
|
flowchart LR
|
||||||
work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
|
work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
|
||||||
work -->|close-solved.yml| PI[problem issues]
|
work -->|close-solved.yml| PI[problem issues]
|
||||||
|
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
|
||||||
PI -->|close-topics.yml| TI[topic issues]
|
PI -->|close-topics.yml| TI[topic issues]
|
||||||
GH[GitHub issues = catalog] -->|nightly reconcile| D1[(D1 = SRS state)]
|
GH[GitHub issues = catalog] -->|nightly reconcile| D1
|
||||||
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
|
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
|
||||||
Mail -->|GET /log| D1
|
Mail -->|GET /log| D1
|
||||||
PI -->|/done webhook| D1
|
PI -->|/done webhook| D1
|
||||||
D1 -->|best-effort mirror| Proj[GitHub Project fields]
|
D1 -->|best-effort mirror: Target Date only| Proj[GitHub Project fields]
|
||||||
|
GH -->|close-solved.yml sync-project-fields| Proj
|
||||||
D1 -->|Sat midnight ET| Review[Review — Week N issue]
|
D1 -->|Sat midnight ET| Review[Review — Week N issue]
|
||||||
|
PI -->|"spaced-repetition.yml (6 AM ET cron, closed +3d/+7d)"| SR[Spaced Repetition — Month D issue]
|
||||||
D1 --> Charts[SVG charts + /api/stats]
|
D1 --> Charts[SVG charts + /api/stats]
|
||||||
```
|
```
|
||||||
|
|
||||||
- **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.
|
- **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.
|
||||||
- **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). Project fields (`Target Date`, `SRS Stage`, `First Attempt`) are a best-effort mirror (`apps/api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written.
|
- **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).
|
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
|
||||||
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
|
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
|
||||||
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
||||||
@@ -32,24 +40,41 @@ flowchart LR
|
|||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `work/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/Easy/Array/1.two-sum.py`. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
||||||
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
|
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
|
||||||
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `islands/`, `public/`). `content/(<category>)/<num>-<slug>.mdx` generated by sync |
|
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `islands/`, `public/`). `content/(<category>)/<num>-<slug>.mdx` generated by sync |
|
||||||
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (day → topic issue, human-edited, bundled at deploy), `migrations/`, `scripts/import-srs.ts` |
|
| `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, close-topics, sync-d1 (issue edits → `/admin/reconcile`) |
|
| `.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
|
## Development Commands
|
||||||
|
|
||||||
All from the repo root (a Bun workspace over `apps/*`):
|
All from the repo root (a Bun workspace over `apps/*`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bun run pick # scaffold a solution via leetcode-cli (fuzzy picker)
|
bun run pick # scaffold a solution via leetcode-cli (fuzzy picker; Tab cycles the
|
||||||
bun run test # run ONE solution against LeetCode's judge (not a test suite)
|
# sections: new problems → work/1, then the owed 3-day and 7-day
|
||||||
bun run sync # work/ → apps/docs/content/ pages
|
# re-solves → work/3, work/7. Each section hides what its own bucket
|
||||||
|
# already holds; index+descriptions cached in
|
||||||
|
# ~/.local/share/leetcode/data.db — see apps/cli/db.ts)
|
||||||
|
bun run test # run ONE solution against LeetCode's judge (not a test suite;
|
||||||
|
# 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 dev|build # Blume docs site (runs in apps/docs/)
|
||||||
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
|
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
|
||||||
bun run close-topics -- --dry-run
|
bun run close-topics -- --dry-run
|
||||||
bun run api:dev # wrangler dev on :8787 (local D1); api:test = DST guard tests
|
# close-solved also pushes the solved set to the Worker; point it at a local
|
||||||
|
# one and nothing production is touched:
|
||||||
|
SRS_API=http://localhost:8787 SRS_ADMIN_KEY=$LINK_KEY bun run close-solved -- --dry-run
|
||||||
|
# Project Set/Difficulty from the issue labels (needs a `project`-scoped token;
|
||||||
|
# runs as the last step of close-solved.yml in CI):
|
||||||
|
GH_TOKEN=$PROJECT_PAT bun run sync-project-fields -- --dry-run
|
||||||
|
# today's review issue from the +3d/+7d closes (--date backfills a missed day;
|
||||||
|
# a Sunday date writes nothing — the rest day has no assignment):
|
||||||
|
bun run spaced-repetition -- --dry-run --date=2026-08-31
|
||||||
|
bun run api:dev # wrangler dev on :8787 (local D1)
|
||||||
|
bun run api:test # DST guards + the rest-day calendar sweep
|
||||||
bun run api:deploy # deploy the Worker by hand (CI also deploys on main pushes)
|
bun run api:deploy # deploy the Worker by hand (CI also deploys on main pushes)
|
||||||
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
||||||
"localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing
|
"localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing
|
||||||
@@ -63,18 +88,19 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
|||||||
- **Dry-run everything:** reconcilers take `--dry-run`/`DRY_RUN=1`; Worker admin routes take `?dry=1&force=1&date=` (the `SRS_TODAY` equivalent) and `wrangler dev` runs against local D1 — exercise logic there before touching production state.
|
- **Dry-run everything:** reconcilers take `--dry-run`/`DRY_RUN=1`; Worker admin routes take `?dry=1&force=1&date=` (the `SRS_TODAY` equivalent) and `wrangler dev` runs against local D1 — exercise logic there before touching production state.
|
||||||
- **Dates:** always ET calendar strings (`YYYY-MM-DD`), arithmetic anchored at noon UTC (`atNoon` in `apps/api/src/srs.ts`) to dodge DST. Never `new Date()` math directly.
|
- **Dates:** always ET calendar strings (`YYYY-MM-DD`), arithmetic anchored at noon UTC (`atNoon` in `apps/api/src/srs.ts`) to dodge DST. Never `new Date()` math directly.
|
||||||
- **Style:** section-divider comments (`// ── name ───`), file-top doc comments explaining the *why* and invariants, 2-space JSON with trailing newline, `Map` for dynamic keys / `Record` for static tables, no tiny one-expression wrapper functions.
|
- **Style:** section-divider comments (`// ── name ───`), file-top doc comments explaining the *why* and invariants, 2-space JSON with trailing newline, `Map` for dynamic keys / `Record` for static tables, no tiny one-expression wrapper functions.
|
||||||
- **Known intentional duplication:** `close-solved.ts` re-implements header stripping instead of importing from `sync.ts` because `sync.ts` runs its pipeline on import. Don't "deduplicate" it.
|
- **Known intentional duplication:** `sync.ts` keeps its own header split instead of sharing `apps/cli/source.ts`, because `sync.ts` runs its whole pipeline on import and so cannot be imported from. Don't "deduplicate" it. `close-solved.ts` and `work.ts` do share it — the stub test decides both "close the issue" and "owes a review", and the two must never disagree.
|
||||||
|
|
||||||
## Important Files
|
## Important Files
|
||||||
|
|
||||||
- `apps/api/src/srs.ts` — SRS domain: ladder (`new → +2 → +5 → +10 → retired`; fail resets to `+2`; first-ever log enters at `+2`), ET date math, seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here).
|
- `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` (the one vocabulary shared by the charts, the `work/<n>` buckets and the review issues), ET date math, `workingDay()` (no scheduled date is ever a Sunday), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
|
||||||
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`.
|
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`.
|
||||||
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/56) LeetCode Daily Digest`.
|
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/56) LeetCode Daily Digest`.
|
||||||
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
|
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
|
||||||
- `apps/cli/sync.ts` — work→docs contract: only `## Solution` onward is script-owned on existing pages; human prose is never touched; never hand-write solution pages or edit inside `## Solution`.
|
- `apps/cli/sync.ts` — work→docs contract: only `## Solution` onward is script-owned on existing pages; human prose is never touched; never hand-write solution pages or edit inside `## Solution`.
|
||||||
|
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `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.
|
||||||
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the docs `/progress` page fetches `/api/stats` client-side (`apps/docs/islands/ProgressDashboard.tsx`).
|
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the docs `/progress` page fetches `/api/stats` client-side (`apps/docs/islands/ProgressDashboard.tsx`).
|
||||||
- `apps/docs/blume.config.ts` — site base `/leetcode`; `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`).
|
- `apps/docs/blume.config.ts` — site base `/leetcode`; `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`).
|
||||||
- Old `.github/srs/` JSON state and the `srs-*` Actions are RETIRED — do not resurrect; `apps/api/scripts/import-srs.ts` documents the migration.
|
- Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
|
||||||
|
|
||||||
## Runtime/Tooling Preferences
|
## Runtime/Tooling Preferences
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<sub>8-week interview-prep fight camp. Each phase is a milestone; each topic an issue; each problem a sub-issue.<br>
|
<sub>8-week interview-prep fight camp. Each phase is a milestone; each topic an issue; each problem a sub-issue.<br>
|
||||||
Charts are live from the SRS Worker — the daily digest email drives the schedule.</sub>
|
New topic Mon–Fri, gate on Saturday, Sunday off — every solve comes back at <strong>+3</strong> and <strong>+7</strong> days,
|
||||||
|
the two windows that keep the rest day clear (a Thursday solve's +3 slides to Monday).<br>
|
||||||
|
Charts are live from the SRS Worker, never committed: pushing a solution or tapping the daily digest moves them within ~5 min.</sub>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<table align="center">
|
<table align="center">
|
||||||
@@ -18,7 +20,7 @@
|
|||||||
<td colspan="2" align="center"><img alt="phase progress" width="100%" src="https://srs-api.prdlk.workers.dev/chart/progress.svg" /></td>
|
<td colspan="2" align="center"><img alt="phase progress" width="100%" src="https://srs-api.prdlk.workers.dev/chart/progress.svg" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" width="53%"><img alt="SRS ladder" width="100%" src="https://srs-api.prdlk.workers.dev/chart/ladder.svg" /></td>
|
<td align="center" width="53%"><img alt="SRS ladder — new / +3 / +7 / retired" width="100%" src="https://srs-api.prdlk.workers.dev/chart/ladder.svg" /></td>
|
||||||
<td align="center" width="47%"><img alt="attempt heatmap" width="100%" src="https://srs-api.prdlk.workers.dev/chart/heatmap.svg" /></td>
|
<td align="center" width="47%"><img alt="attempt heatmap" width="100%" src="https://srs-api.prdlk.workers.dev/chart/heatmap.svg" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+49
-15
@@ -16,16 +16,33 @@ Live at `https://srs-api.prdlk.workers.dev`.
|
|||||||
never overwrites SRS-owned columns (`stage`, `next_review`).
|
never overwrites SRS-owned columns (`stage`, `next_review`).
|
||||||
- **The repo owns the schedule** — `data/schedule.json`, human-edited,
|
- **The repo owns the schedule** — `data/schedule.json`, human-edited,
|
||||||
bundled at deploy. Git history is its audit log.
|
bundled at deploy. Git history is its audit log.
|
||||||
- The GitHub Project mirror (`Target Date`, `SRS Stage`, `First Attempt`)
|
- The GitHub Project mirror writes `Target Date` and nothing else: it is
|
||||||
is best-effort: failures log warnings, never block a D1 write. Topic rows
|
best-effort, so failures log warnings and never block a D1 write, and topic
|
||||||
are never written.
|
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
|
## The ladder
|
||||||
|
|
||||||
`new → +2 → +5 → +10 → retired`; pass advances, fail resets to `+2`.
|
`new → +3 → +7 → retired`; pass advances, fail resets to `+3`. A problem's
|
||||||
A problem's first-ever log enters at `+2` regardless of result. Stage names
|
first-ever log enters at `+3` regardless of result. Stage names the NEXT
|
||||||
the NEXT review's interval. All dates are ET calendar strings anchored at
|
review's interval, and the two rungs are the same numbers as the `work/3` and
|
||||||
noon UTC (`src/srs.ts`) — never raw `Date` math.
|
`work/7` buckets `bun run pick` scaffolds re-solves into.
|
||||||
|
|
||||||
|
**Sunday is never booked.** Topics run Mon–Fri, the gate is Saturday, and 3/7
|
||||||
|
are chosen so a solve returns on a working day: only a Thursday solve's `+3`
|
||||||
|
would land on the rest day, and `workingDay()` in `src/srs.ts` slides it to
|
||||||
|
Monday. Every scheduled date in the Worker — ladder reviews, levelled
|
||||||
|
overflow, deferred-Hard release dates — is minted by that one function, so the
|
||||||
|
rest day cannot be booked and then swallowed by the digest's Sunday branch.
|
||||||
|
Reviews may slip later, never earlier: pulling one back to Saturday would
|
||||||
|
shorten the interval it exists to test. `src/srs.test.ts` sweeps the whole
|
||||||
|
campaign calendar to prove it.
|
||||||
|
|
||||||
|
All dates are ET calendar strings anchored at noon UTC (`src/srs.ts`) — never
|
||||||
|
raw `Date` math.
|
||||||
|
|
||||||
Blind drills draw only from topics **already learned**: scheduled in an
|
Blind drills draw only from topics **already learned**: scheduled in an
|
||||||
earlier week (the current week's optional pool is reserved for Saturday's
|
earlier week (the current week's optional pool is reserved for Saturday's
|
||||||
@@ -40,6 +57,8 @@ day never feeds drills just because its calendar week lapsed.
|
|||||||
| `POST /webhook/github` | HMAC (`WEBHOOK_SECRET`) | `/done <n> pass\|fail` comments (owner only, any issue); `review`-issue close → gate scoring |
|
| `POST /webhook/github` | HMAC (`WEBHOOK_SECRET`) | `/done <n> pass\|fail` comments (owner only, any issue); `review`-issue close → gate scoring |
|
||||||
| `GET /chart/{progress,ladder,heatmap}.svg`, `GET /badge/gate.svg` | public | hand-rolled SVGs, `max-age=300` (GitHub Camo's freshness floor) |
|
| `GET /chart/{progress,ladder,heatmap}.svg`, `GET /badge/gate.svg` | public | hand-rolled SVGs, `max-age=300` (GitHub Camo's freshness floor) |
|
||||||
| `GET /api/stats` | public, CORS-pinned to the docs origin | one JSON document for `/progress` |
|
| `GET /api/stats` | public, CORS-pinned to the docs origin | one JSON document for `/progress` |
|
||||||
|
| `POST /admin/solved` | `Authorization: Bearer <LINK_KEY>` | `{"solved":[{"lc":1,"bucket":1},…]}` — solutions committed under `work/`; the bucket names the rung it settles (1 → `new`, 3 → `+3`, 7 → `+7`), so a first solve enters the ladder and a pushed re-solve advances it. Writes only when the problem stands on that rung, which is what makes re-posting the whole set a no-op. Takes `?dry=1&date=` |
|
||||||
|
| `POST /admin/due` | `Authorization: Bearer <LINK_KEY>` | read-only: what the ladder has due on `?date=` (default today), oldest first, with the rung/bucket, how late it is, and when it was last seen. The one answer behind the daily `Spaced Repetition` issue |
|
||||||
| `POST /admin/{digest,review,reconcile}` | `Authorization: Bearer <LINK_KEY>` | manual triggers; `digest` takes `?dry=1&force=1&date=` |
|
| `POST /admin/{digest,review,reconcile}` | `Authorization: Bearer <LINK_KEY>` | manual triggers; `digest` takes `?dry=1&force=1&date=` |
|
||||||
|
|
||||||
## Crons (DST-proof)
|
## Crons (DST-proof)
|
||||||
@@ -59,9 +78,23 @@ routes). Bindings in `wrangler.jsonc`: `DB` (D1 `srs`), `EMAIL`
|
|||||||
(`send_email`, restricted to the verified destination). Sender domain
|
(`send_email`, restricted to the verified destination). Sender domain
|
||||||
`prdlk.com` is onboarded to Email Sending.
|
`prdlk.com` is onboarded to Email Sending.
|
||||||
|
|
||||||
The `sync-d1.yml` Actions workflow additionally pushes curriculum issue
|
Three Actions workflows talk to this Worker with the `SRS_ADMIN_KEY` repo
|
||||||
edits into D1 immediately (`POST /admin/reconcile` with the
|
secret:
|
||||||
`SRS_ADMIN_KEY` repo secret); the morning cron is the backstop.
|
|
||||||
|
- `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
|
## Local dev
|
||||||
|
|
||||||
@@ -72,13 +105,14 @@ bunx wrangler d1 migrations apply srs --local
|
|||||||
bun run dev # wrangler dev on :8787, local D1
|
bun run dev # wrangler dev on :8787, local D1
|
||||||
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
||||||
"localhost:8787/admin/digest?dry=1&date=2026-08-31" # prints HTML, sends nothing
|
"localhost:8787/admin/digest?dry=1&date=2026-08-31" # prints HTML, sends nothing
|
||||||
bun test src # DST guard + date math
|
bun test src # DST guards, date math, the rest-day sweep
|
||||||
```
|
```
|
||||||
|
|
||||||
`?date=` on admin routes is the `SRS_TODAY` equivalent. The one-shot
|
`?date=` on admin routes is the `SRS_TODAY` equivalent. Migrations are
|
||||||
migration from the retired `.github/srs/srs.json` lives at
|
append-only and applied in order; `0002_ladder_3_7.sql` is the +2/+5/+10 →
|
||||||
`scripts/import-srs.ts` (`--remote` for production D1); it is re-runnable —
|
`+3`/`+7` rebuild, which also lifts every date that was sitting on a Sunday.
|
||||||
import-sourced attempts are wiped and re-inserted.
|
The one-shot importer for the retired `.github/srs/srs.json` is gone — that
|
||||||
|
state no longer exists, and git history is its record.
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ CREATE TABLE attempts (
|
|||||||
date TEXT NOT NULL,
|
date TEXT NOT NULL,
|
||||||
kind TEXT NOT NULL, -- first|review|drill|gate
|
kind TEXT NOT NULL, -- first|review|drill|gate
|
||||||
result TEXT NOT NULL, -- pass|fail
|
result TEXT NOT NULL, -- pass|fail
|
||||||
source TEXT NOT NULL -- email|webhook|import
|
source TEXT NOT NULL -- email|webhook|commit|import
|
||||||
);
|
);
|
||||||
|
|
||||||
-- One-tap email links must be idempotent (same link twice = no-op), but
|
-- One-tap email links must be idempotent (same link twice = no-op), but
|
||||||
-- /done webhook corrections (pass then fail, same day) must stay legal —
|
-- /done webhook corrections (pass then fail, same day) must stay legal —
|
||||||
-- so uniqueness applies to email-sourced attempts only.
|
-- so uniqueness applies to email-sourced attempts only. Commit-sourced
|
||||||
|
-- attempts need no index: logAttempt() only accepts them for a stage-'new'
|
||||||
|
-- problem, so a re-push cannot insert a second one.
|
||||||
CREATE UNIQUE INDEX attempts_email_once
|
CREATE UNIQUE INDEX attempts_email_once
|
||||||
ON attempts (lc_number, date, kind) WHERE source = 'email';
|
ON attempts (lc_number, date, kind) WHERE source = 'email';
|
||||||
CREATE INDEX attempts_by_date ON attempts (date);
|
CREATE INDEX attempts_by_date ON attempts (date);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Ladder rebuild: +2/+5/+10 → +3/+7, and no scheduled date on a Sunday.
|
||||||
|
--
|
||||||
|
-- The old three rungs scattered reviews across every weekday, so roughly one
|
||||||
|
-- review in seven came due on the rest day: the digest hid it (Sunday builds
|
||||||
|
-- an empty body) and Monday inherited the pile. The campaign's windows are 3
|
||||||
|
-- and 7 days — the same numbers as the work/3 and work/7 buckets and the
|
||||||
|
-- Spaced Repetition issue windows — and srs.ts now mints every date through
|
||||||
|
-- workingDay(), which never returns a Sunday.
|
||||||
|
--
|
||||||
|
-- Stage remap keeps earned progress: a problem at +5 has passed one review, so
|
||||||
|
-- it owes the 7-day one; a problem at +10 has passed two and is done. Existing
|
||||||
|
-- next_review dates keep their day (recomputing them would move work the
|
||||||
|
-- reader has already been told about) — only Sunday landings shift, forward to
|
||||||
|
-- Monday, because a review may come later than its interval but never sooner.
|
||||||
|
--
|
||||||
|
-- problems.stage is now: new | +3 | +7 | retired
|
||||||
|
|
||||||
|
UPDATE problems SET stage = '+3' WHERE stage = '+2';
|
||||||
|
UPDATE problems SET stage = '+7' WHERE stage = '+5';
|
||||||
|
UPDATE problems SET stage = 'retired', next_review = NULL WHERE stage = '+10';
|
||||||
|
|
||||||
|
UPDATE problems
|
||||||
|
SET next_review = date(next_review, '+1 day')
|
||||||
|
WHERE next_review IS NOT NULL AND strftime('%w', next_review) = '0';
|
||||||
|
|
||||||
|
-- defer_until is the deferred Hards' release date; catalog.ts spreads them two
|
||||||
|
-- per working day, so the same rule applies. next_review tracks it for a row
|
||||||
|
-- that is still `new`, and the shift above kept the two in step.
|
||||||
|
UPDATE problems
|
||||||
|
SET defer_until = date(defer_until, '+1 day')
|
||||||
|
WHERE defer_until IS NOT NULL AND strftime('%w', defer_until) = '0';
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
/**
|
|
||||||
* One-shot migration: .github/srs/srs.json → D1.
|
|
||||||
*
|
|
||||||
* Run AFTER the catalog reconcile has filled `problems` (the import only
|
|
||||||
* overlays SRS-owned fields — stage, next_review, defer_until — and inserts
|
|
||||||
* attempts/topic counters). Re-runnable without duplicates: import-sourced
|
|
||||||
* attempts are wiped and re-inserted, everything else upserts.
|
|
||||||
*
|
|
||||||
* bun apps/api/scripts/import-srs.ts # local D1 (wrangler dev state)
|
|
||||||
* bun apps/api/scripts/import-srs.ts --remote # production D1
|
|
||||||
*
|
|
||||||
* Prints row counts; verify them against srs.json before deleting anything.
|
|
||||||
*/
|
|
||||||
import { $ } from "bun";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
|
||||||
const API = join(ROOT, "api");
|
|
||||||
const REMOTE = process.argv.includes("--remote");
|
|
||||||
|
|
||||||
interface Attempt {
|
|
||||||
date: string;
|
|
||||||
kind: string;
|
|
||||||
result: string;
|
|
||||||
}
|
|
||||||
interface Problem {
|
|
||||||
issue: number;
|
|
||||||
topic: number;
|
|
||||||
difficulty: string;
|
|
||||||
set: string;
|
|
||||||
solved_on?: string;
|
|
||||||
stage: string;
|
|
||||||
next_review?: string;
|
|
||||||
defer_until?: string;
|
|
||||||
history: Attempt[];
|
|
||||||
}
|
|
||||||
interface State {
|
|
||||||
problems: Record<string, Problem>;
|
|
||||||
topics: Record<string, { misses: number; boost: boolean }>;
|
|
||||||
drill_pool_used: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const state: State = JSON.parse(
|
|
||||||
await Bun.file(join(ROOT, ".github", "srs", "srs.json")).text(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const q = (v: string | null | undefined) => (v == null ? "NULL" : `'${v}'`);
|
|
||||||
const lines: string[] = ["DELETE FROM attempts WHERE source = 'import';"];
|
|
||||||
|
|
||||||
let attempts = 0;
|
|
||||||
for (const [lc, p] of Object.entries(state.problems)) {
|
|
||||||
lines.push(
|
|
||||||
`UPDATE problems SET stage = ${q(p.stage)}, next_review = ${q(p.next_review ?? null)}, ` +
|
|
||||||
`defer_until = ${q(p.defer_until ?? null)} WHERE lc_number = ${Number(lc)};`,
|
|
||||||
);
|
|
||||||
for (const a of p.history) {
|
|
||||||
lines.push(
|
|
||||||
`INSERT INTO attempts (lc_number, date, kind, result, source) ` +
|
|
||||||
`VALUES (${Number(lc)}, ${q(a.date)}, ${q(a.kind)}, ${q(a.result)}, 'import');`,
|
|
||||||
);
|
|
||||||
attempts++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const [topic, t] of Object.entries(state.topics)) {
|
|
||||||
lines.push(
|
|
||||||
`UPDATE topics SET misses = ${t.misses}, boost = ${t.boost ? 1 : 0} WHERE issue = ${Number(topic)};`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const lc of state.drill_pool_used) {
|
|
||||||
lines.push(`INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (${lc});`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sqlPath = join(API, "migrations", ".import.sql");
|
|
||||||
await Bun.write(sqlPath, lines.join("\n") + "\n");
|
|
||||||
const flag = REMOTE ? "--remote" : "--local";
|
|
||||||
await $`bunx wrangler d1 execute srs ${flag} --file ${sqlPath}`.cwd(API);
|
|
||||||
await $`rm ${sqlPath}`;
|
|
||||||
|
|
||||||
const counts =
|
|
||||||
await $`bunx wrangler d1 execute srs ${flag} --json --command ${"SELECT (SELECT COUNT(*) FROM problems) AS problems, (SELECT COUNT(*) FROM problems WHERE stage != 'new') AS laddered, (SELECT COUNT(*) FROM problems WHERE defer_until IS NOT NULL) AS deferred, (SELECT COUNT(*) FROM attempts WHERE source='import') AS imported_attempts, (SELECT COUNT(*) FROM topics) AS topics, (SELECT COUNT(*) FROM drill_pool_used) AS drills_used"}`
|
|
||||||
.cwd(API)
|
|
||||||
.json();
|
|
||||||
|
|
||||||
const expected = {
|
|
||||||
json_problems: Object.keys(state.problems).length,
|
|
||||||
json_attempts: attempts,
|
|
||||||
json_laddered: Object.values(state.problems).filter((p) => p.stage !== "new").length,
|
|
||||||
json_deferred: Object.values(state.problems).filter((p) => p.defer_until).length,
|
|
||||||
json_topics: Object.keys(state.topics).length,
|
|
||||||
json_drills_used: state.drill_pool_used.length,
|
|
||||||
};
|
|
||||||
console.log("expected from srs.json:", JSON.stringify(expected));
|
|
||||||
console.log("in D1:", JSON.stringify(counts[0]?.results?.[0] ?? counts));
|
|
||||||
+40
-9
@@ -4,13 +4,21 @@
|
|||||||
* rows from scratch on every run — re-runs are no-ops. SRS-owned columns
|
* 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
|
* (stage, next_review) are NEVER overwritten; defer_until is set once, on
|
||||||
* first sight of a deferred problem. The Worker never invents catalog rows.
|
* first sight of a deferred problem. The Worker never invents catalog rows.
|
||||||
|
*
|
||||||
|
* Difficulty and set come from the `diff:*` / `set:*` LABELS, never from the
|
||||||
|
* issue title — the title carries only the LC number and the problem's name,
|
||||||
|
* so relabelling a problem is enough to move it and titles stay readable.
|
||||||
|
* The `problem` label is what marks a sub-issue as curriculum.
|
||||||
*/
|
*/
|
||||||
import { addDays } from "./srs.ts";
|
import { workingDay } from "./srs.ts";
|
||||||
import type { GitHub } from "./github.ts";
|
import type { GitHub } from "./github.ts";
|
||||||
|
|
||||||
const TITLE_RE = /^LC (\d+) · (.+) · (Easy|Medium|Hard) · (core|optional|deferred)$/;
|
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
||||||
|
|
||||||
/** Deferred Hards enter the queue from this date, two per day. */
|
const DIFFICULTIES = ["easy", "medium", "hard"] as const;
|
||||||
|
const SETS = ["core", "optional", "deferred"] as const;
|
||||||
|
|
||||||
|
/** Deferred Hards enter the queue from this date, two per working day. */
|
||||||
const DEFER_FROM = "2026-09-28";
|
const DEFER_FROM = "2026-09-28";
|
||||||
|
|
||||||
export interface ReconcileReport {
|
export interface ReconcileReport {
|
||||||
@@ -18,6 +26,18 @@ export interface ReconcileReport {
|
|||||||
problems: 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> {
|
export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<ReconcileReport> {
|
||||||
interface TopicIssue {
|
interface TopicIssue {
|
||||||
number: number;
|
number: number;
|
||||||
@@ -62,13 +82,24 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
|||||||
if (!raw || typeof raw !== "object") continue;
|
if (!raw || typeof raw !== "object") continue;
|
||||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||||
if (!("title" in raw) || typeof raw.title !== "string") 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);
|
const m = raw.title.match(TITLE_RE);
|
||||||
if (!m) continue; // non-curriculum sub-issue
|
if (!m) {
|
||||||
|
console.warn(`catalog: #${raw.number} is a problem but not titled "LC <n> · <name>"`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const difficulty = DIFFICULTIES.find((d) => labels.includes(`diff:${d}`));
|
||||||
|
const set = SETS.find((s) => labels.includes(`set:${s}`));
|
||||||
|
if (!difficulty || !set) {
|
||||||
|
console.warn(`catalog: #${raw.number} is missing a diff:/set: label — skipped`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const lc = Number(m[1]);
|
const lc = Number(m[1]);
|
||||||
const set = m[4]!;
|
// Two deferred Hards per WORKING day from DEFER_FROM, in catalog walk
|
||||||
// Two deferred Hards per day from DEFER_FROM, in catalog walk order —
|
// order — applied only when the row is first created (SRS owns it
|
||||||
// applied only when the row is first created (SRS owns it afterwards).
|
// afterwards). Sunday is skipped like every other scheduled date.
|
||||||
const defer = set === "deferred" ? addDays(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
|
const defer = set === "deferred" ? workingDay(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
|
||||||
if (set === "deferred") deferredSeen++;
|
if (set === "deferred") deferredSeen++;
|
||||||
statements.push(
|
statements.push(
|
||||||
db
|
db
|
||||||
@@ -78,7 +109,7 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
|||||||
ON CONFLICT(lc_number) DO UPDATE SET
|
ON CONFLICT(lc_number) DO UPDATE SET
|
||||||
issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`,
|
issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`,
|
||||||
)
|
)
|
||||||
.bind(lc, raw.number, t.number, m[2]!, m[3]!.toLowerCase(), set, defer),
|
.bind(lc, raw.number, t.number, m[2]!, difficulty, set, defer),
|
||||||
);
|
);
|
||||||
problems++;
|
problems++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
* badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode
|
* badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode
|
||||||
* green ramp. Every function renders on a zero-row DB.
|
* green ramp. Every function renders on a zero-row DB.
|
||||||
*/
|
*/
|
||||||
|
import { STAGES } from "./srs.ts";
|
||||||
import { Canvas, textWidth } from "./png.ts";
|
import { Canvas, textWidth } from "./png.ts";
|
||||||
|
|
||||||
// D1Database comes from the generated worker-configuration.d.ts runtime types.
|
// D1Database comes from the generated worker-configuration.d.ts runtime types.
|
||||||
@@ -43,8 +44,6 @@ const PHASES = [
|
|||||||
{ milestone: 5, name: "V — Decision Space" },
|
{ milestone: 5, name: "V — Decision Space" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STAGES = ["new", "+2", "+5", "+10", "retired"];
|
|
||||||
|
|
||||||
// ── svg helpers ──────────────────────────────────────────────────
|
// ── svg helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Opening tag plus the rounded dark card every chart starts with. */
|
/** Opening tag plus the rounded dark card every chart starts with. */
|
||||||
@@ -159,6 +158,9 @@ export async function progressChart(db: D1Database): Promise<string> {
|
|||||||
|
|
||||||
// ── ladder: bar per stage ────────────────────────────────────────
|
// ── ladder: bar per stage ────────────────────────────────────────
|
||||||
|
|
||||||
|
// One bar per rung, labelled and ordered by srs.ts's STAGES, so changing the
|
||||||
|
// ladder can never leave a stale bar here: the slot width is derived from the
|
||||||
|
// rung count, only the bar width inside a slot is fixed.
|
||||||
export async function ladderChart(db: D1Database): Promise<string> {
|
export async function ladderChart(db: D1Database): Promise<string> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`)
|
.prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`)
|
||||||
|
|||||||
+21
-6
@@ -8,22 +8,28 @@
|
|||||||
* Retrieval rules: review and drill rows carry number + difficulty only —
|
* Retrieval rules: review and drill rows carry number + difficulty only —
|
||||||
* never the topic, never a solution link. `DigestRow` has no field for
|
* never the topic, never a solution link. `DigestRow` has no field for
|
||||||
* either, so the rule holds by construction. The learning day's core list is
|
* either, so the rule holds by construction. The learning day's core list is
|
||||||
* the only labeled section. Reviews + drills ≤ 6, reviews first, overflow
|
* the only labeled section. At most REVIEW_CAP (3) reviews per day; drills
|
||||||
* simply stays due (oldest tomorrow). Sunday is a two-line rest note.
|
* 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
|
* Idempotency: email_log keys sends by ET date — a same-day re-send is a
|
||||||
* no-op unless forced; the body itself is deterministic (drill picks are
|
* no-op unless forced; the body itself is deterministic (drill picks are
|
||||||
* seeded by the date).
|
* seeded by the date). Levelling keeps that: it runs once, before the send,
|
||||||
|
* and a re-run finds nothing left over the cap.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
CAMPAIGN_DAYS,
|
CAMPAIGN_DAYS,
|
||||||
type ProblemRow,
|
type ProblemRow,
|
||||||
|
REVIEW_CAP,
|
||||||
SCHEDULE,
|
SCHEDULE,
|
||||||
addDays,
|
addDays,
|
||||||
campaignDay,
|
campaignDay,
|
||||||
campaignWeek,
|
campaignWeek,
|
||||||
dueReviews,
|
dueReviews,
|
||||||
isoWeek,
|
isoWeek,
|
||||||
|
levelReviews,
|
||||||
pickDrills,
|
pickDrills,
|
||||||
prettyDate,
|
prettyDate,
|
||||||
streak,
|
streak,
|
||||||
@@ -32,6 +38,7 @@ import {
|
|||||||
import { type DigestData, type DigestRow, renderDigest } from "./email.tsx";
|
import { type DigestData, type DigestRow, renderDigest } from "./email.tsx";
|
||||||
import { signLink } from "./links.ts";
|
import { signLink } from "./links.ts";
|
||||||
|
|
||||||
|
/** Total daily ceiling: drills only fill what the whole due queue leaves. */
|
||||||
const DAILY_CAP = 6;
|
const DAILY_CAP = 6;
|
||||||
|
|
||||||
export interface Digest {
|
export interface Digest {
|
||||||
@@ -61,10 +68,12 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
|
|||||||
const db = env.DB;
|
const db = env.DB;
|
||||||
const rest = weekdayOf(date) === 0;
|
const rest = weekdayOf(date) === 0;
|
||||||
|
|
||||||
// Reviews take the cap first; drills fill whatever is left.
|
// Reviews surface at most REVIEW_CAP; drills fill toward DAILY_CAP only
|
||||||
|
// when the WHOLE due queue is light — a heavy backlog must never trade
|
||||||
|
// review slots for brand-new drill problems.
|
||||||
const due = rest ? [] : await dueReviews(db, date);
|
const due = rest ? [] : await dueReviews(db, date);
|
||||||
const capped = due.slice(0, DAILY_CAP);
|
const capped = due.slice(0, REVIEW_CAP);
|
||||||
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - capped.length);
|
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
|
||||||
|
|
||||||
const data: DigestData = {
|
const data: DigestData = {
|
||||||
day: prettyDate(date),
|
day: prettyDate(date),
|
||||||
@@ -96,6 +105,7 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
|
|||||||
name: topic?.name ?? `#${topicIssue}`,
|
name: topic?.name ?? `#${topicIssue}`,
|
||||||
core: core.map((p) => ({
|
core: core.map((p) => ({
|
||||||
lc: p.lc_number,
|
lc: p.lc_number,
|
||||||
|
difficulty: p.difficulty,
|
||||||
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
|
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
|
||||||
solved: p.stage !== "new",
|
solved: p.stage !== "new",
|
||||||
})),
|
})),
|
||||||
@@ -164,6 +174,11 @@ export async function sendDigest(
|
|||||||
.first();
|
.first();
|
||||||
if (already && !opts.force) return { sent: false, reason: "already sent today", digest };
|
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({
|
await env.EMAIL.send({
|
||||||
to: env.TO_EMAIL,
|
to: env.TO_EMAIL,
|
||||||
from: { email: env.FROM_EMAIL, name: "SRS" },
|
from: { email: env.FROM_EMAIL, name: "SRS" },
|
||||||
|
|||||||
+69
-25
@@ -35,6 +35,7 @@ import {
|
|||||||
} from "@react-email/components";
|
} from "@react-email/components";
|
||||||
import { render } from "@react-email/render";
|
import { render } from "@react-email/render";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
import { REVIEW_CAP } from "./srs.ts";
|
||||||
|
|
||||||
// ── palette (GitHub dark) ────────────────────────────────────────
|
// ── palette (GitHub dark) ────────────────────────────────────────
|
||||||
const CANVAS = "#010409";
|
const CANVAS = "#010409";
|
||||||
@@ -61,12 +62,12 @@ const DIFFICULTY: Record<string, { label: string; color: string }> = {
|
|||||||
hard: { label: "Hard", color: RED },
|
hard: { label: "Hard", color: RED },
|
||||||
};
|
};
|
||||||
|
|
||||||
// The SRS ladder in plain English — "+5" means the last look was 5 days back.
|
// The SRS ladder in plain English — stage "+3" means the last clean look was
|
||||||
|
// three days back, so that is what the reader is told.
|
||||||
const LAST_SEEN: Record<string, string> = {
|
const LAST_SEEN: Record<string, string> = {
|
||||||
new: "first look",
|
new: "first look",
|
||||||
"+2": "2 days ago",
|
"+3": "3 days ago",
|
||||||
"+5": "5 days ago",
|
"+7": "7 days ago",
|
||||||
"+10": "10 days ago",
|
|
||||||
retired: "retired",
|
retired: "retired",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,6 +83,14 @@ export interface DigestRow {
|
|||||||
failUrl: 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 {
|
export interface DigestData {
|
||||||
/** "Tuesday, August 25" */
|
/** "Tuesday, August 25" */
|
||||||
day: string;
|
day: string;
|
||||||
@@ -89,9 +98,9 @@ export interface DigestData {
|
|||||||
progress: string;
|
progress: string;
|
||||||
streak: number;
|
streak: number;
|
||||||
rest: boolean;
|
rest: boolean;
|
||||||
topic?: { name: string; core: { lc: number; url: string; solved: boolean }[] };
|
topic?: { name: string; core: TopicRow[] };
|
||||||
reviews: DigestRow[];
|
reviews: DigestRow[];
|
||||||
/** Reviews past the daily cap; they simply stay due. */
|
/** Reviews past the daily cap; levelled onto the coming days. */
|
||||||
carried: number;
|
carried: number;
|
||||||
drills: DigestRow[];
|
drills: DigestRow[];
|
||||||
gate?: { week: number; url: string };
|
gate?: { week: number; url: string };
|
||||||
@@ -196,6 +205,48 @@ function ProblemTable({ rows, staged }: { rows: DigestRow[]; staged: boolean })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The learning day's core list as the same table shape as reviews — this is
|
||||||
|
* the one section allowed to link problems, and ticks replace tap buttons.
|
||||||
|
*/
|
||||||
|
function TopicTable({ rows }: { rows: TopicRow[] }) {
|
||||||
|
return (
|
||||||
|
<table
|
||||||
|
width="100%"
|
||||||
|
border={0}
|
||||||
|
cellPadding={0}
|
||||||
|
cellSpacing={0}
|
||||||
|
style={{ borderCollapse: "collapse", width: "100%" }}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={th}>Problem</th>
|
||||||
|
<th style={th}>Level</th>
|
||||||
|
<th style={{ ...th, textAlign: "right" }}>Solved</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((p) => {
|
||||||
|
const difficulty = DIFFICULTY[p.difficulty];
|
||||||
|
return (
|
||||||
|
<tr key={p.lc}>
|
||||||
|
<td style={{ ...td, fontWeight: 600 }}>
|
||||||
|
<Link href={p.url} style={{ color: p.solved ? MUTED : LINK, textDecoration: "none" }}>
|
||||||
|
LC {p.lc}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td style={{ ...td, color: difficulty?.color ?? INK }}>{difficulty?.label ?? p.difficulty}</td>
|
||||||
|
<td style={{ ...td, color: p.solved ? GREEN : MUTED, textAlign: "right" }}>
|
||||||
|
{p.solved ? "✓" : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Block({
|
function Block({
|
||||||
title,
|
title,
|
||||||
note,
|
note,
|
||||||
@@ -251,7 +302,7 @@ function DigestEmail({ data }: { data: DigestData }) {
|
|||||||
<Text style={{ color: MUTED, fontSize: "13px", margin: "8px 0 0" }}>
|
<Text style={{ color: MUTED, fontSize: "13px", margin: "8px 0 0" }}>
|
||||||
{data.progress}
|
{data.progress}
|
||||||
{data.rest
|
{data.rest
|
||||||
? " · nothing is due today, and anything overdue waits for Monday."
|
? " · the ladder never books a Sunday, so nothing is due — anything still open waits for Monday."
|
||||||
: data.streak === 0
|
: data.streak === 0
|
||||||
? " · no streak going yet — today is a good day to start one."
|
? " · no streak going yet — today is a good day to start one."
|
||||||
: ` · 🔥 ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`}
|
: ` · 🔥 ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`}
|
||||||
@@ -265,18 +316,8 @@ function DigestEmail({ data }: { data: DigestData }) {
|
|||||||
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
||||||
{data.topic.name}
|
{data.topic.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ ...hint, margin: 0 }}>
|
<Text style={hint}>Work through these from the top — a tick means it is already solved.</Text>
|
||||||
Work through these — ticked ones you have already solved:{" "}
|
<TopicTable rows={data.topic.core} />
|
||||||
{data.topic.core.map((p, i) => (
|
|
||||||
<span key={p.lc}>
|
|
||||||
{i > 0 ? " · " : ""}
|
|
||||||
<Link href={p.url} style={{ color: p.solved ? MUTED : LINK, textDecoration: "none" }}>
|
|
||||||
LC {p.lc}
|
|
||||||
</Link>
|
|
||||||
{p.solved ? <span style={{ color: GREEN }}> ✓</span> : null}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</Text>
|
|
||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -289,8 +330,8 @@ function DigestEmail({ data }: { data: DigestData }) {
|
|||||||
/>
|
/>
|
||||||
{data.carried > 0 ? (
|
{data.carried > 0 ? (
|
||||||
<Text style={{ ...hint, margin: "12px 0 0" }}>
|
<Text style={{ ...hint, margin: "12px 0 0" }}>
|
||||||
{data.carried} more {data.carried === 1 ? "is" : "are"} waiting — they come back
|
{data.carried} more {data.carried === 1 ? "is" : "are"} waiting — spread over the
|
||||||
tomorrow, oldest first.
|
coming days, oldest first, never more than {REVIEW_CAP} a day.
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -362,7 +403,7 @@ function plainDigest(data: DigestData): string {
|
|||||||
data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`,
|
data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`,
|
||||||
data.progress +
|
data.progress +
|
||||||
(data.rest
|
(data.rest
|
||||||
? " · nothing is due today, and anything overdue waits for Monday."
|
? " · the ladder never books a Sunday, so nothing is due — anything still open waits for Monday."
|
||||||
: data.streak === 0
|
: data.streak === 0
|
||||||
? " · no streak going yet — today is a good day to start one."
|
? " · no streak going yet — today is a good day to start one."
|
||||||
: ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`),
|
: ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`),
|
||||||
@@ -373,8 +414,11 @@ function plainDigest(data: DigestData): string {
|
|||||||
out.push(
|
out.push(
|
||||||
"",
|
"",
|
||||||
`SOMETHING NEW TODAY — ${data.topic.name}`,
|
`SOMETHING NEW TODAY — ${data.topic.name}`,
|
||||||
"Work through these; the ones marked done you have already solved.",
|
"Work through these from the top; the ones marked done you have already solved.",
|
||||||
...data.topic.core.map((p) => ` LC ${p.lc}${p.solved ? " (done)" : ""} — ${p.url}`),
|
...data.topic.core.map(
|
||||||
|
(p) =>
|
||||||
|
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,7 +455,7 @@ function plainDigest(data: DigestData): string {
|
|||||||
if (data.carried > 0) {
|
if (data.carried > 0) {
|
||||||
out.push(
|
out.push(
|
||||||
"",
|
"",
|
||||||
`${data.carried} more ${data.carried === 1 ? "is" : "are"} waiting — they come back tomorrow, oldest first.`,
|
`${data.carried} more ${data.carried === 1 ? "is" : "are"} waiting — spread over the coming days, oldest first, never more than ${REVIEW_CAP} a day.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (data.gate) {
|
if (data.gate) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* The gate half is a topic-blind quiz — one unsolved optional problem per
|
* 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
|
* topic scheduled this week (which is why daily drills only draw from
|
||||||
* earlier weeks). The recap half is generated data, deliberately NOT a task
|
* earlier weeks). The recap half is generated data, deliberately NOT a task
|
||||||
* list: the week's solved problems are already scheduled by the +2 ladder,
|
* list: the week's solved problems are already scheduled by the +3/+7 ladder,
|
||||||
* and re-assigning them on Saturday would be massed practice.
|
* and re-assigning them on Saturday would be massed practice.
|
||||||
*
|
*
|
||||||
* Label is `review` (the retired Actions system owned `gate`). Creation is
|
* Label is `review` (the retired Actions system owned `gate`). Creation is
|
||||||
|
|||||||
+76
-6
@@ -19,9 +19,12 @@ import { type Mirror, projectMirror } from "./mirror.ts";
|
|||||||
import {
|
import {
|
||||||
CAMPAIGN_START,
|
CAMPAIGN_START,
|
||||||
type LogOutcome,
|
type LogOutcome,
|
||||||
|
type Stage,
|
||||||
daysBetween,
|
daysBetween,
|
||||||
|
dueToday,
|
||||||
etDate,
|
etDate,
|
||||||
etHour,
|
etHour,
|
||||||
|
getProblem,
|
||||||
logAttempt,
|
logAttempt,
|
||||||
weekdayOf,
|
weekdayOf,
|
||||||
} from "./srs.ts";
|
} from "./srs.ts";
|
||||||
@@ -30,22 +33,20 @@ import { buildStats } from "./stats.ts";
|
|||||||
// ── shared side effects after a state write ──────────────────────
|
// ── shared side effects after a state write ──────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirror one logged attempt into GitHub: Project fields always; on a
|
* Mirror one logged attempt into GitHub: the Project's Target Date always; on a
|
||||||
* first-ever pass also close the problem's sub-issue (comment first — repo
|
* first-ever pass also close the problem's sub-issue (comment first — repo
|
||||||
* convention). Runs inside ctx.waitUntil; failures are warnings.
|
* convention). Runs inside ctx.waitUntil; failures are warnings.
|
||||||
*/
|
*/
|
||||||
async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Promise<void> {
|
async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Promise<void> {
|
||||||
if (outcome.error || outcome.duplicate) return;
|
if (outcome.error || outcome.duplicate) return;
|
||||||
const gh = github(env.GH_PAT, env.REPO);
|
const gh = github(env.GH_PAT, env.REPO);
|
||||||
const mirror: Mirror = projectMirror(gh, env.REPO.split("/")[0]!);
|
const mirror = projectMirror(gh, env.REPO.split("/")[0]!);
|
||||||
await mirror.setStage(outcome.issue, outcome.stage);
|
|
||||||
await mirror.setTargetDate(outcome.issue, outcome.next_review);
|
await mirror.setTargetDate(outcome.issue, outcome.next_review);
|
||||||
if (outcome.first) await mirror.setFirstAttempt(outcome.issue, outcome.result);
|
|
||||||
if (outcome.first && outcome.result === "pass") {
|
if (outcome.first && outcome.result === "pass") {
|
||||||
try {
|
try {
|
||||||
await gh.closeIssue(
|
await gh.closeIssue(
|
||||||
outcome.issue,
|
outcome.issue,
|
||||||
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +2. Logged by the SRS Worker.`,
|
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +3. Logged by the SRS Worker.`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`close #${outcome.issue}: ${err}`);
|
console.warn(`close #${outcome.issue}: ${err}`);
|
||||||
@@ -97,7 +98,7 @@ async function handleTap(request: Request, env: Env, ctx: ExecutionContext): Pro
|
|||||||
}
|
}
|
||||||
ctx.waitUntil(mirrorOutcome(env, outcome, "email"));
|
ctx.waitUntil(mirrorOutcome(env, outcome, "email"));
|
||||||
return page(
|
return page(
|
||||||
result === "pass" ? "Logged ✅" : "Logged — back to +2",
|
result === "pass" ? "Logged ✅" : "Logged — back to +3",
|
||||||
outcomeLine(outcome),
|
outcomeLine(outcome),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -200,6 +201,68 @@ async function adminAuthorized(request: Request, env: Env): Promise<boolean> {
|
|||||||
return timingSafeEqual(header.slice(7), env.LINK_KEY);
|
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> {
|
async function handleAdmin(request: Request, env: Env, path: string): Promise<Response> {
|
||||||
if (!(await adminAuthorized(request, env))) return new Response("unauthorized", { status: 401 });
|
if (!(await adminAuthorized(request, env))) return new Response("unauthorized", { status: 401 });
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -209,6 +272,7 @@ async function handleAdmin(request: Request, env: Env, path: string): Promise<Re
|
|||||||
if (path === "/admin/reconcile") {
|
if (path === "/admin/reconcile") {
|
||||||
return Response.json(await reconcileCatalog(env.DB, gh()));
|
return Response.json(await reconcileCatalog(env.DB, gh()));
|
||||||
}
|
}
|
||||||
|
if (path === "/admin/solved") return await handleSolved(request, env, date);
|
||||||
if (path === "/admin/digest") {
|
if (path === "/admin/digest") {
|
||||||
const report = await sendDigest(env, date, {
|
const report = await sendDigest(env, date, {
|
||||||
dry: url.searchParams.get("dry") === "1",
|
dry: url.searchParams.get("dry") === "1",
|
||||||
@@ -222,6 +286,12 @@ async function handleAdmin(request: Request, env: Env, path: string): Promise<Re
|
|||||||
if (path === "/admin/review") {
|
if (path === "/admin/review") {
|
||||||
return Response.json(await createReviewIssue(env, gh(), date));
|
return Response.json(await createReviewIssue(env, gh(), date));
|
||||||
}
|
}
|
||||||
|
// What is due on `date`, straight out of the ladder — the ONE answer to
|
||||||
|
// "which problems are open today", so the daily review issue and the digest
|
||||||
|
// cannot disagree. Read-only; the caller decides what to render.
|
||||||
|
if (path === "/admin/due") {
|
||||||
|
return Response.json(await dueToday(env.DB, date));
|
||||||
|
}
|
||||||
return new Response("not found", { status: 404 });
|
return new Response("not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-39
@@ -4,30 +4,26 @@
|
|||||||
* console warning and never blocks a state write. Only problem issues are
|
* console warning and never blocks a state write. Only problem issues are
|
||||||
* ever passed in, so topic rows' Target Date is never written.
|
* ever passed in, so topic rows' Target Date is never written.
|
||||||
*
|
*
|
||||||
* Field/option IDs are resolved by NAME per invocation (a Worker isolate is
|
* Target Date is the ONLY mirrored column. SRS stage and first-attempt result
|
||||||
* short-lived; the two extra queries per mirror burst are irrelevant at this
|
* live exclusively in D1 — they are read from there (charts, digest, /api/stats)
|
||||||
* volume and survive field re-creation).
|
* 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";
|
import type { GitHub } from "./github.ts";
|
||||||
|
|
||||||
const PROJECT_TITLE = "Interview Prep";
|
const PROJECT_TITLE = "Interview Prep";
|
||||||
|
|
||||||
interface SelectField {
|
|
||||||
id: string;
|
|
||||||
options: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectInfo {
|
interface ProjectInfo {
|
||||||
id: string;
|
id: string;
|
||||||
targetDate: string;
|
targetDate: string;
|
||||||
srsStage: SelectField;
|
|
||||||
firstAttempt: SelectField;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Mirror {
|
export interface Mirror {
|
||||||
setTargetDate(issue: number, date: string | null): Promise<void>;
|
setTargetDate(issue: number, date: string | null): Promise<void>;
|
||||||
setStage(issue: number, stage: string): Promise<void>;
|
|
||||||
setFirstAttempt(issue: number, result: string): Promise<void>;
|
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +43,6 @@ export function projectMirror(gh: GitHub, owner: string): Mirror {
|
|||||||
fields(first: 30) {
|
fields(first: 30) {
|
||||||
nodes {
|
nodes {
|
||||||
... on ProjectV2FieldCommon { id name dataType }
|
... on ProjectV2FieldCommon { id name dataType }
|
||||||
... on ProjectV2SingleSelectField { id name options { id name } }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,7 +57,7 @@ export function projectMirror(gh: GitHub, owner: string): Mirror {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
fields: {
|
fields: {
|
||||||
nodes: { id: string; name: string; options?: { id: string; name: string }[] }[];
|
nodes: { id: string; name: string }[];
|
||||||
};
|
};
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
@@ -70,19 +65,9 @@ export function projectMirror(gh: GitHub, owner: string): Mirror {
|
|||||||
};
|
};
|
||||||
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
|
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
|
||||||
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
|
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
|
||||||
const select = (name: string): SelectField => {
|
|
||||||
const f = node.fields.nodes.find((n) => n.name === name);
|
|
||||||
if (!f?.options) throw new Error(`single-select field "${name}" missing`);
|
|
||||||
return { id: f.id, options: Object.fromEntries(f.options.map((o) => [o.name, o.id])) };
|
|
||||||
};
|
|
||||||
const date = node.fields.nodes.find((n) => n.name === "Target Date");
|
const date = node.fields.nodes.find((n) => n.name === "Target Date");
|
||||||
if (!date) throw new Error(`date field "Target Date" missing`);
|
if (!date) throw new Error(`date field "Target Date" missing`);
|
||||||
info = {
|
info = { id: node.id, targetDate: date.id };
|
||||||
id: node.id,
|
|
||||||
targetDate: date.id,
|
|
||||||
srsStage: select("SRS Stage"),
|
|
||||||
firstAttempt: select("First Attempt"),
|
|
||||||
};
|
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,19 +139,5 @@ export function projectMirror(gh: GitHub, owner: string): Mirror {
|
|||||||
await setField(issue, project.targetDate, { date });
|
await setField(issue, project.targetDate, { date });
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
setStage: (issue, stage) =>
|
|
||||||
attempt(`SRS Stage #${issue}`, async () => {
|
|
||||||
const project = await resolve();
|
|
||||||
const option = project.srsStage.options[stage];
|
|
||||||
if (!option) throw new Error(`no option "${stage}"`);
|
|
||||||
await setField(issue, project.srsStage.id, { singleSelectOptionId: option });
|
|
||||||
}),
|
|
||||||
setFirstAttempt: (issue, result) =>
|
|
||||||
attempt(`First Attempt #${issue}`, async () => {
|
|
||||||
const project = await resolve();
|
|
||||||
const option = project.firstAttempt.options[result];
|
|
||||||
if (!option) throw new Error(`no option "${result}"`);
|
|
||||||
await setField(issue, project.firstAttempt.id, { singleSelectOptionId: option });
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* DST-guard and date-math checks with fixed instants — the acceptance proof
|
* Acceptance proof for the two rules that cannot be eyeballed in production:
|
||||||
* that the double crons fire exactly once at 8 AM ET (digest) and midnight
|
* the double crons fire exactly once at 8 AM ET (digest) and midnight Saturday
|
||||||
* Saturday ET (review issue) on BOTH UTC offsets.
|
* ET (review issue) on BOTH UTC offsets, and NOTHING is ever scheduled on the
|
||||||
|
* Sunday rest day — which is the whole reason the ladder's windows are 3 and 7.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
import { addDays, campaignDay, campaignWeek, etDate, etHour, isoWeek, rng, sample, weekdayOf } from "./srs.ts";
|
import {
|
||||||
|
INTERVAL,
|
||||||
|
STAGES,
|
||||||
|
WINDOWS,
|
||||||
|
addDays,
|
||||||
|
campaignDay,
|
||||||
|
campaignWeek,
|
||||||
|
etDate,
|
||||||
|
etHour,
|
||||||
|
isRestDay,
|
||||||
|
isoWeek,
|
||||||
|
rng,
|
||||||
|
sample,
|
||||||
|
weekdayOf,
|
||||||
|
workingDay,
|
||||||
|
} from "./srs.ts";
|
||||||
|
|
||||||
|
/** Every ET date of the campaign, plus the deferred-Hard tail. */
|
||||||
|
const CALENDAR = Array.from({ length: 70 }, (_, i) => addDays("2026-08-17", i));
|
||||||
|
|
||||||
describe("DST-proof cron guard", () => {
|
describe("DST-proof cron guard", () => {
|
||||||
test("digest fires only at ET hour 8 — EDT (UTC-4)", () => {
|
test("digest fires only at ET hour 8 — EDT (UTC-4)", () => {
|
||||||
@@ -50,6 +69,69 @@ describe("date math (noon-UTC anchored)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the rest day", () => {
|
||||||
|
test("Sunday is the only rest day", () => {
|
||||||
|
expect(isRestDay("2026-08-30")).toBe(true); // Sunday
|
||||||
|
expect(isRestDay("2026-08-29")).toBe(false); // Saturday: gate day, still work
|
||||||
|
expect(isRestDay("2026-08-31")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workingDay never returns a Sunday, from any day and any offset", () => {
|
||||||
|
for (const date of CALENDAR) {
|
||||||
|
for (let offset = 0; offset < 10; offset++) {
|
||||||
|
expect(isRestDay(workingDay(date, offset))).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workingDay(date) leaves a working day alone and slides Sunday to Monday", () => {
|
||||||
|
expect(workingDay("2026-09-04")).toBe("2026-09-04"); // Friday
|
||||||
|
expect(workingDay("2026-09-05")).toBe("2026-09-05"); // Saturday
|
||||||
|
expect(workingDay("2026-09-06")).toBe("2026-09-07"); // Sunday → Monday
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offsets count working days, so a spill skips the rest day", () => {
|
||||||
|
// Levelling from Saturday: tomorrow is Sunday, so the spill starts Monday.
|
||||||
|
expect(workingDay("2026-09-05", 1)).toBe("2026-09-07");
|
||||||
|
expect(workingDay("2026-09-05", 2)).toBe("2026-09-08");
|
||||||
|
// Two deferred Hards per working day from a Monday.
|
||||||
|
expect(workingDay("2026-09-28", 5)).toBe("2026-10-03"); // Saturday
|
||||||
|
expect(workingDay("2026-09-28", 6)).toBe("2026-10-05"); // Sunday skipped
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every ladder interval lands on a working day, from every learning day", () => {
|
||||||
|
for (const date of CALENDAR) {
|
||||||
|
if (isRestDay(date)) continue;
|
||||||
|
for (const days of WINDOWS) {
|
||||||
|
const review = workingDay(addDays(date, days));
|
||||||
|
expect(isRestDay(review)).toBe(false);
|
||||||
|
// Never earlier than the interval: a review may slip, never shorten.
|
||||||
|
expect(review >= addDays(date, days)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only Thursday's +3 needs the slide", () => {
|
||||||
|
const slid = CALENDAR.filter(
|
||||||
|
(date) => !isRestDay(date) && workingDay(addDays(date, 3)) !== addDays(date, 3),
|
||||||
|
);
|
||||||
|
expect(new Set(slid.map(weekdayOf))).toEqual(new Set([4])); // Thursday only
|
||||||
|
for (const date of CALENDAR) {
|
||||||
|
if (isRestDay(date)) continue;
|
||||||
|
expect(workingDay(addDays(date, 7))).toBe(addDays(date, 7)); // +7 keeps the weekday
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the ladder", () => {
|
||||||
|
test("two rungs, named after the windows", () => {
|
||||||
|
expect(STAGES).toEqual(["new", "+3", "+7", "retired"]);
|
||||||
|
expect(WINDOWS.map((w) => INTERVAL[`+${w}`])).toEqual([...WINDOWS]);
|
||||||
|
// Every non-terminal stage has an interval, and only those.
|
||||||
|
expect(Object.keys(INTERVAL).sort()).toEqual(STAGES.slice(1, -1).sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("deterministic sampling", () => {
|
describe("deterministic sampling", () => {
|
||||||
test("same seed, same picks", () => {
|
test("same seed, same picks", () => {
|
||||||
const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||||
|
|||||||
+197
-21
@@ -1,12 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* SRS domain: ET dates, the interval ladder, deterministic sampling, and the
|
* SRS domain: ET dates, the work week, the interval ladder, deterministic
|
||||||
* one write path for attempts — ported from scripts/srs.ts, re-homed on D1.
|
* sampling, and the one write path for attempts.
|
||||||
*
|
*
|
||||||
* D1 is the single source of truth. The stage names the review a problem must
|
* D1 is the single source of truth. The stage names the review a problem must
|
||||||
* pass NEXT (`+2` = due 2 days after last clean solve). Passing advances
|
* pass NEXT (`+3` = due 3 working days after the last clean solve). Passing
|
||||||
* new → +2 → +5 → +10 → retired; any failure resets to +2. A problem's
|
* advances new → +3 → +7 → retired; any failure resets to +3. A problem's
|
||||||
* FIRST-ever log enters the ladder at +2 regardless of result: a pass earns
|
* FIRST-ever log enters the ladder at +3 regardless of result: a pass earns a
|
||||||
* a +2 review, a fail must be re-solved just as soon.
|
* 3-day review, a fail must be re-solved just as soon. Two rungs, 3 and 7 —
|
||||||
|
* the same numbers as the `work/3` and `work/7` buckets a re-solve is
|
||||||
|
* scaffolded into, and as the review-issue windows in
|
||||||
|
* apps/cli/spaced-repetition.ts.
|
||||||
|
*
|
||||||
|
* Sunday is off, structurally: every scheduled date comes out of
|
||||||
|
* workingDay(), which never returns one. Nothing "falls on" the rest day and
|
||||||
|
* gets swallowed or carried — it is simply never booked there.
|
||||||
*
|
*
|
||||||
* All dates are America/New_York calendar strings (YYYY-MM-DD); arithmetic is
|
* All dates are America/New_York calendar strings (YYYY-MM-DD); arithmetic is
|
||||||
* anchored at noon UTC so DST edges cannot shift a date. Never raw Date math.
|
* anchored at noon UTC so DST edges cannot shift a date. Never raw Date math.
|
||||||
@@ -56,6 +63,37 @@ export function weekdayOf(date: string): number {
|
|||||||
return atNoon(date).getUTCDay();
|
return atNoon(date).getUTCDay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── the work week ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sunday, the one day the campaign never schedules: topics run Mon–Fri
|
||||||
|
* (data/schedule.json), the gate is Saturday, and the review windows below are
|
||||||
|
* picked so a solve comes back on a working day.
|
||||||
|
*/
|
||||||
|
const REST_DAY = 0;
|
||||||
|
|
||||||
|
export function isRestDay(date: string): boolean {
|
||||||
|
return weekdayOf(date) === REST_DAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `offset`-th working day counting from `from` (0 = `from` itself), with
|
||||||
|
* Sundays skipped — the ONE place a scheduled date is minted.
|
||||||
|
*
|
||||||
|
* Work may slide later, never earlier: pulling a review back to Saturday would
|
||||||
|
* shorten the very interval it exists to test, so a Sunday landing becomes
|
||||||
|
* Monday. With the +3/+7 ladder that only ever happens to a Thursday solve's
|
||||||
|
* 3-day review; +7 lands on the solve's own weekday, which is never a Sunday.
|
||||||
|
*/
|
||||||
|
export function workingDay(from: string, offset = 0): string {
|
||||||
|
let cursor = isRestDay(from) ? addDays(from, 1) : from;
|
||||||
|
for (let i = 0; i < offset; i++) {
|
||||||
|
cursor = addDays(cursor, 1);
|
||||||
|
if (isRestDay(cursor)) cursor = addDays(cursor, 1);
|
||||||
|
}
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
export function campaignDay(date: string): number {
|
export function campaignDay(date: string): number {
|
||||||
return daysBetween(CAMPAIGN_START, date) + 1;
|
return daysBetween(CAMPAIGN_START, date) + 1;
|
||||||
}
|
}
|
||||||
@@ -126,7 +164,21 @@ export function sample<T>(pool: T[], n: number, random: () => number): T[] {
|
|||||||
|
|
||||||
// ── rows ─────────────────────────────────────────────────────────
|
// ── rows ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type Stage = "new" | "+2" | "+5" | "+10" | "retired";
|
/**
|
||||||
|
* Review windows in days. One vocabulary for the whole campaign: these are the
|
||||||
|
* ladder rungs, the `work/<n>` buckets the picker scaffolds into, and the
|
||||||
|
* windows the `Spaced Repetition — <date>` issues are built from.
|
||||||
|
*
|
||||||
|
* 3 and 7 keep the rest day free. +7 returns a solve to its own weekday, and
|
||||||
|
* of the five learning weekdays only Thursday's +3 touches a Sunday — which
|
||||||
|
* workingDay() slides to Monday.
|
||||||
|
*/
|
||||||
|
export const WINDOWS = [3, 7] as const;
|
||||||
|
export type Window = (typeof WINDOWS)[number];
|
||||||
|
|
||||||
|
export type Stage = "new" | `+${Window}` | "retired";
|
||||||
|
/** Chart / stats order, low to high. */
|
||||||
|
export const STAGES: Stage[] = ["new", "+3", "+7", "retired"];
|
||||||
export type Result = "pass" | "fail";
|
export type Result = "pass" | "fail";
|
||||||
export type Kind = "first" | "review" | "drill" | "gate";
|
export type Kind = "first" | "review" | "drill" | "gate";
|
||||||
|
|
||||||
@@ -142,8 +194,8 @@ export interface ProblemRow {
|
|||||||
defer_until: string | null;
|
defer_until: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INTERVAL: Record<string, number> = { "+2": 2, "+5": 5, "+10": 10 };
|
export const INTERVAL: Record<string, number> = { "+3": 3, "+7": 7 };
|
||||||
const NEXT_STAGE: Record<string, Stage> = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" };
|
const NEXT_STAGE: Record<string, Stage> = { new: "+3", "+3": "+7", "+7": "retired" };
|
||||||
|
|
||||||
// ── queries ──────────────────────────────────────────────────────
|
// ── queries ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -151,20 +203,111 @@ export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow
|
|||||||
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What "due" means, in one place: not retired, scheduled on/before ?1, and
|
||||||
|
* past its deferral if it has one. Every caller that asks "what is open on
|
||||||
|
* this day" — the digest, the review issue, the docs queue chart — shares
|
||||||
|
* this clause so they cannot answer differently.
|
||||||
|
*/
|
||||||
|
export const DUE_WHERE = `stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||||
|
AND (defer_until IS NULL OR defer_until <= ?1)`;
|
||||||
|
|
||||||
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
||||||
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(`SELECT * FROM problems WHERE ${DUE_WHERE} ORDER BY next_review, lc_number`)
|
||||||
`SELECT * FROM problems
|
|
||||||
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
|
||||||
AND (defer_until IS NULL OR defer_until <= ?1)
|
|
||||||
ORDER BY next_review, lc_number`,
|
|
||||||
)
|
|
||||||
.bind(date)
|
.bind(date)
|
||||||
.all<ProblemRow>();
|
.all<ProblemRow>();
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One problem the ladder wants re-solved today. */
|
||||||
|
export interface DueRow {
|
||||||
|
lc: number;
|
||||||
|
issue: number;
|
||||||
|
title: string;
|
||||||
|
difficulty: string;
|
||||||
|
/** The rung being settled: also the `work/<n>` bucket the re-solve goes in. */
|
||||||
|
stage: Stage;
|
||||||
|
bucket: number;
|
||||||
|
/** Scheduled day; earlier than `date` when a day was missed. */
|
||||||
|
due: string;
|
||||||
|
/** Days late (0 = due today), so a renderer can say so. */
|
||||||
|
late: number;
|
||||||
|
/** Last logged attempt, and how long ago — the "last seen" line. */
|
||||||
|
last_seen: string | null;
|
||||||
|
days_since: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the ladder wants re-solved on `date`, oldest first.
|
||||||
|
*
|
||||||
|
* The read side of "which problems are open today": the same rows the digest
|
||||||
|
* mails, shaped for whoever renders them (the daily Spaced Repetition issue).
|
||||||
|
* Nothing here writes, so it is safe to call repeatedly, and there is no
|
||||||
|
* second schedule to drift — D1's ladder is the schedule.
|
||||||
|
*/
|
||||||
|
export async function dueToday(db: D1Database, date: string): Promise<{ date: string; due: DueRow[] }> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT lc_number, issue, title, difficulty, stage, next_review,
|
||||||
|
(SELECT MAX(a.date) FROM attempts a WHERE a.lc_number = problems.lc_number) AS last_seen
|
||||||
|
FROM problems WHERE ${DUE_WHERE} ORDER BY next_review, lc_number`,
|
||||||
|
)
|
||||||
|
.bind(date)
|
||||||
|
.all<{
|
||||||
|
lc_number: number;
|
||||||
|
issue: number;
|
||||||
|
title: string;
|
||||||
|
difficulty: string;
|
||||||
|
stage: Stage;
|
||||||
|
next_review: string;
|
||||||
|
last_seen: string | null;
|
||||||
|
}>();
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
due: results.map((r) => ({
|
||||||
|
lc: r.lc_number,
|
||||||
|
issue: r.issue,
|
||||||
|
title: r.title,
|
||||||
|
difficulty: r.difficulty,
|
||||||
|
stage: r.stage,
|
||||||
|
// "+3" -> 3: the rung's number IS its work/ bucket.
|
||||||
|
bucket: Number(r.stage.slice(1)),
|
||||||
|
due: r.next_review,
|
||||||
|
late: daysBetween(r.next_review, date),
|
||||||
|
last_seen: r.last_seen,
|
||||||
|
days_since: r.last_seen === null ? null : daysBetween(r.last_seen, date),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reviews surfaced per day; the digest levels everything past this forward. */
|
||||||
|
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 —
|
* Blind drills: unsolved optional problems from topics ALREADY LEARNED —
|
||||||
* scheduled in an earlier week (the current week's optional pool is reserved
|
* scheduled in an earlier week (the current week's optional pool is reserved
|
||||||
@@ -242,16 +385,32 @@ export interface LogOutcome {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Record an attempt and move the ladder. Ladder semantics live here and only
|
* Record an attempt and move the ladder. Ladder semantics live here and only
|
||||||
* here — email one-taps, webhook /done lines, and gate scoring all converge.
|
* here — email one-taps, webhook /done lines, gate scoring, and solutions
|
||||||
|
* landing in work/ all converge.
|
||||||
*
|
*
|
||||||
* Email idempotency comes from the partial unique index on
|
* Email idempotency comes from the partial unique index on
|
||||||
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
||||||
* nothing and must not touch the ladder. Webhook corrections (pass then fail
|
* nothing and must not touch the ladder. Webhook corrections (pass then fail
|
||||||
* on the same day) remain legal — every webhook attempt appends.
|
* 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(
|
export async function logAttempt(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
opts: { lc: number; date: string; result: Result; source: "email" | "webhook"; gate?: boolean },
|
opts: {
|
||||||
|
lc: number;
|
||||||
|
date: string;
|
||||||
|
result: Result;
|
||||||
|
source: "email" | "webhook" | "commit";
|
||||||
|
/** Commit only: the stage this file settles. Default `new` = first solve. */
|
||||||
|
rung?: Stage;
|
||||||
|
gate?: boolean;
|
||||||
|
},
|
||||||
): Promise<LogOutcome> {
|
): Promise<LogOutcome> {
|
||||||
const p = await getProblem(db, opts.lc);
|
const p = await getProblem(db, opts.lc);
|
||||||
const nothing: LogOutcome = {
|
const nothing: LogOutcome = {
|
||||||
@@ -266,6 +425,21 @@ export async function logAttempt(
|
|||||||
duplicate: false,
|
duplicate: false,
|
||||||
};
|
};
|
||||||
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
|
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
|
||||||
|
// A commit 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") {
|
if (p.stage === "retired") {
|
||||||
return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` };
|
return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` };
|
||||||
}
|
}
|
||||||
@@ -288,16 +462,18 @@ export async function logAttempt(
|
|||||||
return { ...nothing, title: p.title, kind, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true };
|
return { ...nothing, title: p.title, kind, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ladder move. First-ever logs enter at +2 for pass AND fail.
|
// Ladder move. First-ever logs enter at +3 for pass AND fail.
|
||||||
let stage: Stage;
|
let stage: Stage;
|
||||||
if (first) {
|
if (first) {
|
||||||
stage = "+2";
|
stage = "+3";
|
||||||
} else if (opts.result === "pass") {
|
} else if (opts.result === "pass") {
|
||||||
stage = NEXT_STAGE[p.stage] ?? "retired";
|
stage = NEXT_STAGE[p.stage] ?? "retired";
|
||||||
} else {
|
} else {
|
||||||
stage = "+2";
|
stage = "+3";
|
||||||
}
|
}
|
||||||
const next = stage === "retired" ? null : addDays(opts.date, INTERVAL[stage]!);
|
// workingDay(), not addDays(): a Thursday solve's +3 would land on the rest
|
||||||
|
// day, and it takes Monday instead.
|
||||||
|
const next = stage === "retired" ? null : workingDay(addDays(opts.date, INTERVAL[stage]!));
|
||||||
|
|
||||||
const writes = [
|
const writes = [
|
||||||
db.prepare(
|
db.prepare(
|
||||||
|
|||||||
+12
-7
@@ -7,9 +7,12 @@
|
|||||||
import {
|
import {
|
||||||
CAMPAIGN_DAYS,
|
CAMPAIGN_DAYS,
|
||||||
CAMPAIGN_START,
|
CAMPAIGN_START,
|
||||||
|
DUE_WHERE,
|
||||||
addDays,
|
addDays,
|
||||||
campaignDay,
|
campaignDay,
|
||||||
campaignWeek,
|
campaignWeek,
|
||||||
|
isoWeek,
|
||||||
|
STAGES,
|
||||||
streak,
|
streak,
|
||||||
} from "./srs.ts";
|
} from "./srs.ts";
|
||||||
|
|
||||||
@@ -58,23 +61,25 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
|||||||
const { results: ladderRows } = await db
|
const { results: ladderRows } = await db
|
||||||
.prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage")
|
.prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage")
|
||||||
.all<{ stage: string; n: number }>();
|
.all<{ stage: string; n: number }>();
|
||||||
const ladder: Record<string, number> = { new: 0, "+2": 0, "+5": 0, "+10": 0, retired: 0 };
|
// Every rung is present even at zero: the page's bar list is this object.
|
||||||
|
const ladder: Record<string, number> = Object.fromEntries(STAGES.map((s) => [s, 0]));
|
||||||
for (const row of ladderRows) ladder[row.stage] = row.n;
|
for (const row of ladderRows) ladder[row.stage] = row.n;
|
||||||
|
|
||||||
const { results: gates } = await db
|
// 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")
|
.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 }>();
|
.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.
|
// Review-queue depth for the next 14 days: everything due by that day.
|
||||||
const queue: { date: string; due: number }[] = [];
|
const queue: { date: string; due: number }[] = [];
|
||||||
for (let i = 0; i < 14; i++) {
|
for (let i = 0; i < 14; i++) {
|
||||||
const date = addDays(today, i);
|
const date = addDays(today, i);
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(`SELECT COUNT(*) AS n FROM problems WHERE ${DUE_WHERE}`)
|
||||||
`SELECT COUNT(*) AS n FROM problems
|
|
||||||
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
|
||||||
AND (defer_until IS NULL OR defer_until <= ?1)`,
|
|
||||||
)
|
|
||||||
.bind(date)
|
.bind(date)
|
||||||
.first<{ n: number }>();
|
.first<{ n: number }>();
|
||||||
queue.push({ date, due: row?.n ?? 0 });
|
queue.push({ date, due: row?.n ?? 0 });
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
{ "binding": "DB", "database_name": "srs", "database_id": "2ee2c34a-c2e2-4ebb-934e-c9e7dc8aa4f1" }
|
{ "binding": "DB", "database_name": "srs", "database_id": "2ee2c34a-c2e2-4ebb-934e-c9e7dc8aa4f1" }
|
||||||
],
|
],
|
||||||
|
|
||||||
// Sender domain prdlk.com is onboarded to Email Sending; the binding is
|
// Sender domain prdlk.com is onboarded to Email Service; the binding is
|
||||||
// restricted to the one verified destination inbox.
|
// restricted to the one verified destination inbox.
|
||||||
"send_email": [
|
"send_email": [
|
||||||
{ "name": "EMAIL", "allowed_destination_addresses": ["prnk28@gmail.com"] }
|
{ "name": "EMAIL", "allowed_destination_addresses": ["prnk28@gmail.com"] }
|
||||||
|
|||||||
+87
-73
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Close the GitHub issue for every LeetCode problem actually solved under work/.
|
* Close the GitHub issue for every LeetCode problem actually solved under work/,
|
||||||
|
* then report the solved set to the SRS Worker so D1 agrees.
|
||||||
*
|
*
|
||||||
* Reconciles state instead of reacting to a push diff: any `problem`-labelled
|
* Reconciles state instead of reacting to a push diff: any `problem`-labelled
|
||||||
* issue whose LC number has an *implemented* solution file in work/ gets
|
* issue whose LC number has an *implemented* solution file in work/ gets
|
||||||
@@ -9,75 +10,27 @@
|
|||||||
*
|
*
|
||||||
* `bun run pick` scaffolds a statement header plus an empty function body, so
|
* `bun run pick` scaffolds a statement header plus an empty function body, so
|
||||||
* file existence alone means nothing — a stub must not close its issue. See
|
* file existence alone means nothing — a stub must not close its issue. See
|
||||||
* isImplemented().
|
* isImplemented() in source.ts.
|
||||||
*
|
*
|
||||||
* bun apps/cli/close-solved.ts # close matches
|
* bun apps/cli/close-solved.ts # close matches, log them in D1
|
||||||
* bun apps/cli/close-solved.ts --dry-run # report only, touch nothing
|
* bun apps/cli/close-solved.ts --dry-run # report only, touch nothing
|
||||||
*
|
*
|
||||||
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
||||||
|
* SRS_ADMIN_KEY for the D1 push (unset = skip it); SRS_API overrides
|
||||||
|
* the Worker URL for local `wrangler dev` runs.
|
||||||
*/
|
*/
|
||||||
import { basename, join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { github } from "./github.ts";
|
import { github } from "./github.ts";
|
||||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
|
import { isImplemented } from "./source.ts";
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
import { WORK, parseWorkPath, type Bucket } from "./work.ts";
|
||||||
const WORK = join(ROOT, "work");
|
|
||||||
|
|
||||||
const DRY =
|
const DRY =
|
||||||
process.argv.includes("--dry-run") ||
|
process.argv.includes("--dry-run") ||
|
||||||
process.env.DRY_RUN === "1" ||
|
process.env.DRY_RUN === "1" ||
|
||||||
process.env.DRY_RUN === "true";
|
process.env.DRY_RUN === "true";
|
||||||
|
|
||||||
// ── is the file a real solution or just a scaffolded stub? ────────
|
|
||||||
|
|
||||||
/** Placeholder bodies that leetcode-cli / a human leaves behind. */
|
|
||||||
const PLACEHOLDERS: Record<string, true> = { pass: true, "...": true, TODO: true };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide whether a work/ file contains an implementation.
|
|
||||||
*
|
|
||||||
* The header comment is dropped the same way sync.ts splitSource() does it
|
|
||||||
* (duplicated rather than imported, because sync.ts runs its whole pipeline on
|
|
||||||
* import). Comments must go before any brace analysis: the scaffold's JSDoc
|
|
||||||
* carries `@param {number[]}`, whose braces would otherwise read as a body.
|
|
||||||
*
|
|
||||||
* A brace-language file counts as implemented when at least one *innermost*
|
|
||||||
* brace pair holds real content. That distinguishes a bare stub
|
|
||||||
* (`function(nums) {}`) and a class-shaped design stub (every method body
|
|
||||||
* empty) from any genuine solution, whose innermost block always has code.
|
|
||||||
*/
|
|
||||||
function isImplemented(src: string, lang: "js" | "py"): boolean {
|
|
||||||
if (lang === "py") {
|
|
||||||
const open = src.indexOf('"""');
|
|
||||||
const close = src.indexOf('"""', open + 3);
|
|
||||||
const code = open === -1 || close === -1 ? src : src.slice(close + 3);
|
|
||||||
for (const raw of code.split("\n")) {
|
|
||||||
const line = raw.replace(/#.*$/, "").trim();
|
|
||||||
if (!line || PLACEHOLDERS[line]) continue;
|
|
||||||
if (/^(?:@|def\s|class\s)/.test(line)) continue;
|
|
||||||
return true; // a statement inside some def body
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerEnd = src.indexOf("*/");
|
|
||||||
const body = headerEnd === -1 ? src : src.slice(headerEnd + 2);
|
|
||||||
const code = body.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/.*$/gm, " ");
|
|
||||||
|
|
||||||
let innermost = -1;
|
|
||||||
for (let i = 0; i < code.length; i++) {
|
|
||||||
if (code[i] === "{") {
|
|
||||||
innermost = i;
|
|
||||||
} else if (code[i] === "}" && innermost !== -1) {
|
|
||||||
const inner = code.slice(innermost + 1, i).replace(/[\s;]/g, "");
|
|
||||||
if (inner && !PLACEHOLDERS[inner]) return true;
|
|
||||||
innermost = -1; // measured; the enclosing pair is not innermost
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── repo + auth ──────────────────────────────────────────────────
|
// ── repo + auth ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const gh = await github();
|
const gh = await github();
|
||||||
@@ -86,27 +39,27 @@ const gh = await github();
|
|||||||
|
|
||||||
interface WorkEntry {
|
interface WorkEntry {
|
||||||
files: string[];
|
files: string[];
|
||||||
|
/** Any bucket implemented — this is what closes the problem issue. */
|
||||||
implemented: boolean;
|
implemented: boolean;
|
||||||
|
/** Buckets holding a real solution: 1 = first solve, 3/7 = the re-solves. */
|
||||||
|
solved: Set<Bucket>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** LC number -> solution files (a problem may have both js and py). */
|
/** LC number -> its solution files across every bucket (js and py both count). */
|
||||||
const work = new Map<number, WorkEntry>();
|
const work = new Map<number, WorkEntry>();
|
||||||
for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
||||||
const m = basename(rel).match(/^(\d+)\.(.+)\.(?:js|py)$/);
|
const file = parseWorkPath(rel);
|
||||||
if (!m) {
|
if (!file) {
|
||||||
console.warn(`skip (unrecognized name): work/${rel}`);
|
console.warn(`skip (off-layout): work/${rel}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const num = Number(m[1]);
|
|
||||||
const src = await Bun.file(join(WORK, rel)).text();
|
const src = await Bun.file(join(WORK, rel)).text();
|
||||||
const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js");
|
const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js");
|
||||||
const entry = work.get(num);
|
const entry = work.get(file.lc) ?? { files: [], implemented: false, solved: new Set<Bucket>() };
|
||||||
if (entry) {
|
entry.files.push(`work/${rel}`);
|
||||||
entry.files.push(`work/${rel}`);
|
entry.implemented ||= implemented;
|
||||||
entry.implemented ||= implemented;
|
if (implemented) entry.solved.add(file.bucket);
|
||||||
} else {
|
work.set(file.lc, entry);
|
||||||
work.set(num, { files: [`work/${rel}`], implemented });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── open problem issues ──────────────────────────────────────────
|
// ── open problem issues ──────────────────────────────────────────
|
||||||
@@ -193,24 +146,85 @@ for (const num of [...work.keys()].sort((a, b) => a - b)) {
|
|||||||
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.
|
||||||
|
const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
||||||
|
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 ───────────────────────────────────────────────────────
|
// ── report ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const rows = [["lc", "issue", "set", "status"], ...report];
|
const rows = [["lc", "issue", "set", "status"], ...report];
|
||||||
printTable(rows);
|
printTable(rows);
|
||||||
|
|
||||||
const implemented = [...work.values()].filter((e) => e.implemented).length;
|
|
||||||
const actionable = report.filter((r) => r[3] !== "no open issue");
|
const actionable = report.filter((r) => r[3] !== "no open issue");
|
||||||
console.log(
|
console.log(
|
||||||
`\n${work.size} in work/ · ${implemented} implemented · ` +
|
`\n${work.size} problems in work/ · ${solved.length} solved files across buckets · ` +
|
||||||
`${actionable.length} matched an open issue · ` +
|
`${actionable.length} matched an open issue · ` +
|
||||||
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}`,
|
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}` +
|
||||||
|
`\nd1: ${syncNote}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await writeStepSummary(
|
await writeStepSummary(
|
||||||
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
|
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
|
||||||
`${work.size} files in \`work/\`, ${implemented} implemented, ` +
|
`${work.size} problems in \`work/\`, ${solved.length} solved files across buckets, ` +
|
||||||
`${actionable.length} matched an open issue.\n\n` +
|
`${actionable.length} matched an open issue.\n\n` +
|
||||||
(actionable.length
|
(actionable.length
|
||||||
? `${markdownTable([rows[0]!, ...actionable])}\n`
|
? `${markdownTable([rows[0]!, ...actionable])}\n`
|
||||||
: "Nothing to close.\n"),
|
: "Nothing to close.\n") +
|
||||||
|
`\nD1: ${syncNote}\n`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Non-zero only for the D1 push: the closes are already reported above.
|
||||||
|
if (syncFailed) process.exitCode = 1;
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* Local LeetCode problem cache backed by SQLite at
|
||||||
|
* ~/.local/share/leetcode/data.db (XDG_DATA_HOME honored).
|
||||||
|
*
|
||||||
|
* One `problems` table, two freshness rules:
|
||||||
|
* - the full problem index (id / title / slug / difficulty / paid flag) is
|
||||||
|
* refreshed from leetcode.com at most once per 24h (`meta.index_fetched_at`);
|
||||||
|
* - descriptions (HTML from GraphQL `question.content`) are fetched once per
|
||||||
|
* slug and kept forever. Only the campaign problems linked from README.md
|
||||||
|
* are ever fetched, and paid-only problems are skipped because GraphQL
|
||||||
|
* returns null content for them.
|
||||||
|
*
|
||||||
|
* A `solutions` table tracks the lifecycle of each work/ file by LC number:
|
||||||
|
* `created_at` (backfilled from the file's birthtime on every picker run),
|
||||||
|
* `last_tested` and `last_submitted` (stamped on exit 0 of leetcode-cli).
|
||||||
|
* The test picker sorts by last_tested, the submit picker by created_at, and
|
||||||
|
* submit hides anything with a last_submitted.
|
||||||
|
*
|
||||||
|
* Library: side-effect-free on import — nothing is opened or fetched until
|
||||||
|
* problemDb() is called. Index upserts never touch the description column, so
|
||||||
|
* a daily refresh cannot evict cached statements.
|
||||||
|
*/
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
export interface Problem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
difficulty: "Easy" | "Medium" | "Hard";
|
||||||
|
paidOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lifecycle of one work/ solution, keyed by LC number. ISO timestamps. */
|
||||||
|
export interface SolutionActivity {
|
||||||
|
createdAt: string;
|
||||||
|
lastTested: string | null;
|
||||||
|
lastSubmitted: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
|
||||||
|
const INDEX_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export const DB_PATH = join(
|
||||||
|
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
|
||||||
|
"leetcode",
|
||||||
|
"data.db",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── README parsing ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Campaign problem slugs linked from README.md, in order, deduplicated. */
|
||||||
|
export function readmeSlugs(markdown: string): string[] {
|
||||||
|
const slugs = new Set<string>();
|
||||||
|
for (const m of markdown.matchAll(/https:\/\/leetcode\.com\/problems\/([\w-]+)\//g)) {
|
||||||
|
slugs.add(m[1]!);
|
||||||
|
}
|
||||||
|
return [...slugs];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── remote fetches ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchIndex(): Promise<Problem[]> {
|
||||||
|
const res = await fetch("https://leetcode.com/api/problems/all/", {
|
||||||
|
headers: { "user-agent": "Mozilla/5.0" },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`GET /api/problems/all -> ${res.status} ${res.statusText}`);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
stat_status_pairs: Array<{
|
||||||
|
stat: {
|
||||||
|
frontend_question_id: number;
|
||||||
|
question__title: string;
|
||||||
|
question__title_slug: string;
|
||||||
|
};
|
||||||
|
difficulty: { level: 1 | 2 | 3 };
|
||||||
|
paid_only: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
return data.stat_status_pairs
|
||||||
|
.map((p) => ({
|
||||||
|
id: p.stat.frontend_question_id,
|
||||||
|
title: p.stat.question__title,
|
||||||
|
slug: p.stat.question__title_slug,
|
||||||
|
difficulty: DIFFICULTY[p.difficulty.level] as Problem["difficulty"],
|
||||||
|
paidOnly: p.paid_only,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.id - b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTML problem statement, or null when LeetCode withholds it (paid-only). */
|
||||||
|
export async function fetchDescription(slug: string): Promise<string | null> {
|
||||||
|
const res = await fetch("https://leetcode.com/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"user-agent": "Mozilla/5.0",
|
||||||
|
referer: `https://leetcode.com/problems/${slug}/`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: "query questionContent($titleSlug: String!) { question(titleSlug: $titleSlug) { content } }",
|
||||||
|
variables: { titleSlug: slug },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`POST /graphql (${slug}) -> ${res.status} ${res.statusText}`);
|
||||||
|
const data = (await res.json()) as { data?: { question?: { content?: string | null } | null } };
|
||||||
|
return data.data?.question?.content ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the cache ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function problemDb(path = DB_PATH) {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
const db = new Database(path);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS problems (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
difficulty TEXT NOT NULL,
|
||||||
|
paid_only INTEGER NOT NULL,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS solutions (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_tested TEXT,
|
||||||
|
last_submitted TEXT
|
||||||
|
);
|
||||||
|
DROP TABLE IF EXISTS submissions;
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const getMeta = db.query<{ value: string }, [string]>("SELECT value FROM meta WHERE key = ?1");
|
||||||
|
const setMeta = db.query<never, [string, string]>(
|
||||||
|
"INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
);
|
||||||
|
const upsert = db.query<never, [number, string, string, string, number]>(
|
||||||
|
`INSERT INTO problems (id, title, slug, difficulty, paid_only)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
title = excluded.title, slug = excluded.slug,
|
||||||
|
difficulty = excluded.difficulty, paid_only = excluded.paid_only`,
|
||||||
|
);
|
||||||
|
const descState = db.query<{ paid_only: number; described: number }, [string]>(
|
||||||
|
"SELECT paid_only, description IS NOT NULL AS described FROM problems WHERE slug = ?1",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
|
||||||
|
/** True when the index has never been fetched or is older than 24h. */
|
||||||
|
indexStale(): boolean {
|
||||||
|
const row = getMeta.get("index_fetched_at");
|
||||||
|
return !row || Date.now() - Number(row.value) > INDEX_TTL_MS;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Pull the full index from leetcode.com and upsert it. Returns the count. */
|
||||||
|
async refreshIndex(): Promise<number> {
|
||||||
|
const problems = await fetchIndex();
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const p of problems) upsert.run(p.id, p.title, p.slug, p.difficulty, p.paidOnly ? 1 : 0);
|
||||||
|
setMeta.run("index_fetched_at", String(Date.now()));
|
||||||
|
})();
|
||||||
|
return problems.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Every cached problem, ordered by LC number. */
|
||||||
|
all(): Problem[] {
|
||||||
|
const rows = db
|
||||||
|
.query<{ id: number; title: string; slug: string; difficulty: string; paid_only: number }, []>(
|
||||||
|
"SELECT id, title, slug, difficulty, paid_only FROM problems ORDER BY id",
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
slug: r.slug,
|
||||||
|
difficulty: r.difficulty as Problem["difficulty"],
|
||||||
|
paidOnly: r.paid_only === 1,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Free (non-premium) slugs among `slugs` with no cached description yet. */
|
||||||
|
missingDescriptions(slugs: string[]): string[] {
|
||||||
|
return slugs.filter((slug) => {
|
||||||
|
const row = descState.get(slug);
|
||||||
|
return row !== null && !row.paid_only && !row.described;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
saveDescription(slug: string, html: string): void {
|
||||||
|
db.query("UPDATE problems SET description = ?2 WHERE slug = ?1").run(slug, html);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Per-solution lifecycle rows keyed by LC number. */
|
||||||
|
solutionActivity(): Map<number, SolutionActivity> {
|
||||||
|
const rows = db
|
||||||
|
.query<{ id: number; created_at: string; last_tested: string | null; last_submitted: string | null }, []>(
|
||||||
|
"SELECT id, created_at, last_tested, last_submitted FROM solutions",
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
return new Map(
|
||||||
|
rows.map((r) => [r.id, { createdAt: r.created_at, lastTested: r.last_tested, lastSubmitted: r.last_submitted }]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Register a solution's creation time; existing rows are left alone. */
|
||||||
|
ensureSolution(id: number, createdAt: string): void {
|
||||||
|
db.query("INSERT INTO solutions (id, created_at) VALUES (?1, ?2) ON CONFLICT(id) DO NOTHING").run(id, createdAt);
|
||||||
|
},
|
||||||
|
|
||||||
|
markTested(id: number): void {
|
||||||
|
db.query(
|
||||||
|
`INSERT INTO solutions (id, created_at, last_tested) VALUES (?1, ?2, ?2)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET last_tested = excluded.last_tested`,
|
||||||
|
).run(id, new Date().toISOString());
|
||||||
|
},
|
||||||
|
|
||||||
|
markSubmitted(id: number): void {
|
||||||
|
db.query(
|
||||||
|
`INSERT INTO solutions (id, created_at, last_submitted) VALUES (?1, ?2, ?2)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET last_submitted = excluded.last_submitted`,
|
||||||
|
).run(id, new Date().toISOString());
|
||||||
|
},
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
db.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+23
-3
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* GitHub REST plumbing shared by the issue reconcilers (close-solved,
|
* GitHub REST + GraphQL plumbing shared by the issue reconcilers (close-solved,
|
||||||
* close-topics).
|
* close-topics) and the Project field reconciler (sync-project-fields).
|
||||||
*
|
*
|
||||||
* Importing this module is side-effect free — nothing resolves credentials or
|
* Importing this module is side-effect free — nothing resolves credentials or
|
||||||
* touches the network until github() is awaited — so a script can import it
|
* touches the network until github() is awaited — so a script can import it
|
||||||
@@ -22,6 +22,8 @@ export interface GitHub {
|
|||||||
repo: string;
|
repo: string;
|
||||||
/** One authenticated request against api.github.com; throws on non-2xx. */
|
/** One authenticated request against api.github.com; throws on non-2xx. */
|
||||||
api(path: string, init?: RequestInit): Promise<unknown>;
|
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. */
|
/** Every page of a list endpoint, flattened into one stream of elements. */
|
||||||
list(path: string): AsyncGenerator<unknown, void, void>;
|
list(path: string): AsyncGenerator<unknown, void, void>;
|
||||||
/** Comment on an issue, then close it as completed. */
|
/** Comment on an issue, then close it as completed. */
|
||||||
@@ -74,6 +76,24 @@ export async function github(): Promise<GitHub> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function graphql(
|
||||||
|
query: string,
|
||||||
|
variables: Record<string, unknown> = {},
|
||||||
|
): Promise<unknown> {
|
||||||
|
const res = await fetch("https://api.github.com/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
|
||||||
|
const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
|
||||||
|
if (payload.errors?.length) throw new Error(payload.errors.map((e) => e.message).join("; "));
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comment before closing: if the PATCH fails, the issue still carries a
|
* Comment before closing: if the PATCH fails, the issue still carries a
|
||||||
* visible note of what the automation decided, instead of failing silently.
|
* visible note of what the automation decided, instead of failing silently.
|
||||||
@@ -89,5 +109,5 @@ export async function github(): Promise<GitHub> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { repo, api, list, closeIssue };
|
return { repo, api, graphql, list, closeIssue };
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"pick": "bun ./pick.ts",
|
"pick": "bun ./pick.ts",
|
||||||
"test": "bun ./test.ts",
|
"test": "bun ./test.ts",
|
||||||
|
"submit": "bun ./submit.ts",
|
||||||
"sync": "bun ./sync.ts",
|
"sync": "bun ./sync.ts",
|
||||||
"close-solved": "bun ./close-solved.ts",
|
"close-solved": "bun ./close-solved.ts",
|
||||||
"close-topics": "bun ./close-topics.ts"
|
"close-topics": "bun ./close-topics.ts"
|
||||||
|
|||||||
+227
-69
@@ -1,80 +1,171 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Fuzzy-pick a LeetCode problem and scaffold it via leetcode-cli.
|
* Fuzzy-pick a problem and scaffold it via leetcode-cli, in one of the three
|
||||||
* Problem index is fetched from leetcode.com and cached for 24h in .cache/.
|
* work/ buckets (see work.ts): a first solve under work/1, or the 3-day and
|
||||||
|
* 7-day blind re-solves under work/3 and work/7. Tab cycles the sections.
|
||||||
|
*
|
||||||
|
* The problem index lives in the local SQLite cache (see db.ts), refreshed at
|
||||||
|
* most once per 24h; campaign problem descriptions from README.md are
|
||||||
|
* backfilled into the same db on the way. The new-problem section hides
|
||||||
|
* anything that already has a numbered file under work/1 — re-picking it would
|
||||||
|
* only clobber the existing scaffold — and each review section hides whatever
|
||||||
|
* its own bucket already holds, so a finished re-solve leaves the list.
|
||||||
*/
|
*/
|
||||||
import { autocomplete, cancel, isCancel, spinner } from "@clack/prompts";
|
import { autocomplete, cancel, isCancel, log, spinner } from "@clack/prompts";
|
||||||
import { mkdir } from "node:fs/promises";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { styleText } from "node:util";
|
||||||
|
import { etDate } from "../api/src/srs.ts";
|
||||||
|
import { fetchDescription, problemDb, readmeSlugs } from "./db";
|
||||||
|
import { ROOT, WINDOWS, bucketDir, reviewQueues, scanBucket, type Bucket } from "./work";
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
const db = problemDb();
|
||||||
const CACHE_FILE = join(ROOT, ".cache", "leetcode-problems.json");
|
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
interface Problem {
|
// ── problem index (24h TTL) ──────────────────────────────────────
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
slug: string;
|
|
||||||
difficulty: "Easy" | "Medium" | "Hard";
|
|
||||||
paidOnly: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
|
|
||||||
|
|
||||||
async function loadProblems(): Promise<Problem[]> {
|
|
||||||
const file = Bun.file(CACHE_FILE);
|
|
||||||
if (await file.exists()) {
|
|
||||||
const stale = Date.now() - file.lastModified > CACHE_TTL_MS;
|
|
||||||
if (!stale) return file.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (db.indexStale()) {
|
||||||
const s = spinner();
|
const s = spinner();
|
||||||
s.start("Fetching problem index from leetcode.com");
|
s.start("Fetching problem index from leetcode.com");
|
||||||
const res = await fetch("https://leetcode.com/api/problems/all/", {
|
try {
|
||||||
headers: { "user-agent": "Mozilla/5.0" },
|
s.stop(`Indexed ${await db.refreshIndex()} problems`);
|
||||||
});
|
} catch (err) {
|
||||||
if (!res.ok) {
|
if (db.all().length === 0) {
|
||||||
s.error(`Fetch failed: ${res.status} ${res.statusText}`);
|
s.error(`Fetch failed: ${err}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
}
|
||||||
|
s.stop(`Index refresh failed (${err}) — using cached copy`);
|
||||||
}
|
}
|
||||||
const data = (await res.json()) as {
|
|
||||||
stat_status_pairs: Array<{
|
|
||||||
stat: {
|
|
||||||
frontend_question_id: number;
|
|
||||||
question__title: string;
|
|
||||||
question__title_slug: string;
|
|
||||||
};
|
|
||||||
difficulty: { level: 1 | 2 | 3 };
|
|
||||||
paid_only: boolean;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
const problems: Problem[] = data.stat_status_pairs
|
|
||||||
.map((p) => ({
|
|
||||||
id: p.stat.frontend_question_id,
|
|
||||||
title: p.stat.question__title,
|
|
||||||
slug: p.stat.question__title_slug,
|
|
||||||
difficulty: DIFFICULTY[p.difficulty.level] as Problem["difficulty"],
|
|
||||||
paidOnly: p.paid_only,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.id - b.id);
|
|
||||||
s.stop(`Loaded ${problems.length} problems`);
|
|
||||||
|
|
||||||
await mkdir(join(ROOT, ".cache"), { recursive: true });
|
|
||||||
await Bun.write(CACHE_FILE, JSON.stringify(problems));
|
|
||||||
return problems;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const problems = await loadProblems();
|
// ── description backfill for the campaign problems in README.md ──
|
||||||
|
|
||||||
const picked = await autocomplete<Problem>({
|
const missing = db.missingDescriptions(readmeSlugs(await Bun.file(join(ROOT, "README.md")).text()));
|
||||||
message: "Pick a problem",
|
if (missing.length > 0) {
|
||||||
placeholder: "Type to search by number or title...",
|
const s = spinner();
|
||||||
maxItems: 12,
|
s.start(`Caching ${missing.length} problem descriptions`);
|
||||||
options: problems.map((p) => ({
|
let fetched = 0;
|
||||||
value: p,
|
let failed = 0;
|
||||||
label: `${p.id}. ${p.title}`,
|
await Promise.all(
|
||||||
hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty,
|
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) => {
|
filter: (search, option) => {
|
||||||
const haystack = option.label!.toLowerCase();
|
const haystack = option.label!.toLowerCase();
|
||||||
return search
|
return search
|
||||||
@@ -88,10 +179,77 @@ if (isCancel(picked)) {
|
|||||||
cancel("Nothing picked.");
|
cancel("Nothing picked.");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
if (picked === undefined) {
|
||||||
|
// Enter on an empty section: nothing was focused, so there is nothing to do.
|
||||||
|
cancel(`Nothing owed in the ${sections[active]!.label} section.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const proc = Bun.spawn(
|
// ── scaffold into the picked bucket ──────────────────────────────
|
||||||
// bun installs workspace-dep bins into this package's node_modules, not the root's
|
|
||||||
[join(import.meta.dir, "node_modules", ".bin", "leetcode"), "pick", picked.slug],
|
// The active workspace names the config file; everything else about it belongs
|
||||||
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
|
// to leetcode-cli.
|
||||||
);
|
const registry = join(homedir(), ".leetcode", "workspaces.json");
|
||||||
process.exit(await proc.exited);
|
let workspace = "default";
|
||||||
|
if (existsSync(registry)) {
|
||||||
|
const parsed: unknown = JSON.parse(readFileSync(registry, "utf8"));
|
||||||
|
if (parsed && typeof parsed === "object" && "active" in parsed && typeof parsed.active === "string") {
|
||||||
|
workspace = parsed.active || workspace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const configPath = join(homedir(), ".leetcode", "workspaces", workspace, "config.json");
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
log.error(`No leetcode-cli config at ${configPath} — run \`leetcode config -i\` once.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point leetcode-cli at one bucket. It reads its output directory from the
|
||||||
|
* active workspace config and from nowhere else — no flag, no environment
|
||||||
|
* override — so `workDir` is the whole handover.
|
||||||
|
*
|
||||||
|
* Each write MERGES the file as it is on disk at that moment; a saved copy is
|
||||||
|
* never played back. The CLI owns this file too (theme, language, whatever a
|
||||||
|
* `leetcode login` adds), and replaying stale bytes over it would silently
|
||||||
|
* undo what it wrote while the child ran. After the pick, `workDir` is set to
|
||||||
|
* work/1 instead of to whatever it was before: the first-solve bucket is the
|
||||||
|
* one resting value that is always right, so even a killed run leaves a bare
|
||||||
|
* `leetcode pick` on-layout.
|
||||||
|
*/
|
||||||
|
function setWorkDir(target: string): void {
|
||||||
|
const raw: unknown = JSON.parse(readFileSync(configPath, "utf8"));
|
||||||
|
if (raw === null || typeof raw !== "object") {
|
||||||
|
log.error(`${configPath} holds no JSON object — run \`leetcode config -i\` to rewrite it.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
// 2-space JSON with a trailing newline: leetcode-cli's own format.
|
||||||
|
writeFileSync(configPath, `${JSON.stringify({ ...raw, workDir: target }, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = bucketDir(picked.bucket);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
setWorkDir(dir);
|
||||||
|
|
||||||
|
let code: number;
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
// bun installs workspace-dep bins into this package's node_modules, not the root's
|
||||||
|
[join(import.meta.dir, "node_modules", ".bin", "leetcode"), "pick", picked.slug],
|
||||||
|
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
|
||||||
|
);
|
||||||
|
code = await proc.exited;
|
||||||
|
} finally {
|
||||||
|
setWorkDir(bucketDir(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// leetcode-cli exits 0 even when it wrote nothing (an expired session only
|
||||||
|
// prints a warning), so the scaffold itself is the acceptance test: without a
|
||||||
|
// file for this problem in the bucket, the pick failed.
|
||||||
|
if (!scanBucket(picked.bucket).has(picked.lc)) {
|
||||||
|
log.error(
|
||||||
|
`leetcode-cli scaffolded nothing for LC ${picked.lc} under work/${picked.bucket}/.\n` +
|
||||||
|
"If it printed “Session expired”, run `leetcode login` and pick again.",
|
||||||
|
);
|
||||||
|
process.exit(code === 0 ? 1 : code);
|
||||||
|
}
|
||||||
|
process.exit(code);
|
||||||
@@ -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
+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));
|
||||||
+7
-3
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Sync work/ leetcode solutions into apps/docs/content/ pages.
|
* Sync work/1 leetcode solutions into apps/docs/content/ pages.
|
||||||
*
|
*
|
||||||
* - New problems get a full page (frontmatter, badge, warning, examples,
|
* - New problems get a full page (frontmatter, badge, warning, examples,
|
||||||
* constraints, solution) in the gold-standard format.
|
* constraints, solution) in the gold-standard format.
|
||||||
@@ -9,12 +9,16 @@
|
|||||||
* - When a problem is solved in both JavaScript and Python the solution
|
* - When a problem is solved in both JavaScript and Python the solution
|
||||||
* renders as a <CodeGroup> (Python first); single-language solutions
|
* renders as a <CodeGroup> (Python first); single-language solutions
|
||||||
* render as a plain fence.
|
* render as a plain fence.
|
||||||
|
*
|
||||||
|
* 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 { mkdir, rename } from "node:fs/promises";
|
||||||
import { basename, join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
const WORK = join(ROOT, "work");
|
const WORK = join(ROOT, "work", "1");
|
||||||
const DOCS = join(ROOT, "apps", "docs", "content");
|
const DOCS = join(ROOT, "apps", "docs", "content");
|
||||||
|
|
||||||
type Lang = "js" | "py";
|
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 file = basename(rel);
|
||||||
const m = file.match(/^(\d+)\.(.+)\.(?:js|py)$/);
|
const m = file.match(/^(\d+)\.(.+)\.(?:js|py)$/);
|
||||||
if (!m) {
|
if (!m) {
|
||||||
console.warn(`skip (unrecognized name): work/${rel}`);
|
console.warn(`skip (unrecognized name): work/1/${rel}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const num = Number(m[1]);
|
const num = Number(m[1]);
|
||||||
|
|||||||
+2
-50
@@ -2,54 +2,6 @@
|
|||||||
/**
|
/**
|
||||||
* Fuzzy-select a solution file from work/ and run leetcode-cli tests on it.
|
* Fuzzy-select a solution file from work/ and run leetcode-cli tests on it.
|
||||||
*/
|
*/
|
||||||
import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
|
import { runOnSolution } from "./solutions";
|
||||||
import { join, relative } from "node:path";
|
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
await runOnSolution("test", "Test which solution?");
|
||||||
const WORK_DIR = join(ROOT, "work");
|
|
||||||
|
|
||||||
const files = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })]
|
|
||||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
||||||
|
|
||||||
if (files.length === 0) {
|
|
||||||
log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const picked = await autocomplete<string>({
|
|
||||||
message: "Test which solution?",
|
|
||||||
placeholder: "Type to search...",
|
|
||||||
maxItems: 12,
|
|
||||||
options: files.map((f) => {
|
|
||||||
// work layout: Difficulty/Category/<id>.<slug>.<ext>
|
|
||||||
const [difficulty, category, name] = f.split("/");
|
|
||||||
return {
|
|
||||||
value: f,
|
|
||||||
label: name ?? f,
|
|
||||||
hint: category ? `${difficulty} · ${category}` : difficulty,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
filter: (search, option) => {
|
|
||||||
const haystack = option.value.toLowerCase();
|
|
||||||
return search
|
|
||||||
.toLowerCase()
|
|
||||||
.split(/\s+/)
|
|
||||||
.every((token) => haystack.includes(token));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isCancel(picked)) {
|
|
||||||
cancel("Nothing selected.");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const proc = Bun.spawn(
|
|
||||||
[
|
|
||||||
// bun installs workspace-dep bins into this package's node_modules, not the root's
|
|
||||||
join(import.meta.dir, "node_modules", ".bin", "leetcode"),
|
|
||||||
"test",
|
|
||||||
join(WORK_DIR, picked),
|
|
||||||
],
|
|
||||||
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
|
|
||||||
);
|
|
||||||
process.exit(await proc.exited);
|
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 — the same call close-solved.ts makes before it closes an issue.
|
||||||
|
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((f) =>
|
||||||
|
isImplemented(readFileSync(join(WORK, f.rel), "utf8"), f.rel.endsWith(".py") ? "py" : "js"),
|
||||||
|
);
|
||||||
|
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,55 @@
|
|||||||
|
---
|
||||||
|
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].
|
||||||
|
|
||||||
|
## 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]
|
||||||
|
```
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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]
|
||||||
|
```
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
title: '189. Rotate Array'
|
|
||||||
description: Given an integer array nums, rotate the array to the right by k steps, where k is non-negative
|
|
||||||
sidebar:
|
|
||||||
label: 'Rotate Array'
|
|
||||||
badge: 'Medium'
|
|
||||||
---
|
|
||||||
|
|
||||||
<Badge variant="accent">Two Pointers</Badge>
|
|
||||||
|
|
||||||
::::warning
|
|
||||||
Try to come up with as many solutions as you can. There are at least three different ways to solve this problem. Could you do it in-place with `O(1)` extra space?
|
|
||||||
::::
|
|
||||||
|
|
||||||
### Example 1:
|
|
||||||
- Input: `nums = [1,2,3,4,5,6,7], k = 3`
|
|
||||||
- Output: `[5,6,7,1,2,3,4]`
|
|
||||||
- Explanation: rotate 1 steps to the right: `[7,1,2,3,4,5,6]` rotate 2 steps to the right: `[6,7,1,2,3,4,5]` rotate 3 steps to the right: `[5,6,7,1,2,3,4]`
|
|
||||||
|
|
||||||
### Example 2:
|
|
||||||
- Input: `nums = [-1,-100,3,99], k = 2`
|
|
||||||
- Output: `[3,99,-1,-100]`
|
|
||||||
- Explanation: rotate 1 steps to the right: `[99,-1,-100,3]` rotate 2 steps to the right: `[3,99,-1,-100]`
|
|
||||||
|
|
||||||
### Constraints:
|
|
||||||
|
|
||||||
- `1 <= nums.length <= 10^5`
|
|
||||||
- `-2^31 <= nums[i] <= 2^31 - 1`
|
|
||||||
- `0 <= k <= 10^5`
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
```js
|
|
||||||
/**
|
|
||||||
* @param {number[]} nums
|
|
||||||
* @param {number} k
|
|
||||||
* @return {void} Do not return anything, modify nums in-place instead.
|
|
||||||
*/
|
|
||||||
var rotate = function(nums, k) {
|
|
||||||
|
|
||||||
};
|
|
||||||
```
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
---
|
|
||||||
title: '2090. K Radius Subarray Averages'
|
|
||||||
description: You are given a 0-indexed array nums of n integers, and an integer k
|
|
||||||
sidebar:
|
|
||||||
label: 'K Radius Subarray Averages'
|
|
||||||
badge: 'Medium'
|
|
||||||
---
|
|
||||||
|
|
||||||
<Badge variant="accent">Array</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
|
||||||
- Input: `nums = [7,4,3,9,1,8,5,2,6], k = 3`
|
|
||||||
- Output: `[-1,-1,-1,5,4,4,-1,-1,-1]`
|
|
||||||
- Explanation:
|
|
||||||
- `avg[0]`, `avg[1]`, and `avg[2]` are `-1` because there are less than `k` elements before each index.
|
|
||||||
- The sum of the subarray centered at index `3` with radius `3` is: `7 + 4 + 3 + 9 + 1 + 8 + 5 = 37`. Using integer division, `avg[3] = 37 / 7 = 5`.
|
|
||||||
- For the subarray centered at index `4`, `avg[4] = (4 + 3 + 9 + 1 + 8 + 5 + 2) / 7 = 4`.
|
|
||||||
- For the subarray centered at index `5`, `avg[5] = (3 + 9 + 1 + 8 + 5 + 2 + 6) / 7 = 4`.
|
|
||||||
- `avg[6]`, `avg[7]`, and `avg[8]` are `-1` because there are less than `k` elements after each index.
|
|
||||||
|
|
||||||
### Example 2:
|
|
||||||
- Input: `nums = [100000], k = 0`
|
|
||||||
- Output: `[100000]`
|
|
||||||
- Explanation:
|
|
||||||
- The sum of the subarray centered at index `0` with radius `0` is: `100000`. `avg[0] = 100000 / 1 = 100000`.
|
|
||||||
|
|
||||||
### Example 3:
|
|
||||||
- Input: `nums = [8], k = 100000`
|
|
||||||
- Output: `[-1]`
|
|
||||||
- Explanation:
|
|
||||||
- `avg[0]` is `-1` because there are less than `k` elements before and after index `0`.
|
|
||||||
|
|
||||||
### Constraints:
|
|
||||||
|
|
||||||
- `n == nums.length`
|
|
||||||
- `1 <= n <= 10^5`
|
|
||||||
- `0 <= nums[i], k <= 10^5`
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
```js
|
|
||||||
/**
|
|
||||||
* @param {number[]} nums
|
|
||||||
* @param {number} k
|
|
||||||
* @return {number[]}
|
|
||||||
*/
|
|
||||||
var getAverages = function(nums, k) {
|
|
||||||
|
|
||||||
};
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
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`
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
---
|
|
||||||
title: '42. Trapping Rain Water'
|
|
||||||
description: Given n non-negative integers representing an elevation map where the width of each bar is 1, compute how much water it can trap after raining
|
|
||||||
sidebar:
|
|
||||||
label: 'Trapping Rain Water'
|
|
||||||
badge: 'Hard'
|
|
||||||
---
|
|
||||||
|
|
||||||
<Badge variant="accent">Two Pointers</Badge>
|
|
||||||
|
|
||||||
### Example 1:
|
|
||||||
- Input: `height = [0,1,0,2,1,0,1,3,2,1,2,1]`
|
|
||||||
- Output: `6`
|
|
||||||
- Explanation: The above elevation map (black section) is represented by array `[0,1,0,2,1,0,1,3,2,1,2,1]`. In this case, `6` units of rain water (blue section) are being trapped.
|
|
||||||
|
|
||||||
### Example 2:
|
|
||||||
- Input: `height = [4,2,0,3,2,5]`
|
|
||||||
- Output: `9`
|
|
||||||
|
|
||||||
### Constraints:
|
|
||||||
|
|
||||||
- `n == height.length`
|
|
||||||
- `1 <= n <= 2 * 10^4`
|
|
||||||
- `0 <= height[i] <= 10^5`
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
```js
|
|
||||||
/**
|
|
||||||
* @param {number[]} height
|
|
||||||
* @return {number}
|
|
||||||
*/
|
|
||||||
var trap = function(height) {
|
|
||||||
|
|
||||||
};
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
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`
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
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`
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
|
||||||
|
ROWS, COLS = len(matrix), len(matrix[0])
|
||||||
|
|
||||||
|
top, bot = 0, ROWS - 1
|
||||||
|
while top <= bot:
|
||||||
|
row = (top + bot) // 2
|
||||||
|
if target > matrix[row][-1]:
|
||||||
|
top = row + 1
|
||||||
|
elif target < matrix[row][0]:
|
||||||
|
bot = row - 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not (top <= bot):
|
||||||
|
return False
|
||||||
|
|
||||||
|
row = (top + bot) // 2
|
||||||
|
l, r = 0, COLS - 1
|
||||||
|
while l <= r:
|
||||||
|
m = (l + r) // 2
|
||||||
|
if target > matrix[row][m]:
|
||||||
|
l = m + 1
|
||||||
|
elif target < matrix[row][m]:
|
||||||
|
r = m - 1
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
```
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
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`
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def carFleet(self, target: int, position: List[int], speed: List[int]) -> int:
|
||||||
|
stack = []
|
||||||
|
for pos, spd in sorted(zip(position, speed), reverse=True):
|
||||||
|
time = (target - pos) / spd
|
||||||
|
if not stack or time > stack[-1]:
|
||||||
|
stack.append(time)
|
||||||
|
|
||||||
|
return len(stack)
|
||||||
|
```
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
# Definition for singly-linked list.
|
||||||
|
# class ListNode:
|
||||||
|
# def __init__(self, x):
|
||||||
|
# self.val = x
|
||||||
|
# self.next = None
|
||||||
|
|
||||||
|
|
||||||
|
class Solution:
|
||||||
|
def hasCycle(self, head: Optional[ListNode]) -> bool:
|
||||||
|
fast = slow = head
|
||||||
|
|
||||||
|
while fast and fast.next:
|
||||||
|
fast = fast.next.next
|
||||||
|
slow = slow.next
|
||||||
|
if fast is slow:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
```
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
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`
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
# Definition for singly-linked list.
|
||||||
|
# class ListNode:
|
||||||
|
# def __init__(self, val=0, next=None):
|
||||||
|
# self.val = val
|
||||||
|
# self.next = next
|
||||||
|
class Solution:
|
||||||
|
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
|
||||||
|
prev = None
|
||||||
|
curr = head
|
||||||
|
while curr:
|
||||||
|
next_ = curr.next
|
||||||
|
curr.next = prev
|
||||||
|
prev = curr
|
||||||
|
curr = next_
|
||||||
|
return prev
|
||||||
|
```
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
# Definition for singly-linked list.
|
||||||
|
# class ListNode:
|
||||||
|
# def __init__(self, val=0, next=None):
|
||||||
|
# self.val = val
|
||||||
|
# self.next = next
|
||||||
|
class Solution:
|
||||||
|
def mergeTwoLists(
|
||||||
|
self, list1: Optional[ListNode], list2: Optional[ListNode]
|
||||||
|
) -> Optional[ListNode]:
|
||||||
|
|
||||||
|
if not list1:
|
||||||
|
return list2
|
||||||
|
if not list2:
|
||||||
|
return list1
|
||||||
|
|
||||||
|
if list1.val < list2.val:
|
||||||
|
head = list1
|
||||||
|
list1 = list1.next
|
||||||
|
else:
|
||||||
|
head = list2
|
||||||
|
list2 = list2.next
|
||||||
|
|
||||||
|
current = head
|
||||||
|
while list1 and list2:
|
||||||
|
if list1.val < list2.val:
|
||||||
|
current.next = list1
|
||||||
|
list1 = list1.next
|
||||||
|
else:
|
||||||
|
current.next = list2
|
||||||
|
list2 = list2.next
|
||||||
|
current = current.next
|
||||||
|
|
||||||
|
current.next = list1 or list2
|
||||||
|
return head
|
||||||
|
```
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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()
|
||||||
|
```
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
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 '()[]{}'.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
@@ -17,7 +17,9 @@ const API_BASE = "https://srs-api.prdlk.workers.dev";
|
|||||||
|
|
||||||
// ── /api/stats response shape ────────────────────────────────────
|
// ── /api/stats response shape ────────────────────────────────────
|
||||||
|
|
||||||
const STAGES = ["new", "+2", "+5", "+10", "retired"] as const;
|
// Mirrors STAGES in apps/api/src/srs.ts — the ladder rungs, in order. The
|
||||||
|
// Worker ships every rung in `ladder`, zeros included.
|
||||||
|
const STAGES = ["new", "+3", "+7", "retired"] as const;
|
||||||
|
|
||||||
type Stage = (typeof STAGES)[number];
|
type Stage = (typeof STAGES)[number];
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ interface Stats {
|
|||||||
deferred_total: number;
|
deferred_total: number;
|
||||||
}[];
|
}[];
|
||||||
ladder: Record<Stage, number>;
|
ladder: Record<Stage, number>;
|
||||||
|
/** week is the 1-based campaign week — the Worker converts from ISO. */
|
||||||
gates: {
|
gates: {
|
||||||
week: number;
|
week: number;
|
||||||
issue: number | null;
|
issue: number | null;
|
||||||
@@ -76,7 +79,12 @@ function dayLabel(iso: string): string {
|
|||||||
return `${weekday} ${iso.slice(8)}`;
|
return `${weekday} ${iso.slice(8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── shared styles (theme tokens only — no hand-rolled palette) ───
|
// ── shared styles ────────────────────────────────────────────────
|
||||||
|
// Theme tokens only, with two deliberate exceptions: the phase-bar sets get
|
||||||
|
// fixed hues (accent-blue core, yellow optional, red deferred — they must be
|
||||||
|
// distinguishable, not shades of one colour), and the last-7-days heatmap
|
||||||
|
// reuses the GREENS ramp from apps/api/src/charts.ts so the page matches the
|
||||||
|
// README/email heatmaps (level 0 stays a theme token so light mode works).
|
||||||
|
|
||||||
const card: CSSProperties = {
|
const card: CSSProperties = {
|
||||||
border: "1px solid var(--blume-border)",
|
border: "1px solid var(--blume-border)",
|
||||||
@@ -142,32 +150,78 @@ function Header({ stats }: { stats: Stats }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One stacked bar per phase: solved core / optional / deferred segments over
|
||||||
|
// a muted track sized by the phase's total problem count, so both phase size
|
||||||
|
// and progress read at a glance.
|
||||||
|
const SETS = [
|
||||||
|
["core", "var(--blume-accent)"],
|
||||||
|
["optional", "#d29922"],
|
||||||
|
["deferred", "#e5534b"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
function Phases({ stats }: { stats: Stats }) {
|
function Phases({ stats }: { stats: Stats }) {
|
||||||
|
const maxTotal = Math.max(
|
||||||
|
1,
|
||||||
|
...stats.phases.map((p) => p.core_total + p.optional_total + p.deferred_total),
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div style={card}>
|
<div style={card}>
|
||||||
<div style={heading}>Phases</div>
|
<div style={heading}>Phases</div>
|
||||||
<table style={{ borderCollapse: "collapse", width: "100%" }}>
|
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap", marginBottom: "0.6rem" }}>
|
||||||
<tbody>
|
{SETS.map(([set, color]) => (
|
||||||
{stats.phases.map((p) => (
|
<span key={set} style={muted}>
|
||||||
<tr key={p.milestone}>
|
<span
|
||||||
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>{p.name}</td>
|
style={{
|
||||||
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
|
display: "inline-block",
|
||||||
<Bar done={p.core_done} total={p.core_total} />{" "}
|
width: "0.65rem",
|
||||||
<span style={muted}>
|
height: "0.65rem",
|
||||||
core {p.core_done}/{p.core_total}
|
borderRadius: "2px",
|
||||||
</span>
|
background: color,
|
||||||
</td>
|
marginRight: "0.35rem",
|
||||||
<td style={{ padding: "0.2rem 0" }}>
|
}}
|
||||||
<span style={muted}>
|
/>
|
||||||
optional {p.optional_done}/{p.optional_total}
|
{set}
|
||||||
{p.deferred_total > 0 &&
|
</span>
|
||||||
` · deferred ${p.deferred_done}/${p.deferred_total}`}
|
))}
|
||||||
</span>
|
</div>
|
||||||
</td>
|
{stats.phases.map((p) => {
|
||||||
</tr>
|
const total = p.core_total + p.optional_total + p.deferred_total;
|
||||||
))}
|
const done = p.core_done + p.optional_done + p.deferred_done;
|
||||||
</tbody>
|
return (
|
||||||
</table>
|
<div
|
||||||
|
key={p.milestone}
|
||||||
|
title={`core ${p.core_done}/${p.core_total} · optional ${p.optional_done}/${p.optional_total} · deferred ${p.deferred_done}/${p.deferred_total}`}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "0.75rem", padding: "0.25rem 0" }}
|
||||||
|
>
|
||||||
|
<span style={{ width: "9.5rem", flexShrink: 0 }}>{p.name}</span>
|
||||||
|
<span style={{ flex: 1 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
width: `${(total / maxTotal) * 100}%`,
|
||||||
|
height: "0.65rem",
|
||||||
|
borderRadius: "var(--blume-radius)",
|
||||||
|
background: "var(--blume-muted)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SETS.map(([set, color]) => {
|
||||||
|
const solved = p[`${set}_done`];
|
||||||
|
return solved > 0 ? (
|
||||||
|
<span
|
||||||
|
key={set}
|
||||||
|
style={{ width: `${(solved / total) * 100}%`, background: color }}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span style={{ ...muted, width: "3.5rem", textAlign: "right", flexShrink: 0 }}>
|
||||||
|
{done}/{total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -224,76 +278,95 @@ function Gates({ stats }: { stats: Stats }) {
|
|||||||
<div style={card}>
|
<div style={card}>
|
||||||
<div style={heading}>Gate log</div>
|
<div style={heading}>Gate log</div>
|
||||||
{stats.gates.length === 0 ? (
|
{stats.gates.length === 0 ? (
|
||||||
<span style={muted}>No gates yet.</span>
|
<span style={muted}>No gates yet — the first weekly review opens Saturday night.</span>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ borderCollapse: "collapse", width: "100%" }}>
|
stats.gates.map((g) => (
|
||||||
<thead>
|
<div
|
||||||
<tr>
|
key={g.week}
|
||||||
{["Week", "Issue", "Pass rate", "Closed"].map((h) => (
|
style={{
|
||||||
<th key={h} style={{ ...muted, textAlign: "left", padding: "0.2rem 1rem 0.2rem 0" }}>
|
display: "flex",
|
||||||
{h}
|
gap: "0.75rem",
|
||||||
</th>
|
alignItems: "baseline",
|
||||||
))}
|
flexWrap: "wrap",
|
||||||
</tr>
|
padding: "0.15rem 0",
|
||||||
</thead>
|
}}
|
||||||
<tbody>
|
>
|
||||||
{stats.gates.map((g) => (
|
<span style={{ width: "4.5rem", flexShrink: 0 }}>Week {g.week}</span>
|
||||||
<tr key={g.week}>
|
{g.issue === null ? (
|
||||||
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>{g.week}</td>
|
<span style={muted}>no review issue</span>
|
||||||
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
|
) : (
|
||||||
{g.issue === null ? (
|
<a
|
||||||
<span style={muted}>—</span>
|
href={`https://github.com/prdlk/leetcode/issues/${g.issue}`}
|
||||||
) : (
|
target="_blank"
|
||||||
<a
|
rel="noreferrer"
|
||||||
href={`https://github.com/prdlk/leetcode/issues/${g.issue}`}
|
>
|
||||||
target="_blank"
|
#{g.issue}
|
||||||
rel="noreferrer"
|
</a>
|
||||||
>
|
)}
|
||||||
#{g.issue}
|
{g.pass_rate === null ? (
|
||||||
</a>
|
<span style={muted}>in progress</span>
|
||||||
)}
|
) : (
|
||||||
</td>
|
<span>
|
||||||
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
|
{percent(g.pass_rate)} passed
|
||||||
{g.pass_rate === null ? (
|
{g.closed_on && <span style={muted}> · closed {g.closed_on}</span>}
|
||||||
<span style={muted}>open</span>
|
</span>
|
||||||
) : (
|
)}
|
||||||
percent(g.pass_rate)
|
</div>
|
||||||
)}
|
))
|
||||||
</td>
|
|
||||||
<td style={{ padding: "0.2rem 0" }}>
|
|
||||||
{g.closed_on ?? <span style={muted}>—</span>}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
|
<div style={{ ...muted, marginTop: "0.4rem" }}>
|
||||||
|
One gate per campaign week — scored when its review issue closes.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GREENS ramp from apps/api/src/charts.ts; level 0 swapped for the theme's
|
||||||
|
// muted token so an empty day looks right in both colour schemes.
|
||||||
|
const GREENS = [
|
||||||
|
"var(--blume-muted)",
|
||||||
|
"#0e4429",
|
||||||
|
"#006d32",
|
||||||
|
"#26a641",
|
||||||
|
"#39d353",
|
||||||
|
];
|
||||||
|
|
||||||
function Recent({ stats }: { stats: Stats }) {
|
function Recent({ stats }: { stats: Stats }) {
|
||||||
return (
|
return (
|
||||||
<div style={card}>
|
<div style={card}>
|
||||||
<div style={heading}>Last 7 days</div>
|
<div style={heading}>Last 7 days</div>
|
||||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
|
||||||
{stats.recent.map((r) => (
|
{stats.recent.map((r) => (
|
||||||
<span
|
<span
|
||||||
key={r.date}
|
key={r.date}
|
||||||
style={{
|
title={`${r.date}: ${r.passes}/${r.attempts} passed`}
|
||||||
border: "1px solid var(--blume-border)",
|
style={{ textAlign: "center" }}
|
||||||
borderRadius: "var(--blume-radius)",
|
|
||||||
padding: "0.35rem 0.6rem",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span style={{ ...muted, display: "block" }}>{dayLabel(r.date)}</span>
|
<span
|
||||||
<strong>{r.passes}</strong>
|
style={{
|
||||||
<span style={muted}>/{r.attempts}</span>
|
display: "block",
|
||||||
|
width: "2.6rem",
|
||||||
|
height: "2rem",
|
||||||
|
borderRadius: "var(--blume-radius)",
|
||||||
|
background: GREENS[Math.min(r.attempts, 4)],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ ...muted, display: "block", marginTop: "0.25rem", fontSize: "0.75em" }}>
|
||||||
|
{dayLabel(r.date)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...muted, marginTop: "0.4rem" }}>passes / attempts</div>
|
<div style={{ ...muted, marginTop: "0.5rem", display: "flex", alignItems: "center", gap: "0.25rem" }}>
|
||||||
|
<span>Less</span>
|
||||||
|
{GREENS.map((color) => (
|
||||||
|
<span
|
||||||
|
key={color}
|
||||||
|
style={{ display: "inline-block", width: "0.65rem", height: "0.65rem", borderRadius: "2px", background: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span>More · colour = attempts, hover for passes</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,14 @@
|
|||||||
"doctor": "cd apps/docs && bun run doctor",
|
"doctor": "cd apps/docs && bun run doctor",
|
||||||
"close-solved": "bun apps/cli/close-solved.ts",
|
"close-solved": "bun apps/cli/close-solved.ts",
|
||||||
"close-topics": "bun apps/cli/close-topics.ts",
|
"close-topics": "bun apps/cli/close-topics.ts",
|
||||||
|
"sync-project-fields": "bun apps/cli/sync-project-fields.ts",
|
||||||
|
"spaced-repetition": "bun apps/cli/spaced-repetition.ts",
|
||||||
"api:dev": "cd apps/api && bun run dev",
|
"api:dev": "cd apps/api && bun run dev",
|
||||||
"api:deploy": "cd apps/api && bun run deploy",
|
"api:deploy": "cd apps/api && bun run deploy",
|
||||||
"api:test": "cd apps/api && bun test src",
|
"api:test": "cd apps/api && bun test src",
|
||||||
"pick": "bun apps/cli/pick.ts",
|
"pick": "bun apps/cli/pick.ts",
|
||||||
"sync": "bun apps/cli/sync.ts",
|
"sync": "bun apps/cli/sync.ts",
|
||||||
|
"submit": "bun apps/cli/submit.ts",
|
||||||
"test": "bun apps/cli/test.ts"
|
"test": "bun apps/cli/test.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
35. Search Insert Position
|
||||||
|
Difficulty: Easy
|
||||||
|
https://leetcode.com/problems/search-insert-position/
|
||||||
|
|
||||||
|
──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
141. Linked List Cycle
|
||||||
|
Difficulty: Easy
|
||||||
|
https://leetcode.com/problems/linked-list-cycle/
|
||||||
|
|
||||||
|
──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Given head, the head of a linked list, determine if the linked list
|
||||||
|
has a cycle in it.
|
||||||
|
|
||||||
|
There is a cycle in a linked list if there is some node in the list
|
||||||
|
that can be reached again by continuously following the next pointer.
|
||||||
|
Internally, pos is used to denote the index of the node that tail's
|
||||||
|
next pointer is connected to. Note that pos is not passed as a
|
||||||
|
parameter.
|
||||||
|
|
||||||
|
Return true if there is a cycle in the linked list. Otherwise, return
|
||||||
|
false.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Follow up: Can you solve it using O(1) (i.e. constant) memory?
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 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
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
206. Reverse Linked List
|
||||||
|
Difficulty: Easy
|
||||||
|
https://leetcode.com/problems/reverse-linked-list/
|
||||||
|
|
||||||
|
──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Given the head of a singly linked list, reverse the list, and return
|
||||||
|
the reversed list.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Follow up: A linked list can be reversed either iteratively or
|
||||||
|
recursively. Could you implement both?
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Definition for singly-linked list.
|
||||||
|
# class ListNode:
|
||||||
|
# def __init__(self, val=0, next=None):
|
||||||
|
# self.val = val
|
||||||
|
# self.next = next
|
||||||
|
class Solution:
|
||||||
|
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
|
||||||
|
prev = None
|
||||||
|
curr = head
|
||||||
|
while curr:
|
||||||
|
next_ = curr.next
|
||||||
|
curr.next = prev
|
||||||
|
prev = curr
|
||||||
|
curr = next_
|
||||||
|
return prev
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
21. Merge Two Sorted Lists
|
||||||
|
Difficulty: Easy
|
||||||
|
https://leetcode.com/problems/merge-two-sorted-lists/
|
||||||
|
|
||||||
|
──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
You are given the heads of two sorted linked lists list1 and list2.
|
||||||
|
|
||||||
|
Merge the two lists into one sorted list. The list should be made by
|
||||||
|
splicing together the nodes of the first two lists.
|
||||||
|
|
||||||
|
Return the head of the merged linked list.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Definition for singly-linked list.
|
||||||
|
# class ListNode:
|
||||||
|
# def __init__(self, val=0, next=None):
|
||||||
|
# self.val = val
|
||||||
|
# self.next = next
|
||||||
|
class Solution:
|
||||||
|
def mergeTwoLists(
|
||||||
|
self, list1: Optional[ListNode], list2: Optional[ListNode]
|
||||||
|
) -> Optional[ListNode]:
|
||||||
|
|
||||||
|
if not list1:
|
||||||
|
return list2
|
||||||
|
if not list2:
|
||||||
|
return list1
|
||||||
|
|
||||||
|
if list1.val < list2.val:
|
||||||
|
head = list1
|
||||||
|
list1 = list1.next
|
||||||
|
else:
|
||||||
|
head = list2
|
||||||
|
list2 = list2.next
|
||||||
|
|
||||||
|
current = head
|
||||||
|
while list1 and list2:
|
||||||
|
if list1.val < list2.val:
|
||||||
|
current.next = list1
|
||||||
|
list1 = list1.next
|
||||||
|
else:
|
||||||
|
current.next = list2
|
||||||
|
list2 = list2.next
|
||||||
|
current = current.next
|
||||||
|
|
||||||
|
current.next = list1 or list2
|
||||||
|
return head
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
20. Valid Parentheses
|
||||||
|
Difficulty: Easy
|
||||||
|
https://leetcode.com/problems/valid-parentheses/
|
||||||
|
|
||||||
|
──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Given a string s containing just the characters '(', ')', '{', '}',
|
||||||
|
'[' and ']', determine if the input string is valid.
|
||||||
|
|
||||||
|
An input string is valid if:
|
||||||
|
|
||||||
|
• Open brackets must be closed by the same type of brackets.
|
||||||
|
|
||||||
|
• Open brackets must be closed in the correct order.
|
||||||
|
|
||||||
|
• Every close bracket has a corresponding open bracket of the same
|
||||||
|
type.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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 '()[]{}'.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
150. Evaluate Reverse Polish Notation
|
||||||
|
Difficulty: Medium
|
||||||
|
https://leetcode.com/problems/evaluate-reverse-polish-notation/
|
||||||
|
|
||||||
|
──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
You are given an array of strings tokens that represents an
|
||||||
|
arithmetic expression in a Reverse Polish Notation.
|
||||||
|
|
||||||
|
Evaluate the expression. Return an integer that represents the value
|
||||||
|
of the expression.
|
||||||
|
|
||||||
|
Note that:
|
||||||
|
|
||||||
|
• The valid operators are '+', '-', '*', and '/'.
|
||||||
|
|
||||||
|
• Each operand may be an integer or another expression.
|
||||||
|
|
||||||
|
• The division between two integers always truncates toward zero.
|
||||||
|
|
||||||
|
• There will not be any division by zero.
|
||||||
|
|
||||||
|
• The input represents a valid arithmetic expression in a reverse
|
||||||
|
polish notation.
|
||||||
|
|
||||||
|
• The answer and all the intermediate calculations can be
|
||||||
|
represented in a 32-bit integer.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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].
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
153. Find Minimum in Rotated Sorted Array
|
||||||
|
Difficulty: Medium
|
||||||
|
https://leetcode.com/problems/find-minimum-in-rotated-sorted-array/
|
||||||
|
|
||||||
|
──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
• [4,5,6,7,0,1,2] if it was rotated 4 times.
|
||||||
|
|
||||||
|
• [0,1,2,4,5,6,7] if it was rotated 7 times.
|
||||||
|
|
||||||
|
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]].
|
||||||
|
|
||||||
|
Given the sorted rotated array nums of unique elements, return the
|
||||||
|
minimum element of this array.
|
||||||
|
|
||||||
|
You must write an algorithm that runs in O(log n) time.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,50 @@
|
|||||||
|
"""
|
||||||
|
739. Daily Temperatures
|
||||||
|
Difficulty: Medium
|
||||||
|
https://leetcode.com/problems/daily-temperatures/
|
||||||
|
|
||||||
|
──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
74. Search a 2D Matrix
|
||||||
|
Difficulty: Medium
|
||||||
|
https://leetcode.com/problems/search-a-2d-matrix/
|
||||||
|
|
||||||
|
──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
You are given an m x n integer matrix matrix with the following two
|
||||||
|
properties:
|
||||||
|
|
||||||
|
• Each row is sorted in non-decreasing order.
|
||||||
|
|
||||||
|
• The first integer of each row is greater than the last integer of
|
||||||
|
the previous row.
|
||||||
|
|
||||||
|
Given an integer target, return true if target is in matrix or false
|
||||||
|
otherwise.
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Solution:
|
||||||
|
def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
|
||||||
|
ROWS, COLS = len(matrix), len(matrix[0])
|
||||||
|
|
||||||
|
top, bot = 0, ROWS - 1
|
||||||
|
while top <= bot:
|
||||||
|
row = (top + bot) // 2
|
||||||
|
if target > matrix[row][-1]:
|
||||||
|
top = row + 1
|
||||||
|
elif target < matrix[row][0]:
|
||||||
|
bot = row - 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not (top <= bot):
|
||||||
|
return False
|
||||||
|
|
||||||
|
row = (top + bot) // 2
|
||||||
|
l, r = 0, COLS - 1
|
||||||
|
while l <= r:
|
||||||
|
m = (l + r) // 2
|
||||||
|
if target > matrix[row][m]:
|
||||||
|
l = m + 1
|
||||||
|
elif target < matrix[row][m]:
|
||||||
|
r = m - 1
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
853. Car Fleet
|
||||||
|
Difficulty: Medium
|
||||||
|
https://leetcode.com/problems/car-fleet/
|
||||||
|
|
||||||
|
──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
There are n cars at given miles away from the starting mile 0,
|
||||||
|
traveling to reach the mile target.
|
||||||
|
|
||||||
|
You are given two integer arrays position and speed, both of length
|
||||||
|
n, where position[i] is the starting mile of the i^th car and speed[i]
|
||||||
|
is the speed of the i^th car in miles per hour.
|
||||||
|
|
||||||
|
A car cannot pass another car, but it can catch up and then travel
|
||||||
|
next to it at the speed of the slower car.
|
||||||
|
|
||||||
|
A car fleet is a single car or a group of cars driving next to each
|
||||||
|
other. The speed of the car fleet is the minimum speed of any car in
|
||||||
|
the fleet.
|
||||||
|
|
||||||
|
If a car catches up to a car fleet at the mile target, it will still
|
||||||
|
be considered as part of the car fleet.
|
||||||
|
|
||||||
|
Return the number of car fleets that will arrive at the destination.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
155. Min Stack
|
||||||
|
Difficulty: Medium
|
||||||
|
https://leetcode.com/problems/min-stack/
|
||||||
|
|
||||||
|
──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Design a stack that supports push, pop, top, and retrieving the
|
||||||
|
minimum element in constant time.
|
||||||
|
|
||||||
|
Implement the MinStack class:
|
||||||
|
|
||||||
|
• MinStack() initializes the stack object.
|
||||||
|
|
||||||
|
• void push(int value) pushes the element value onto the stack.
|
||||||
|
|
||||||
|
• void pop() removes the element on the top of the stack.
|
||||||
|
|
||||||
|
• int top() gets the top element of the stack.
|
||||||
|
|
||||||
|
• int getMin() retrieves the minimum element in the stack.
|
||||||
|
|
||||||
|
You must implement a solution with O(1) time complexity for each
|
||||||
|
function.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Example 1:
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -1,44 +0,0 @@
|
|||||||
/*
|
|
||||||
* 42. Trapping Rain Water
|
|
||||||
* Difficulty: Hard
|
|
||||||
* https://leetcode.com/problems/trapping-rain-water/
|
|
||||||
*
|
|
||||||
* ──────────────────────────────────────────────────
|
|
||||||
*
|
|
||||||
* Given n non-negative integers representing an elevation map where the
|
|
||||||
* width of each bar is 1, compute how much water it can trap after
|
|
||||||
* raining.
|
|
||||||
*
|
|
||||||
*
|
|
||||||
*
|
|
||||||
* Example 1:
|
|
||||||
*
|
|
||||||
* Input: height = [0,1,0,2,1,0,1,3,2,1,2,1]
|
|
||||||
* Output: 6
|
|
||||||
* Explanation: The above elevation map (black section) is represented
|
|
||||||
* by array [0,1,0,2,1,0,1,3,2,1,2,1]. In this case, 6 units of rain
|
|
||||||
* water (blue section) are being trapped.
|
|
||||||
*
|
|
||||||
* Example 2:
|
|
||||||
*
|
|
||||||
* Input: height = [4,2,0,3,2,5]
|
|
||||||
* Output: 9
|
|
||||||
*
|
|
||||||
*
|
|
||||||
*
|
|
||||||
* Constraints:
|
|
||||||
*
|
|
||||||
* • n == height.length
|
|
||||||
*
|
|
||||||
* • 1 <= n <= 2 * 10^4
|
|
||||||
*
|
|
||||||
* • 0 <= height[i] <= 10^5
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number[]} height
|
|
||||||
* @return {number}
|
|
||||||
*/
|
|
||||||
var trap = function(height) {
|
|
||||||
|
|
||||||
};
|
|
||||||
Reference in new issue
Block a user