18 KiB
Repository Guidelines
Project Overview
LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under work/, a Blume docs site under apps/docs/, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (apps/api/) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 topic issues (Topic NN — Name) own ~161 problem sub-issues titled LC <num> · <Name>, with difficulty and set carried only by labels (set:core|optional|deferred, diff:*) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
Architecture & Data Flow
Three owners, one direction of truth:
flowchart LR
work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
work -->|close-solved.yml| PI[problem issues]
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
PI -->|close-topics.yml| TI[topic issues]
GH[GitHub issues = catalog] -->|nightly reconcile| D1
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
Mail -->|GET /log| D1
PI -->|/done webhook| D1
D1 -->|best-effort mirror: Target Date only| Proj[GitHub Project fields]
GH -->|close-solved.yml sync-project-fields| Proj
D1 -->|Sat midnight ET| Review[Review — Week N issue]
PI -->|"spaced-repetition.yml (6 AM ET cron, closed +3d/+7d)"| SR[Spaced Repetition — Month D issue]
D1 --> Charts[SVG charts + /api/stats]
Charts -->|bun run stats| TUI[terminal dashboard]
- Reconcile, don't react.
close-solved.ts,close-topics.ts, and the Worker's catalog sync recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional.close-solvedposts its whole implemented set to/admin/solved, not just the issues it closed this run. - Solving has three doors, one write path. A digest one-tap, a
/donecomment, and a solution landing inwork/all end inlogAttempt(). Closing an issue is not one of them: the catalog reconcile never reads issue state, so awork/push that skipped the email would otherwise leavestage='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/1settlesnew,work/3settles+3,work/7settles+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-topicsneeds 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 ofworkingDay()inapps/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 bareaddDays(), and never "fix" a Sunday landing downstream — the digest's Sunday rest branch is a courtesy, not the mechanism.apps/api/src/srs.test.tssweeps 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 andDUE_WHEREis the single SQL definition of "due", shared by the digest, the review issue and the docs queue chart.spaced-repetition.ymlrenders that answer into today'sSpaced 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. WithoutSRS_ADMIN_KEYit 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.jsonand friends) or a second "when is this due" rule: D1 answers it, with one writer (logAttempt) and git-free history inattempts. - 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) andbun run pickhas three sections — Tab cycles them. Pools are reconciled from the filesystem plus onegit log(apps/cli/work.ts): a first solve dates from the ET date of the commit that added itswork/1file — 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'sWINDOW_CAP: that issue is one day's assignment, the picker is everything still owed. An empty scaffold underwork/1owes nothing (it is unsolved), which is whyisImplemented()lives inapps/cli/source.tsand is shared withclose-solved.ts. leetcode-cli reads its output directory from~/.leetcode/workspaces/<active>/config.jsonand nowhere else — no flag, no env var — sopick.tsMERGESworkDirinto that file around the child (never replays saved bytes: the CLI owns the file too) and leaves it pointed atwork/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, viaclose-solved→/admin/solvedwith 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.jsonowns the calendar. The catalog reconcile never invents rows and never overwrites SRS columns (stage,next_review).Target Dateis the only Project field the Worker mirrors (apps/api/src/mirror.ts): failures are warnings, never lost D1 writes; topic rows'Target Dateis never written. SRS stage and first-attempt result live only in D1 — do not re-add them as Project fields. The Project'sSet/Difficultysingle-selects are a projection of the issue labels, reconciled bybun run sync-project-fields— a best-effort final step ofclose-solved.yml, never the Worker. - Determinism = idempotency. Drill/gate sampling uses a seeded PRNG (
rng()inapps/api/src/srs.ts, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date inemail_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 inapps/api/src/srs.test.ts. - Chained workflows:
GITHUB_TOKEN-driven issue closes fire noissuesevents, soclose-topics.ymlchains offworkflow_runof Close Solved instead. The Worker's webhook uses its ownGH_PAT, so its events flow normally.
Key Directories
| Path | Purpose |
|---|---|
work/<1|3|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py} |
Solutions, e.g. work/1/Easy/Array/1.two-sum.py. The top directory is the spaced-repetition bucket: 1 = first solve, 3/7 = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / Difficulty: / URL / ─ rule / statement) — preserve its exact shape |
apps/cli/ |
Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root bun run scripts delegate here |
apps/docs/ |
Blume site (blume.config.ts, content/, islands/, public/). content/(<category>)/<num>-<slug>.mdx generated by sync |
apps/api/ |
Cloudflare Worker workspace: src/ modules, data/schedule.json (day → topic issue, human-edited, bundled at deploy), migrations/ (append-only; 0002 is the +3/+7 ladder rebuild) |
.github/workflows/ |
deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved (issues + /admin/solved + Project Set/Difficulty, needs SRS_ADMIN_KEY and PROJECT_PAT), close-topics, sync-d1 (issue edits → /admin/reconcile), spaced-repetition (daily 06:00 ET cron → today's review issue; GITHUB_TOKEN only, no secrets) |
Development Commands
All from the repo root (a Bun workspace over apps/*):
bun run pick # scaffold a solution via leetcode-cli (fuzzy picker; Tab cycles the
# sections: new problems → work/1, then the owed 3-day and 7-day
# re-solves → work/3, work/7. Each section hides what its own bucket
# already holds; index+descriptions cached in
# ~/.local/share/leetcode/data.db — see apps/cli/db.ts)
bun run stats # terminal dashboard over GET /api/stats — four panes (Overview,
# Ladder, Concepts, Activity); Tab/1-4 switches, ↑↓ scrolls,
# r refetches, q quits. Outside a TTY it prints every pane once.
# SRS_API points it at a local Worker like close-solved does.
bun run test # run ONE solution against LeetCode's judge (not a test suite;
# most recently tested first, stamped in the db on exit 0)
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
# hidden; last_submitted stamped in the db on exit 0)
bun run sync # work/1 → apps/docs/content/ pages (re-solves are never published)
bun run dev|build # Blume docs site (runs in apps/docs/)
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
bun run close-topics -- --dry-run
# close-solved also pushes the solved set to the Worker; point it at a local
# one and nothing production is touched:
SRS_API=http://localhost:8787 SRS_ADMIN_KEY=$LINK_KEY bun run close-solved -- --dry-run
# Project Set/Difficulty from the issue labels (needs a `project`-scoped token;
# runs as the last step of close-solved.yml in CI):
GH_TOKEN=$PROJECT_PAT bun run sync-project-fields -- --dry-run
# today's review issue from the +3d/+7d closes (--date backfills a missed day;
# a Sunday date writes nothing — the rest day has no assignment):
bun run spaced-repetition -- --dry-run --date=2026-08-31
bun run api:dev # wrangler dev on :8787 (local D1)
bun run api:test # DST guards + the rest-day calendar sweep
bun run api:deploy # deploy the Worker by hand (CI also deploys on main pushes)
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
"localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing
Code Conventions & Common Patterns
- Scripts are either entries or libraries. Entries (
apps/cli/close-*.ts) use shebang + top-levelawait. Libraries (apps/cli/github.ts,apps/cli/report.ts, everything inapps/api/src/) are side-effect-free on import — nothing touches network/credentials until a factory (github(),projectMirror()) is called. - Shared plumbing:
github()givesrepo,api()(throwsMETHOD path -> status body),list()(paginating async generator),closeIssue()(comment first, then close — a failed PATCH still leaves a trace).report.tsgivesprintTable/markdownTable/writeStepSummary. - Narrow API payloads with guards, not inline casts:
if (!("number" in value) || typeof value.number !== "number") return;(seereadProblemIssueinclose-solved.ts). Named-const casts only with a reason. - Dry-run everything: reconcilers take
--dry-run/DRY_RUN=1; Worker admin routes take?dry=1&force=1&date=(theSRS_TODAYequivalent) andwrangler devruns against local D1 — exercise logic there before touching production state. - Dates: always ET calendar strings (
YYYY-MM-DD), arithmetic anchored at noon UTC (atNooninapps/api/src/srs.ts) to dodge DST. Nevernew Date()math directly. - Style: section-divider comments (
// ── name ───), file-top doc comments explaining the why and invariants, 2-space JSON with trailing newline,Mapfor dynamic keys /Recordfor static tables, no tiny one-expression wrapper functions. - Known intentional duplication:
sync.tskeeps its own header split instead of sharingapps/cli/source.ts, becausesync.tsruns its whole pipeline on import and so cannot be imported from. Don't "deduplicate" it.close-solved.tsandwork.tsdo share it — the stub test decides both "close the issue" and "owes a review", and the two must never disagree.
Important Files
apps/api/src/srs.ts— SRS domain: the ladder (new → +3 → +7 → retired; fail resets to+3; first-ever log enters at+3),WINDOWS/STAGES/TEMPERATURES/DIFFICULTIES/SETS(the one vocabulary shared by the charts, thework/<n>buckets, the review issues and the stats dashboard), ET date math,workingDay()(no scheduled date is ever a Sunday),temperatureOf()(days since a concept was last exercised →hot/fresh/fading/cold, bands derived fromWINDOWSso they cannot drift; read-side only, nothing is scheduled off it), seeded sampling,logAttempt()(the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at mostREVIEW_CAP(3) reviews surface per day —levelReviews()(run bysendDigeston 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; secretsGH_PAT/WEBHOOK_SECRET/LINK_KEYviawrangler secret put.apps/api/src/digest.ts/apps/api/src/email.tsx— the daily digest, split data/presentation.digest.tsreads D1 into aDigestData;email.tsxowns every colour and every sentence, and renders both the HTML and (via its ownplainDigest, not React Email'splainTextmode, which flattens the tables) the text alternative. The retrieval rules hold by construction:DigestRowhas 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 viaCompressionStream("deflate"), CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so/chart/heatmap.pngrasters the heatmap for the digest while/chart/heatmap.svgkeeps serving the README byte-for-byte. Both come from oneheatmapCells()so the two pictures cannot drift.apps/cli/sync.ts— work→docs contract: only## Solutiononward is script-owned on existing pages; human prose is never touched; never hand-write solution pages or edit inside## Solution.apps/cli/work.ts— thework/layout: bucket paths,isSolution()/solvedInBucket()(the stub test, shared withclose-solved.ts),firstSolved()(onegit log, ET dates, keyed by LC number so past layout moves don't matter), andreviewQueues()(what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.apps/api/src/stats.ts/apps/cli/stats.ts/apps/cli/tui.ts/apps/cli/api.ts— the read side.buildStats()is the ONE aggregation: phases, ladder, difficulty, per-topic temperature, gates, the 14-day due queue, andheat(every campaign day, zeros and future days included — the docs page windows the tail off it rather than asking a second question). Two readers consume it, the docs island andbun run stats, so its shape is both their contract: change all three together.tui.tsis hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped orNO_COLORdashboard still reads. The TUI recomputes nothing: the only local fact it shows is thework/solution count, which is inventory, not schedule.README.mdlive charts are Worker endpoints (/chart/*.svg,/badge/gate.svg, 5-min Camo cache); the docs/progresspage fetches/api/statsclient-side (apps/docs/islands/ProgressDashboard.tsx).apps/docs/blume.config.ts— site base/leetcode;README.mdcampaign table doubles as topic-map input tosync.ts(readmeTopics(),TOPIC_ALIASES).- Old
.github/srs/JSON state, itssrs-*Actions, and the one-shotimport-srs.tsimporter are RETIRED and deleted — do not resurrect; git history is the record.
Runtime/Tooling Preferences
- Bun only: run scripts with
bun apps/cli/<name>.ts(or the rootbun runaliases), install withbun install --frozen-lockfile. One workspace (apps/*), one rootbun.lock. Node 22 appears only indeploy.ymlbecause Blume requires it. - Near-zero runtime npm dependencies — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins +
fetchonly. The one exception is the digest email:apps/api/src/email.tsxrenders React Email (react,react-dom,@react-email/components,@react-email/render) inside the Worker, because hand-rolling table-based email HTML that survives Gmail and Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs nonodejs_compat(it resolves torenderToReadableStream), and requires"jsx": "react-jsx"inapps/api/tsconfig.json. Do not extend this exception to any other module. - Local GitHub auth falls back to
gh auth token; scripts run fine outside Actions.
Testing & QA
- Repo-wide: no test framework —
bun run testsubmits one solution to LeetCode's judge. Exception:apps/api/src/srs.test.ts(bun:test) proves the DST cron guards with fixed dates; run viabun run api:test. - QA is dry-runs + local state: Worker logic against
wrangler dev+ local D1 with?dry=1&date=; reconcilers with--dry-run; idempotency check = run twice,cmpoutput. - Docs changes:
bunx blume build --isolated(fromapps/docs/) must pass with no new warnings (thenrm -rf apps/docs/.blume-verify); after sync, review only created pages (prose transforms are heuristic).