mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-03 05:27:36 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -28,7 +28,14 @@ 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 }}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ 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
|
||||||
@@ -22,7 +23,9 @@ flowchart LR
|
|||||||
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.
|
||||||
|
- **Solving has three doors, one write path.** A digest one-tap, a `/done` comment, and a solution landing in `work/` all end in `logAttempt()`. Closing an issue is *not* one of them: the catalog reconcile never reads issue state, so a `work/` push that skipped the email would otherwise leave `stage='new'` — invisible to the charts, the digest's solved ticks, and the drill/gate pools. Hence `/admin/solved` (`source='commit'`, first solve only, no index needed for idempotency: it refuses any problem past stage `new`). `close-topics` needs no such call — D1 stores no topic completion, only problem rows.
|
||||||
|
- **The README charts are live, never committed.** They are Worker endpoints reading D1 per request (`Cache-Control: max-age=300`, honored by GitHub Camo), so a solved push moves them within ~5 minutes with no commit and no workflow of their own.
|
||||||
- **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). Project fields (`Target Date`, `SRS Stage`, `First Attempt`) are a best-effort mirror (`apps/api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written.
|
- **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). 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.
|
||||||
- **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`.
|
||||||
@@ -36,19 +39,27 @@ flowchart LR
|
|||||||
| `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/`, `scripts/import-srs.ts` |
|
||||||
| `.github/workflows/` | deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved, close-topics, sync-d1 (issue edits → `/admin/reconcile`) |
|
| `.github/workflows/` | deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved (issues + `/admin/solved`, needs `SRS_ADMIN_KEY`), close-topics, sync-d1 (issue edits → `/admin/reconcile`) |
|
||||||
|
|
||||||
## 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; hides problems
|
||||||
bun run test # run ONE solution against LeetCode's judge (not a test suite)
|
# already under work/, caches index+descriptions 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/ → apps/docs/content/ pages
|
bun run sync # work/ → apps/docs/content/ pages
|
||||||
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
|
||||||
|
# close-solved also pushes the solved set to the Worker; point it at a local
|
||||||
|
# one and nothing production is touched:
|
||||||
|
SRS_API=http://localhost:8787 SRS_ADMIN_KEY=$LINK_KEY bun run close-solved -- --dry-run
|
||||||
bun run api:dev # wrangler dev on :8787 (local D1); api:test = DST guard tests
|
bun run api:dev # wrangler dev on :8787 (local D1); api:test = DST guard tests
|
||||||
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" \
|
||||||
@@ -67,7 +78,7 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
|||||||
|
|
||||||
## 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: ladder (`new → +2 → +5 → +10 → retired`; fail resets to `+2`; first-ever log enters at `+2`), ET date math, seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future date, ≤ 3 per day, oldest first, instead of letting the due pile grow.
|
||||||
- `apps/api/src/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.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
<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>
|
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">
|
||||||
|
|||||||
+10
-3
@@ -40,6 +40,7 @@ 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>` | `{"lc":[…]}` — solutions committed under `work/`; logs each *first* solve (`source='commit'`), skips anything past stage `new`, takes `?dry=1&date=` |
|
||||||
| `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 +60,15 @@ 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
|
Two Actions workflows push into D1 with the `SRS_ADMIN_KEY` repo secret:
|
||||||
edits into D1 immediately (`POST /admin/reconcile` with the
|
`sync-d1.yml` sends curriculum issue edits to `POST /admin/reconcile`
|
||||||
`SRS_ADMIN_KEY` repo secret); the morning cron is the backstop.
|
(the morning cron is the backstop), and `close-solved.yml` sends the whole
|
||||||
|
implemented `work/` set to `POST /admin/solved` on every push to `main`.
|
||||||
|
That second call is what keeps a committed solution from being invisible
|
||||||
|
here: closing its issue is not a state change the Worker can see — the
|
||||||
|
catalog reconcile ignores issue state, and only `logAttempt()` moves the
|
||||||
|
ladder. `close-solved` still owns the issue close itself (it knows the files
|
||||||
|
and the commit), so this path only writes D1 and mirrors Project fields.
|
||||||
|
|
||||||
## Local dev
|
## Local dev
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+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" },
|
||||||
|
|||||||
+63
-19
@@ -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";
|
||||||
@@ -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,
|
||||||
@@ -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}
|
||||||
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
+59
-4
@@ -22,6 +22,7 @@ import {
|
|||||||
daysBetween,
|
daysBetween,
|
||||||
etDate,
|
etDate,
|
||||||
etHour,
|
etHour,
|
||||||
|
getProblem,
|
||||||
logAttempt,
|
logAttempt,
|
||||||
weekdayOf,
|
weekdayOf,
|
||||||
} from "./srs.ts";
|
} from "./srs.ts";
|
||||||
@@ -29,6 +30,13 @@ import { buildStats } from "./stats.ts";
|
|||||||
|
|
||||||
// ── shared side effects after a state write ──────────────────────
|
// ── shared side effects after a state write ──────────────────────
|
||||||
|
|
||||||
|
/** Project-field half of a mirror; shared by every write path. */
|
||||||
|
async function mirrorFields(mirror: Mirror, outcome: LogOutcome): Promise<void> {
|
||||||
|
await mirror.setStage(outcome.issue, outcome.stage);
|
||||||
|
await mirror.setTargetDate(outcome.issue, outcome.next_review);
|
||||||
|
if (outcome.first) await mirror.setFirstAttempt(outcome.issue, outcome.result);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirror one logged attempt into GitHub: Project fields always; on a
|
* Mirror one logged attempt into GitHub: Project fields 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
|
||||||
@@ -37,10 +45,7 @@ import { buildStats } from "./stats.ts";
|
|||||||
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]!);
|
await mirrorFields(projectMirror(gh, env.REPO.split("/")[0]!), outcome);
|
||||||
await mirror.setStage(outcome.issue, outcome.stage);
|
|
||||||
await mirror.setTargetDate(outcome.issue, outcome.next_review);
|
|
||||||
if (outcome.first) await mirror.setFirstAttempt(outcome.issue, outcome.result);
|
|
||||||
if (outcome.first && outcome.result === "pass") {
|
if (outcome.first && outcome.result === "pass") {
|
||||||
try {
|
try {
|
||||||
await gh.closeIssue(
|
await gh.closeIssue(
|
||||||
@@ -200,6 +205,55 @@ 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: logAttempt ignores anything
|
||||||
|
* already on the ladder, so this is a total recompute like the reconcilers
|
||||||
|
* that call it — a re-run changes nothing.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
async function handleSolved(request: Request, env: Env, date: string): Promise<Response> {
|
||||||
|
const body: unknown = await request.json().catch(() => null);
|
||||||
|
const raw: unknown = body && typeof body === "object" && "lc" in body ? body.lc : null;
|
||||||
|
if (!Array.isArray(raw)) return new Response('expected {"lc": [<number>, ...]}', { status: 400 });
|
||||||
|
const lcs: number[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
const lc: unknown = item;
|
||||||
|
if (typeof lc !== "number" || !Number.isFinite(lc)) {
|
||||||
|
return new Response(`not an LC number: ${JSON.stringify(lc)}`, { status: 400 });
|
||||||
|
}
|
||||||
|
lcs.push(lc);
|
||||||
|
}
|
||||||
|
const dry = new URL(request.url).searchParams.get("dry") === "1";
|
||||||
|
|
||||||
|
const logged: string[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
let mirror: Mirror | undefined;
|
||||||
|
for (const lc of lcs) {
|
||||||
|
if (dry) {
|
||||||
|
const p = await getProblem(env.DB, lc);
|
||||||
|
if (!p) skipped.push(`LC ${lc} is not in the curriculum`);
|
||||||
|
else if (p.stage !== "new") skipped.push(`LC ${lc}: already at stage ${p.stage}`);
|
||||||
|
else logged.push(`LC ${lc}: would enter the ladder at +2`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit" });
|
||||||
|
if (outcome.error) skipped.push(outcome.error);
|
||||||
|
else if (outcome.duplicate) skipped.push(`LC ${lc}: already at stage ${outcome.stage}`);
|
||||||
|
else {
|
||||||
|
logged.push(outcomeLine(outcome));
|
||||||
|
// One mirror for the batch: field IDs resolve once, not per problem.
|
||||||
|
mirror ??= projectMirror(github(env.GH_PAT, env.REPO), env.REPO.split("/")[0]!);
|
||||||
|
await mirrorFields(mirror, outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 +263,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",
|
||||||
|
|||||||
+51
-3
@@ -165,6 +165,31 @@ export async function dueReviews(db: D1Database, date: string): Promise<ProblemR
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reviews surfaced per day; the digest levels everything past this forward. */
|
||||||
|
export const REVIEW_CAP = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load-level an overloaded review queue: everything due beyond today's
|
||||||
|
* REVIEW_CAP is pushed to a concrete future date — at most REVIEW_CAP per
|
||||||
|
* day, oldest first — instead of piling up as "due today". Runs every digest
|
||||||
|
* morning, so a future day that grows past the cap (spill plus newly
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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(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 +267,25 @@ 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: a commit records a FIRST solve only, so the
|
||||||
|
* stage check below turns every re-push into a no-op.
|
||||||
*/
|
*/
|
||||||
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";
|
||||||
|
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 +300,20 @@ 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 only ever reports a first solve: pushing the file again — or the
|
||||||
|
// reconciler re-sending the whole solved set — must never move the ladder,
|
||||||
|
// and must never wake a retired problem. Reviews come from the digest tap,
|
||||||
|
// a /done comment, or a gate.
|
||||||
|
if (opts.source === "commit" && p.stage !== "new") {
|
||||||
|
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` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
addDays,
|
addDays,
|
||||||
campaignDay,
|
campaignDay,
|
||||||
campaignWeek,
|
campaignWeek,
|
||||||
|
isoWeek,
|
||||||
streak,
|
streak,
|
||||||
} from "./srs.ts";
|
} from "./srs.ts";
|
||||||
|
|
||||||
@@ -61,9 +62,14 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
|||||||
const ladder: Record<string, number> = { new: 0, "+2": 0, "+5": 0, "+10": 0, retired: 0 };
|
const ladder: Record<string, number> = { new: 0, "+2": 0, "+5": 0, "+10": 0, retired: 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 }[] = [];
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -11,10 +12,12 @@
|
|||||||
* 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().
|
||||||
*
|
*
|
||||||
* 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 { basename, join } from "node:path";
|
||||||
|
|
||||||
@@ -193,24 +196,82 @@ 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.
|
||||||
|
//
|
||||||
|
// The whole implemented set goes over, not just this run's closes: the
|
||||||
|
// Worker drops anything already on the ladder, so the call is a total
|
||||||
|
// recompute and backfills whatever earlier runs missed.
|
||||||
|
const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
||||||
|
const solved = [...work.entries()]
|
||||||
|
.filter(([, entry]) => entry.implemented)
|
||||||
|
.map(([lc]) => lc)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
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({ lc: 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} in work/ · ${solved.length} implemented · ` +
|
||||||
`${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} files in \`work/\`, ${solved.length} implemented, ` +
|
||||||
`${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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
+54
-52
@@ -1,70 +1,72 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Fuzzy-pick a LeetCode problem and scaffold it via leetcode-cli.
|
* Fuzzy-pick a LeetCode problem and scaffold it via leetcode-cli.
|
||||||
* Problem index is fetched from leetcode.com and cached for 24h in .cache/.
|
*
|
||||||
|
* The problem index lives in the local SQLite cache (see db.ts), refreshed at
|
||||||
|
* most once per 24h; campaign problem descriptions from README.md are
|
||||||
|
* backfilled into the same db on the way. Problems that already have a
|
||||||
|
* numbered file under work/ are excluded from the picker — re-picking them
|
||||||
|
* would only clobber the existing scaffold.
|
||||||
*/
|
*/
|
||||||
import { autocomplete, cancel, isCancel, spinner } from "@clack/prompts";
|
import { autocomplete, cancel, isCancel, spinner } from "@clack/prompts";
|
||||||
import { mkdir } from "node:fs/promises";
|
import { basename, join } from "node:path";
|
||||||
import { join } from "node:path";
|
import { fetchDescription, problemDb, readmeSlugs, type Problem } from "./db";
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
const CACHE_FILE = join(ROOT, ".cache", "leetcode-problems.json");
|
const WORK_DIR = join(ROOT, "work");
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
interface Problem {
|
const db = problemDb();
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
slug: string;
|
|
||||||
difficulty: "Easy" | "Medium" | "Hard";
|
|
||||||
paidOnly: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
|
// ── problem index (24h TTL) ──────────────────────────────────────
|
||||||
|
|
||||||
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 missing = db.missingDescriptions(readmeSlugs(await Bun.file(join(ROOT, "README.md")).text()));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
const s = spinner();
|
||||||
|
s.start(`Caching ${missing.length} problem descriptions`);
|
||||||
|
let fetched = 0;
|
||||||
|
let failed = 0;
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 6 }, async () => {
|
||||||
|
for (let slug = missing.shift(); slug; slug = missing.shift()) {
|
||||||
|
try {
|
||||||
|
const html = await fetchDescription(slug);
|
||||||
|
if (html !== null) db.saveDescription(slug, html);
|
||||||
|
s.message(`Caching problem descriptions (${++fetched})`);
|
||||||
|
} catch {
|
||||||
|
failed++; // left NULL — retried on the next run
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
s.stop(failed ? `Cached ${fetched} descriptions (${failed} failed, will retry next run)` : `Cached ${fetched} problem descriptions`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── exclude problems already picked or solved under work/ ────────
|
||||||
|
|
||||||
|
const done = new Set<number>();
|
||||||
|
for (const rel of new Bun.Glob("**/*.*").scanSync({ cwd: WORK_DIR })) {
|
||||||
|
const m = basename(rel).match(/^(\d+)\./);
|
||||||
|
if (m) done.add(Number(m[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const problems = db.all().filter((p) => !done.has(p.id));
|
||||||
|
db.close();
|
||||||
|
|
||||||
const picked = await autocomplete<Problem>({
|
const picked = await autocomplete<Problem>({
|
||||||
message: "Pick a problem",
|
message: "Pick a problem",
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Shared work/ solution picker for the test and submit entries: fuzzy-select
|
||||||
|
* a solution file, then hand it to leetcode-cli.
|
||||||
|
*
|
||||||
|
* 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";
|
||||||
|
|
||||||
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
|
const WORK_DIR = join(ROOT, "work");
|
||||||
|
|
||||||
|
/** Leading LC number of a work/ file, or undefined for unnumbered names. */
|
||||||
|
function lcNumber(rel: string): number | undefined {
|
||||||
|
const m = basename(rel).match(/^(\d+)\./);
|
||||||
|
return m ? Number(m[1]) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
|
||||||
|
export async function runOnSolution(command: "test" | "submit", message: string): Promise<never> {
|
||||||
|
const 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) => {
|
||||||
|
// 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"),
|
||||||
|
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);
|
||||||
|
}
|
||||||
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?");
|
||||||
+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,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,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,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
|
||||||
|
```
|
||||||
@@ -35,6 +35,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 +77,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 +148,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 +276,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[2026-08-26T19:15:12.457Z] [SHUTDOWN] before-quit fired
|
||||||
|
[2026-08-26T19:15:12.458Z] [SHUTDOWN] shutdown started
|
||||||
|
[2026-08-26T19:15:12.458Z] [SHUTDOWN] Stopping local daemon server early
|
||||||
|
[2026-08-26T19:15:12.458Z] [SHUTDOWN] Phase 1: sending Ctrl+C to all terminals
|
||||||
|
[2026-08-26T19:15:12.459Z] [SHUTDOWN] Signaled 1 terminals: __pane_chat_terminal__
|
||||||
|
[2026-08-26T19:15:12.659Z] [SHUTDOWN] Sent second Ctrl+C, waiting 2s...
|
||||||
|
[2026-08-26T19:15:14.660Z] [SHUTDOWN] 2s wait complete
|
||||||
|
[2026-08-26T19:15:14.660Z] [SHUTDOWN] Phase 2: saving terminal states
|
||||||
|
[2026-08-26T19:15:14.670Z] [SHUTDOWN] ERROR during shutdown: BoundaryDecodeError: input.wasInterrupted: expected JSON value
|
||||||
|
[2026-08-26T19:15:14.671Z] [SHUTDOWN] Calling app.exit(0)
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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) {
|
|
||||||
|
|
||||||
};
|
|
||||||
@@ -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]
|
||||||
@@ -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,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()
|
||||||
Reference in new issue
Block a user