mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
# 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 }}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,25 @@ 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 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)
|
||||
bun run submit # submit ONE solution to LeetCode (same picker as test)
|
||||
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 +76,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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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` };
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
|
||||
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 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);
|
||||
},
|
||||
|
||||
close(): void {
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Shared work/ solution picker for the test and submit entries: fuzzy-select
|
||||
* a solution file, then hand it to leetcode-cli. Library: side-effect-free on
|
||||
* import — nothing scans the filesystem until runOnSolution() is called.
|
||||
*/
|
||||
import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..", "..");
|
||||
const WORK_DIR = join(ROOT, "work");
|
||||
|
||||
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
|
||||
export async function runOnSolution(command: "test" | "submit", message: string): Promise<never> {
|
||||
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,
|
||||
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"] },
|
||||
);
|
||||
process.exit(await proc.exited);
|
||||
}
|
||||
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.
|
||||
*/
|
||||
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,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,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",
|
||||
"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,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
|
||||
Reference in new issue
Block a user