Compare commits

...
Author SHA1 Message Date
Prad Nukala 9b5879b6eb feat(work): add solutions for 150.evaluate-reverse-polish-notation and 155.min-stack, remove 42.trapping-rain-water 2026-08-27 15:21:16 -04:00
Prad Nukala 53136b076c docs(docs): delete obsolete array problem pages 2026-08-27 15:21:13 -04:00
Prad Nukala 95f10980d4 docs(docs): add MDX documentation pages for problems 150 (Evaluate Reverse Polish Notation) and 155 (Min Stack) 2026-08-27 15:19:48 -04:00
Prad Nukala c6ecb85507 feat(work): add valid parentheses solution 2026-08-27 14:31:36 -04:00
Prad Nukala 2a69f36e6a docs(docs): add Valid Parentheses solution page 2026-08-27 14:31:32 -04:00
Prad Nukala 5e4628b20f docs(docs): revamp progress dashboard UI and add week field comment 2026-08-27 11:05:05 -04:00
Prad Nukala 72a8399e6d fix(api): adjust gate week handling to use campaign weeks 2026-08-27 11:05:03 -04:00
Prad Nukala 6ebca68ff8 docs(agents): clarify test and submit command behavior in AGENTS.md 2026-08-26 15:51:07 -04:00
Prad Nukala 957ddf9849 feat(work): add solution for problem 35 search-insert-position 2026-08-26 15:51:04 -04:00
Prad Nukala e67cc79f39 docs(docs): add MDX documentation for problems 153 and 35 2026-08-26 15:51:01 -04:00
Prad Nukala f086c2b39a feat(cli): add solution activity tracking and ordering for test/submit commands 2026-08-26 15:50:58 -04:00
Prad Nukala 1b5bdc4ceb chore(logs): add backend shutdown debug log file 2026-08-26 15:25:48 -04:00
Prad Nukala 22ebb82e55 docs(agents): enhance bun run pick description and add bun run submit command 2026-08-26 15:25:44 -04:00
Prad Nukala 180134597d feat(work): add solution for Find Minimum in Rotated Sorted Array 2026-08-26 15:25:41 -04:00
Prad Nukala b669e97c3f feat(config): add submit script command 2026-08-26 15:25:38 -04:00
Prad Nukala d57d75efcc feat(cli): add SQLite cache, shared solution runner, and submit command 2026-08-26 15:25:35 -04:00
Prad Nukala f1445a071f feat(work): add solution for LeetCode 74 Search a 2D Matrix problem 2026-08-26 14:29:14 -04:00
Prad Nukala bb8aa00175 docs(docs): add documentation page for 74. Search a 2D Matrix problem 2026-08-26 14:29:11 -04:00
Prad Nukala 6df1e0e620 docs(agents): add overload‑leveling details to SRS description in AGENTS.md 2026-08-26 10:34:29 -04:00
Prad Nukala 1b4ad8ca5b feat(api): enforce REVIEW_CAP, level overflow reviews, and display difficulty in digest 2026-08-26 10:34:24 -04:00
Prad Nukala f697a986b8 docs(readme): clarify close-solved solved posting and live chart updates 2026-08-25 15:45:20 -04:00
Prad Nukala de5101b4d1 feat(cli): report solved set to SRS worker and log sync status 2026-08-25 15:44:46 -04:00
Prad Nukala 3068b4e4aa ci(ci): add SRS_ADMIN_KEY env var and documentation to CI workflows 2026-08-25 15:44:43 -04:00
Prad Nukala e4de74c935 mise ~/.config/mise/config.toml tools: crush@0.91.0
feat(api): add /admin/solved endpoint and commit source handling
2026-08-25 15:43:21 -04:00
38 changed files with 1672 additions and 403 deletions

No files matched your search

+7
View File
@@ -28,7 +28,14 @@ jobs:
- uses: oven-sh/setup-bun@v2
# 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
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ inputs.dry_run }}
SRS_ADMIN_KEY: ${{ secrets.SRS_ADMIN_KEY }}
+5
View File
@@ -1,5 +1,10 @@
name: Close Topics
# No D1 push here, unlike Close Solved: D1 stores no topic completion column.
# Phase progress, the gate pools and the charts all derive from the problem
# rows, which close-solved already logs — a topic is finished exactly when its
# core problems are, so there is nothing left to reconcile.
on:
# Close Solved closes problem issues with GITHUB_TOKEN, and token-driven
# closes never fire `issues` events — so chain off its run instead.
+17 -6
View File
@@ -12,8 +12,9 @@ Three owners, one direction of truth:
flowchart LR
work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
work -->|close-solved.yml| PI[problem issues]
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
PI -->|close-topics.yml| TI[topic issues]
GH[GitHub issues = catalog] -->|nightly reconcile| D1[(D1 = SRS state)]
GH[GitHub issues = catalog] -->|nightly reconcile| D1
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
Mail -->|GET /log| D1
PI -->|/done webhook| D1
@@ -22,7 +23,9 @@ flowchart LR
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.
- **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`.
@@ -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/docs/` | Blume site (`blume.config.ts`, `content/`, `islands/`, `public/`). `content/(<category>)/<num>-<slug>.mdx` generated by sync |
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (day → topic issue, human-edited, bundled at deploy), `migrations/`, `scripts/import-srs.ts` |
| `.github/workflows/` | deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved, 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
All from the repo root (a Bun workspace over `apps/*`):
```sh
bun run pick # scaffold a solution via leetcode-cli (fuzzy picker)
bun run test # run ONE solution against LeetCode's judge (not a test suite)
bun run pick # scaffold a solution via leetcode-cli (fuzzy picker; hides problems
# 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 dev|build # Blume docs site (runs in apps/docs/)
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
bun run close-topics -- --dry-run
# close-solved also pushes the solved set to the Worker; point it at a local
# one and nothing production is touched:
SRS_API=http://localhost:8787 SRS_ADMIN_KEY=$LINK_KEY bun run close-solved -- --dry-run
bun run api:dev # wrangler dev on :8787 (local D1); api:test = DST guard tests
bun run api:deploy # deploy the Worker by hand (CI also deploys on main pushes)
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
@@ -67,7 +78,7 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
## 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/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.
+1 -1
View File
@@ -10,7 +10,7 @@
<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>
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>
<table align="center">
+10 -3
View File
@@ -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 |
| `GET /chart/{progress,ladder,heatmap}.svg`, `GET /badge/gate.svg` | public | hand-rolled SVGs, `max-age=300` (GitHub Camo's freshness floor) |
| `GET /api/stats` | public, CORS-pinned to the docs origin | one JSON document for `/progress` |
| `POST /admin/solved` | `Authorization: Bearer <LINK_KEY>` | `{"lc":[…]}` — solutions committed under `work/`; logs each *first* solve (`source='commit'`), skips anything past stage `new`, takes `?dry=1&date=` |
| `POST /admin/{digest,review,reconcile}` | `Authorization: Bearer <LINK_KEY>` | manual triggers; `digest` takes `?dry=1&force=1&date=` |
## 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
`prdlk.com` is onboarded to Email Sending.
The `sync-d1.yml` Actions workflow additionally pushes curriculum issue
edits into D1 immediately (`POST /admin/reconcile` with the
`SRS_ADMIN_KEY` repo secret); the morning cron is the backstop.
Two Actions workflows push into D1 with the `SRS_ADMIN_KEY` repo secret:
`sync-d1.yml` sends curriculum issue edits to `POST /admin/reconcile`
(the morning cron is the backstop), and `close-solved.yml` sends the whole
implemented `work/` set to `POST /admin/solved` on every push to `main`.
That second call is what keeps a committed solution from being invisible
here: closing its issue is not a state change the Worker can see — the
catalog reconcile ignores issue state, and only `logAttempt()` moves the
ladder. `close-solved` still owns the issue close itself (it knows the files
and the commit), so this path only writes D1 and mirrors Project fields.
## Local dev
+4 -2
View File
@@ -18,12 +18,14 @@ CREATE TABLE attempts (
date TEXT NOT NULL,
kind TEXT NOT NULL, -- first|review|drill|gate
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
-- /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
ON attempts (lc_number, date, kind) WHERE source = 'email';
CREATE INDEX attempts_by_date ON attempts (date);
+21 -6
View File
@@ -8,22 +8,28 @@
* Retrieval rules: review and drill rows carry number + difficulty only —
* never the topic, never a solution link. `DigestRow` has no field for
* either, so the rule holds by construction. The learning day's core list is
* the only labeled section. Reviews + drills ≤ 6, reviews first, overflow
* simply stays due (oldest tomorrow). Sunday is a two-line rest note.
* the only labeled section. At most REVIEW_CAP (3) reviews per day; drills
* fill toward DAILY_CAP (6) only when the due queue is light. Overflow is
* levelled, not stacked: sendDigest gives every review past the cap a
* concrete future date, ≤ REVIEW_CAP per day, oldest first. Sunday is a
* two-line rest note.
*
* Idempotency: email_log keys sends by ET date — a same-day re-send is a
* no-op unless forced; the body itself is deterministic (drill picks are
* seeded by the date).
* seeded by the date). Levelling keeps that: it runs once, before the send,
* and a re-run finds nothing left over the cap.
*/
import {
CAMPAIGN_DAYS,
type ProblemRow,
REVIEW_CAP,
SCHEDULE,
addDays,
campaignDay,
campaignWeek,
dueReviews,
isoWeek,
levelReviews,
pickDrills,
prettyDate,
streak,
@@ -32,6 +38,7 @@ import {
import { type DigestData, type DigestRow, renderDigest } from "./email.tsx";
import { signLink } from "./links.ts";
/** Total daily ceiling: drills only fill what the whole due queue leaves. */
const DAILY_CAP = 6;
export interface Digest {
@@ -61,10 +68,12 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
const db = env.DB;
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 capped = due.slice(0, DAILY_CAP);
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - capped.length);
const capped = due.slice(0, REVIEW_CAP);
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
const data: DigestData = {
day: prettyDate(date),
@@ -96,6 +105,7 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
name: topic?.name ?? `#${topicIssue}`,
core: core.map((p) => ({
lc: p.lc_number,
difficulty: p.difficulty,
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
solved: p.stage !== "new",
})),
@@ -164,6 +174,11 @@ export async function sendDigest(
.first();
if (already && !opts.force) return { sent: false, reason: "already sent today", digest };
// The digest above was built against the un-levelled queue (so `carried`
// reports the real backlog); now give everything past the cap its future
// date. Dry runs never reach here, and a forced re-send finds nothing left.
await levelReviews(env.DB, date);
await env.EMAIL.send({
to: env.TO_EMAIL,
from: { email: env.FROM_EMAIL, name: "SRS" },
+63 -19
View File
@@ -35,6 +35,7 @@ import {
} from "@react-email/components";
import { render } from "@react-email/render";
import type { CSSProperties } from "react";
import { REVIEW_CAP } from "./srs.ts";
// ── palette (GitHub dark) ────────────────────────────────────────
const CANVAS = "#010409";
@@ -82,6 +83,14 @@ export interface DigestRow {
failUrl: string;
}
/** A learning-day core problem — the only rows allowed a link. */
export interface TopicRow {
lc: number;
difficulty: string;
url: string;
solved: boolean;
}
export interface DigestData {
/** "Tuesday, August 25" */
day: string;
@@ -89,9 +98,9 @@ export interface DigestData {
progress: string;
streak: number;
rest: boolean;
topic?: { name: string; core: { lc: number; url: string; solved: boolean }[] };
topic?: { name: string; core: TopicRow[] };
reviews: DigestRow[];
/** Reviews past the daily cap; they simply stay due. */
/** Reviews past the daily cap; levelled onto the coming days. */
carried: number;
drills: DigestRow[];
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({
title,
note,
@@ -265,18 +316,8 @@ function DigestEmail({ data }: { data: DigestData }) {
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
{data.topic.name}
</Text>
<Text style={{ ...hint, margin: 0 }}>
Work through these — ticked ones you have already solved:{" "}
{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>
<Text style={hint}>Work through these from the top — a tick means it is already solved.</Text>
<TopicTable rows={data.topic.core} />
</Section>
) : null}
@@ -289,8 +330,8 @@ function DigestEmail({ data }: { data: DigestData }) {
/>
{data.carried > 0 ? (
<Text style={{ ...hint, margin: "12px 0 0" }}>
{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.
</Text>
) : null}
@@ -373,8 +414,11 @@ function plainDigest(data: DigestData): string {
out.push(
"",
`SOMETHING NEW TODAY — ${data.topic.name}`,
"Work through these; the ones marked done you have already solved.",
...data.topic.core.map((p) => ` LC ${p.lc}${p.solved ? " (done)" : ""} — ${p.url}`),
"Work through these from the top; the ones marked done you have already solved.",
...data.topic.core.map(
(p) =>
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`,
),
);
}
@@ -411,7 +455,7 @@ function plainDigest(data: DigestData): string {
if (data.carried > 0) {
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) {
+59 -4
View File
@@ -22,6 +22,7 @@ import {
daysBetween,
etDate,
etHour,
getProblem,
logAttempt,
weekdayOf,
} from "./srs.ts";
@@ -29,6 +30,13 @@ import { buildStats } from "./stats.ts";
// ── shared side effects after a state write ──────────────────────
/** Project-field half of a mirror; shared by every write path. */
async function mirrorFields(mirror: Mirror, outcome: LogOutcome): Promise<void> {
await mirror.setStage(outcome.issue, outcome.stage);
await mirror.setTargetDate(outcome.issue, outcome.next_review);
if (outcome.first) await mirror.setFirstAttempt(outcome.issue, outcome.result);
}
/**
* Mirror one logged attempt into GitHub: Project fields always; on a
* 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> {
if (outcome.error || outcome.duplicate) return;
const gh = github(env.GH_PAT, env.REPO);
const mirror: Mirror = projectMirror(gh, env.REPO.split("/")[0]!);
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);
await mirrorFields(projectMirror(gh, env.REPO.split("/")[0]!), outcome);
if (outcome.first && outcome.result === "pass") {
try {
await gh.closeIssue(
@@ -200,6 +205,55 @@ async function adminAuthorized(request: Request, env: Env): Promise<boolean> {
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> {
if (!(await adminAuthorized(request, env))) return new Response("unauthorized", { status: 401 });
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") {
return Response.json(await reconcileCatalog(env.DB, gh()));
}
if (path === "/admin/solved") return await handleSolved(request, env, date);
if (path === "/admin/digest") {
const report = await sendDigest(env, date, {
dry: url.searchParams.get("dry") === "1",
+51 -3
View File
@@ -165,6 +165,31 @@ export async function dueReviews(db: D1Database, date: string): Promise<ProblemR
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 —
* 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
* 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
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
* nothing and must not touch the ladder. Webhook corrections (pass then fail
* on the same day) remain legal — every webhook attempt appends.
* 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(
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> {
const p = await getProblem(db, opts.lc);
const nothing: LogOutcome = {
@@ -266,6 +300,20 @@ export async function logAttempt(
duplicate: false,
};
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
// A commit only ever reports a first solve: pushing the file again — or the
// reconciler re-sending the whole solved set — must never move the ladder,
// and must never wake a retired problem. Reviews come from the digest tap,
// a /done comment, or a gate.
if (opts.source === "commit" && p.stage !== "new") {
return {
...nothing,
title: p.title,
stage: p.stage,
next_review: p.next_review,
issue: p.issue,
duplicate: true,
};
}
if (p.stage === "retired") {
return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` };
}
+7 -1
View File
@@ -10,6 +10,7 @@ import {
addDays,
campaignDay,
campaignWeek,
isoWeek,
streak,
} 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 };
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")
.all<{ week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }>();
const gates = gateRows.map((g) => ({ ...g, week: g.week - isoWeekOffset }));
// Review-queue depth for the next 14 days: everything due by that day.
const queue: { date: string; due: number }[] = [];
+1 -1
View File
@@ -15,7 +15,7 @@
{ "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.
"send_email": [
{ "name": "EMAIL", "allowed_destination_addresses": ["prnk28@gmail.com"] }
+68 -7
View File
@@ -1,6 +1,7 @@
#!/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
* 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
* 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
*
* 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";
@@ -193,24 +196,82 @@ for (const num of [...work.keys()].sort((a, b) => a - b)) {
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 ───────────────────────────────────────────────────────
const rows = [["lc", "issue", "set", "status"], ...report];
printTable(rows);
const implemented = [...work.values()].filter((e) => e.implemented).length;
const actionable = report.filter((r) => r[3] !== "no open issue");
console.log(
`\n${work.size} in work/ · ${implemented} implemented · ` +
`\n${work.size} in work/ · ${solved.length} implemented · ` +
`${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(
`### 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
? `${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
View File
@@ -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();
},
};
}
+1
View File
@@ -5,6 +5,7 @@
"scripts": {
"pick": "bun ./pick.ts",
"test": "bun ./test.ts",
"submit": "bun ./submit.ts",
"sync": "bun ./sync.ts",
"close-solved": "bun ./close-solved.ts",
"close-topics": "bun ./close-topics.ts"
+54 -52
View File
@@ -1,70 +1,72 @@
#!/usr/bin/env bun
/**
* 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 { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { basename, join } from "node:path";
import { fetchDescription, problemDb, readmeSlugs, type Problem } from "./db";
const ROOT = join(import.meta.dir, "..", "..");
const CACHE_FILE = join(ROOT, ".cache", "leetcode-problems.json");
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const WORK_DIR = join(ROOT, "work");
interface Problem {
id: number;
title: string;
slug: string;
difficulty: "Easy" | "Medium" | "Hard";
paidOnly: boolean;
}
const db = problemDb();
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
async function loadProblems(): Promise<Problem[]> {
const file = Bun.file(CACHE_FILE);
if (await file.exists()) {
const stale = Date.now() - file.lastModified > CACHE_TTL_MS;
if (!stale) return file.json();
}
// ── problem index (24h TTL) ──────────────────────────────────────
if (db.indexStale()) {
const s = spinner();
s.start("Fetching problem index from leetcode.com");
const res = await fetch("https://leetcode.com/api/problems/all/", {
headers: { "user-agent": "Mozilla/5.0" },
});
if (!res.ok) {
s.error(`Fetch failed: ${res.status} ${res.statusText}`);
process.exit(1);
try {
s.stop(`Indexed ${await db.refreshIndex()} problems`);
} catch (err) {
if (db.all().length === 0) {
s.error(`Fetch failed: ${err}`);
process.exit(1);
}
s.stop(`Index refresh failed (${err}) — using cached copy`);
}
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>({
message: "Pick a problem",
+112
View File
@@ -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);
}
+7
View File
@@ -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
View File
@@ -2,54 +2,6 @@
/**
* Fuzzy-select a solution file from work/ and run leetcode-cli tests on it.
*/
import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
import { join, relative } from "node:path";
import { runOnSolution } from "./solutions";
const ROOT = join(import.meta.dir, "..", "..");
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);
await runOnSolution("test", "Test which solution?");
@@ -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
```
+147 -76
View File
@@ -35,6 +35,7 @@ interface Stats {
deferred_total: number;
}[];
ladder: Record<Stage, number>;
/** week is the 1-based campaign week — the Worker converts from ISO. */
gates: {
week: number;
issue: number | null;
@@ -76,7 +77,12 @@ function dayLabel(iso: string): string {
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 = {
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 }) {
const maxTotal = Math.max(
1,
...stats.phases.map((p) => p.core_total + p.optional_total + p.deferred_total),
);
return (
<div style={card}>
<div style={heading}>Phases</div>
<table style={{ borderCollapse: "collapse", width: "100%" }}>
<tbody>
{stats.phases.map((p) => (
<tr key={p.milestone}>
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>{p.name}</td>
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
<Bar done={p.core_done} total={p.core_total} />{" "}
<span style={muted}>
core {p.core_done}/{p.core_total}
</span>
</td>
<td style={{ padding: "0.2rem 0" }}>
<span style={muted}>
optional {p.optional_done}/{p.optional_total}
{p.deferred_total > 0 &&
` · deferred ${p.deferred_done}/${p.deferred_total}`}
</span>
</td>
</tr>
))}
</tbody>
</table>
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap", marginBottom: "0.6rem" }}>
{SETS.map(([set, color]) => (
<span key={set} style={muted}>
<span
style={{
display: "inline-block",
width: "0.65rem",
height: "0.65rem",
borderRadius: "2px",
background: color,
marginRight: "0.35rem",
}}
/>
{set}
</span>
))}
</div>
{stats.phases.map((p) => {
const total = p.core_total + p.optional_total + p.deferred_total;
const done = p.core_done + p.optional_done + p.deferred_done;
return (
<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>
);
}
@@ -224,76 +276,95 @@ function Gates({ stats }: { stats: Stats }) {
<div style={card}>
<div style={heading}>Gate log</div>
{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%" }}>
<thead>
<tr>
{["Week", "Issue", "Pass rate", "Closed"].map((h) => (
<th key={h} style={{ ...muted, textAlign: "left", padding: "0.2rem 1rem 0.2rem 0" }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{stats.gates.map((g) => (
<tr key={g.week}>
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>{g.week}</td>
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
{g.issue === null ? (
<span style={muted}>—</span>
) : (
<a
href={`https://github.com/prdlk/leetcode/issues/${g.issue}`}
target="_blank"
rel="noreferrer"
>
#{g.issue}
</a>
)}
</td>
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
{g.pass_rate === null ? (
<span style={muted}>open</span>
) : (
percent(g.pass_rate)
)}
</td>
<td style={{ padding: "0.2rem 0" }}>
{g.closed_on ?? <span style={muted}>—</span>}
</td>
</tr>
))}
</tbody>
</table>
stats.gates.map((g) => (
<div
key={g.week}
style={{
display: "flex",
gap: "0.75rem",
alignItems: "baseline",
flexWrap: "wrap",
padding: "0.15rem 0",
}}
>
<span style={{ width: "4.5rem", flexShrink: 0 }}>Week {g.week}</span>
{g.issue === null ? (
<span style={muted}>no review issue</span>
) : (
<a
href={`https://github.com/prdlk/leetcode/issues/${g.issue}`}
target="_blank"
rel="noreferrer"
>
#{g.issue}
</a>
)}
{g.pass_rate === null ? (
<span style={muted}>in progress</span>
) : (
<span>
{percent(g.pass_rate)} passed
{g.closed_on && <span style={muted}> · closed {g.closed_on}</span>}
</span>
)}
</div>
))
)}
<div style={{ ...muted, marginTop: "0.4rem" }}>
One gate per campaign week — scored when its review issue closes.
</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 }) {
return (
<div style={card}>
<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) => (
<span
key={r.date}
style={{
border: "1px solid var(--blume-border)",
borderRadius: "var(--blume-radius)",
padding: "0.35rem 0.6rem",
textAlign: "center",
}}
title={`${r.date}: ${r.passes}/${r.attempts} passed`}
style={{ textAlign: "center" }}
>
<span style={{ ...muted, display: "block" }}>{dayLabel(r.date)}</span>
<strong>{r.passes}</strong>
<span style={muted}>/{r.attempts}</span>
<span
style={{
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>
))}
</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>
);
}
+10
View File
@@ -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)
+1
View File
@@ -14,6 +14,7 @@
"api:test": "cd apps/api && bun test src",
"pick": "bun apps/cli/pick.ts",
"sync": "bun apps/cli/sync.ts",
"submit": "bun apps/cli/submit.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
+76
View File
@@ -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
-44
View File
@@ -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
+86
View File
@@ -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()