mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b5879b6eb | ||
|
|
53136b076c | ||
|
|
95f10980d4 | ||
|
|
c6ecb85507 | ||
|
|
2a69f36e6a | ||
|
|
5e4628b20f | ||
|
|
72a8399e6d | ||
|
|
6ebca68ff8 | ||
|
|
957ddf9849 | ||
|
|
e67cc79f39 | ||
|
|
f086c2b39a | ||
|
|
1b5bdc4ceb | ||
|
|
22ebb82e55 | ||
|
|
180134597d | ||
|
|
b669e97c3f | ||
|
|
d57d75efcc | ||
|
|
f1445a071f | ||
|
|
bb8aa00175 | ||
|
|
6df1e0e620 | ||
|
|
1b4ad8ca5b | ||
|
|
f697a986b8 | ||
|
|
de5101b4d1 | ||
|
|
3068b4e4aa | ||
|
|
e4de74c935 | ||
|
|
aba3d9b18b | ||
|
|
554145072d | ||
|
|
5cac46a0bf | ||
|
|
31f89dd4f5 | ||
|
|
217e899281 | ||
|
|
bc2269d9dd | ||
|
|
fd0d6c6ea5 | ||
|
|
9fbe800fed | ||
|
|
e64285f201 | ||
|
|
94b1e1af55 | ||
|
|
cc6c2da859 | ||
|
|
10d133f005 | ||
|
|
8f62875fa7 | ||
|
|
a3431da300 | ||
|
|
f551878961 | ||
|
|
881530a808 | ||
|
|
6b9a259ec7 | ||
|
|
3aa8d50644 | ||
|
|
ddf249c884 | ||
|
|
226fd2a6a8 | ||
|
|
a41ff59406 | ||
|
|
ab5c251952 | ||
|
|
7213cc1c8a | ||
|
|
6be5d55366 | ||
|
|
5bdc0d9089 | ||
|
|
c5e5e26d9d | ||
|
|
43c9b099fa | ||
|
|
0f509c5296 | ||
|
|
e5dbef9ee9 | ||
|
|
4c7a366bfd | ||
|
|
c3265f5672 | ||
|
|
48452fb72c | ||
|
|
d4b9c5b4fa | ||
|
|
2d37d6bc4d | ||
|
|
8661e10832 | ||
|
|
af03ecb9a4 | ||
|
|
fe7556bf1e | ||
|
|
382475b7f1 | ||
|
|
7743ca18bd |
No files matched your search
@@ -0,0 +1,41 @@
|
|||||||
|
name: Close Solved
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths: ["work/**"]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dry_run:
|
||||||
|
description: Report matches without closing anything
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: close-solved
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
# No install step: the script only uses Bun builtins + fetch.
|
||||||
|
#
|
||||||
|
# SRS_ADMIN_KEY 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 }}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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.
|
||||||
|
# Conclusion is not filtered: the script reconciles state, so running after a
|
||||||
|
# partially successful run is safe and catches whatever it did close.
|
||||||
|
workflow_run:
|
||||||
|
workflows: [Close Solved]
|
||||||
|
types: [completed]
|
||||||
|
# Covers problem issues closed by hand in the UI.
|
||||||
|
issues:
|
||||||
|
types: [closed]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dry_run:
|
||||||
|
description: Report finished topics without closing anything
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: close-topics
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close:
|
||||||
|
# The `issues` trigger fires for every issue in the repo; only closing a
|
||||||
|
# problem sub-issue can ever complete a topic's core set.
|
||||||
|
if: github.event_name != 'issues' || contains(github.event.issue.labels.*.name, 'problem')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
# No install step: the script only uses Bun builtins + fetch.
|
||||||
|
- run: bun apps/cli/close-topics.ts
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
DRY_RUN: ${{ inputs.dry_run }}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Deploy Docs
|
name: Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -10,8 +10,11 @@ permissions:
|
|||||||
pages: write
|
pages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
|
# The Worker deploy is idempotent (wrangler uploads the whole bundle), so on a
|
||||||
|
# burst of pushes only the last one matters: cancel-in-progress is correct for
|
||||||
|
# both the Pages build and the Worker job.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pages
|
group: deploy
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -31,7 +34,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/upload-pages-artifact@v3
|
- uses: actions/upload-pages-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: dist
|
path: apps/docs/dist
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
needs: build
|
needs: build
|
||||||
@@ -42,3 +45,24 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- id: deployment
|
- id: deployment
|
||||||
uses: actions/deploy-pages@v4
|
uses: actions/deploy-pages@v4
|
||||||
|
|
||||||
|
# Deploys on every main push (not path-filtered): apps/api/data/schedule.json
|
||||||
|
# is bundled at deploy time, and re-deploying an unchanged Worker is a no-op.
|
||||||
|
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit) and
|
||||||
|
# CLOUDFLARE_ACCOUNT_ID.
|
||||||
|
deploy-api:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# DST cron guards must hold before the Worker ships.
|
||||||
|
- run: bun run api:test
|
||||||
|
|
||||||
|
- run: bun run api:deploy
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: Sync D1 Catalog
|
||||||
|
|
||||||
|
# GitHub issues own the SRS catalog (topics, problems, set/diff labels,
|
||||||
|
# milestones); the Worker's D1 holds a reconciled copy. The 8 AM ET cron is
|
||||||
|
# the backstop — this workflow pushes curriculum edits into D1 immediately.
|
||||||
|
#
|
||||||
|
# The reconcile is a total recompute (never touches SRS-owned columns), so
|
||||||
|
# bursts can safely collapse to the last run: cancel-in-progress is correct.
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types:
|
||||||
|
[opened, edited, labeled, unlabeled, milestoned, demilestoned, reopened, transferred, deleted]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: sync-d1
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
reconcile:
|
||||||
|
# Only curriculum issues shape the catalog.
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
contains(github.event.issue.labels.*.name, 'problem') ||
|
||||||
|
contains(github.event.issue.labels.*.name, 'topic')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Reconcile catalog into D1
|
||||||
|
run: |
|
||||||
|
curl -sf -X POST -H "Authorization: Bearer $SRS_ADMIN_KEY" \
|
||||||
|
https://srs-api.prdlk.workers.dev/admin/reconcile
|
||||||
|
env:
|
||||||
|
SRS_ADMIN_KEY: ${{ secrets.SRS_ADMIN_KEY }}
|
||||||
+6
-1
@@ -35,5 +35,10 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
.blume/
|
.blume/
|
||||||
/docs/.wrangler/state/v3
|
/apps/docs/docs/.wrangler/state/v3
|
||||||
.blume-verify/
|
.blume-verify/
|
||||||
|
|
||||||
|
# api worker
|
||||||
|
apps/api/.dev.vars
|
||||||
|
apps/api/.wrangler/
|
||||||
|
apps/api/worker-configuration.d.ts
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
description: Sync work/ leetcode solutions into docs/solutions/ pages (gold standard format)
|
|
||||||
---
|
|
||||||
|
|
||||||
Sync every leetcode solution under `work/` into a docs page under `docs/solutions/`, formatted like the gold standard `docs/solutions/(two-pointers)/344-reverse-string.mdx`. Extra focus (optional): $@
|
|
||||||
|
|
||||||
## Run the script
|
|
||||||
|
|
||||||
The port is fully scripted — do not hand-write pages:
|
|
||||||
|
|
||||||
```
|
|
||||||
bun run sync
|
|
||||||
```
|
|
||||||
|
|
||||||
`scripts/sync.ts` does the whole pipeline:
|
|
||||||
|
|
||||||
- Globs `work/**/*.{js,py}` (`<number>.<kebab-slug>.{js,py}` under `work/<Difficulty>/<Category>/`) and groups the two language variants of a problem by leetcode number.
|
|
||||||
- Parses each file's header comment (js block comment / py docstring) into title, difficulty, statement, examples, constraints, and follow-up.
|
|
||||||
- New problems get a full page at `docs/solutions/(<category>)/<leetcode-number>-<kebab-slug>.mdx`: frontmatter, `<Badge>` topic (README curriculum table, falling back to the `work/` subdirectory), `::::warning` for follow-ups/special requirements, examples, constraints, and the solution code verbatim.
|
|
||||||
- `(<category>)` is the kebab-cased `work/` subdirectory (`(array)`, `(hash-table)`, `(two-pointers)`, …) and becomes the sidebar group; the leetcode-number filename prefix orders pages numerically. Both are stripped from the URL (`(two-pointers)/344-reverse-string.mdx` → `/solutions/reverse-string`). The script moves any page whose path drifts from this scheme.
|
|
||||||
- Frontmatter `title` keeps the number (`'344. Reverse String'`); `sidebar.label` is the number-free problem name. The script backfills a missing `sidebar.label` on existing pages but never overwrites one.
|
|
||||||
- Already-ported pages (frontmatter title starts with the leetcode number) only get their `## Solution` section regenerated — curated prose is never touched.
|
|
||||||
- Problems solved in both languages render as a `<CodeGroup>` (Python first, then JavaScript); single-language solutions render as a plain fence.
|
|
||||||
|
|
||||||
The script prints the report table: work file → docs page → created / moved / updated / skipped (already in sync).
|
|
||||||
|
|
||||||
## Review
|
|
||||||
|
|
||||||
Skim any **created** pages — the prose transforms (backticking literals in example explanations, warning detection) are heuristic. Fix wording by editing the page prose directly; it won't be overwritten on the next sync. Never edit inside `## Solution` — that section is owned by the script and synced from `work/`.
|
|
||||||
|
|
||||||
## Verify
|
|
||||||
|
|
||||||
1. `bunx blume build --isolated` must succeed with no new warnings; confirm each new route generated (`/solutions/<slug>`).
|
|
||||||
2. Spot-check one new page's built HTML for the badge, callout (when present), and solution code.
|
|
||||||
3. `rm -rf .blume-verify` when done.
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues own ~161 `problem` sub-issues (`set:core|optional|deferred`, `diff:*`); milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
|
||||||
|
|
||||||
|
## Architecture & Data Flow
|
||||||
|
|
||||||
|
Three owners, one direction of truth:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
|
||||||
|
work -->|close-solved.yml| PI[problem issues]
|
||||||
|
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
|
||||||
|
PI -->|close-topics.yml| TI[topic issues]
|
||||||
|
GH[GitHub issues = catalog] -->|nightly reconcile| D1
|
||||||
|
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
|
||||||
|
Mail -->|GET /log| D1
|
||||||
|
PI -->|/done webhook| D1
|
||||||
|
D1 -->|best-effort mirror| Proj[GitHub Project fields]
|
||||||
|
D1 -->|Sat midnight ET| Review[Review — Week N issue]
|
||||||
|
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. `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`.
|
||||||
|
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
||||||
|
|
||||||
|
## Key Directories
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `work/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/Easy/Array/1.two-sum.py`. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
||||||
|
| `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 (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; 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" \
|
||||||
|
"localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Conventions & Common Patterns
|
||||||
|
|
||||||
|
- **Scripts are either entries or libraries.** Entries (`apps/cli/close-*.ts`) use shebang + top-level `await`. Libraries (`apps/cli/github.ts`, `apps/cli/report.ts`, everything in `apps/api/src/`) are side-effect-free on import — nothing touches network/credentials until a factory (`github()`, `projectMirror()`) is called.
|
||||||
|
- **Shared plumbing:** `github()` gives `repo`, `api()` (throws `METHOD path -> status body`), `list()` (paginating async generator), `closeIssue()` (comment **first**, then close — a failed PATCH still leaves a trace). `report.ts` gives `printTable`/`markdownTable`/`writeStepSummary`.
|
||||||
|
- **Narrow API payloads with guards**, not inline casts: `if (!("number" in value) || typeof value.number !== "number") return;` (see `readProblemIssue` in `close-solved.ts`). Named-const casts only with a reason.
|
||||||
|
- **Dry-run everything:** reconcilers take `--dry-run`/`DRY_RUN=1`; Worker admin routes take `?dry=1&force=1&date=` (the `SRS_TODAY` equivalent) and `wrangler dev` runs against local D1 — exercise logic there before touching production state.
|
||||||
|
- **Dates:** always ET calendar strings (`YYYY-MM-DD`), arithmetic anchored at noon UTC (`atNoon` in `apps/api/src/srs.ts`) to dodge DST. Never `new Date()` math directly.
|
||||||
|
- **Style:** section-divider comments (`// ── name ───`), file-top doc comments explaining the *why* and invariants, 2-space JSON with trailing newline, `Map` for dynamic keys / `Record` for static tables, no tiny one-expression wrapper functions.
|
||||||
|
- **Known intentional duplication:** `close-solved.ts` re-implements header stripping instead of importing from `sync.ts` because `sync.ts` runs its pipeline on import. Don't "deduplicate" it.
|
||||||
|
|
||||||
|
## 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), 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.
|
||||||
|
- `apps/cli/sync.ts` — work→docs contract: only `## Solution` onward is script-owned on existing pages; human prose is never touched; never hand-write solution pages or edit inside `## Solution`.
|
||||||
|
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the docs `/progress` page fetches `/api/stats` client-side (`apps/docs/islands/ProgressDashboard.tsx`).
|
||||||
|
- `apps/docs/blume.config.ts` — site base `/leetcode`; `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`).
|
||||||
|
- Old `.github/srs/` JSON state and the `srs-*` Actions are RETIRED — do not resurrect; `apps/api/scripts/import-srs.ts` documents the migration.
|
||||||
|
|
||||||
|
## Runtime/Tooling Preferences
|
||||||
|
|
||||||
|
- **Bun only**: run scripts with `bun apps/cli/<name>.ts` (or the root `bun run` aliases), install with `bun install --frozen-lockfile`. One workspace (`apps/*`), one root `bun.lock`. Node 22 appears only in `deploy.yml` because Blume requires it.
|
||||||
|
- **No root tsconfig, no ESLint, no Prettier** — match surrounding style by hand. `apps/api/` has a strict `tsconfig.json`; `Env` types are generated (`bunx wrangler types`), never hand-written.
|
||||||
|
- **Near-zero runtime npm dependencies** — SVG, PNG, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. The **one** exception is the digest email: `apps/api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. Do not extend this exception to any other module.
|
||||||
|
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
|
||||||
|
|
||||||
|
## Testing & QA
|
||||||
|
|
||||||
|
- Repo-wide: no test framework — `bun run test` submits one solution to LeetCode's judge. Exception: `apps/api/src/srs.test.ts` (`bun:test`) proves the DST cron guards with fixed dates; run via `bun run api:test`.
|
||||||
|
- QA is dry-runs + local state: Worker logic against `wrangler dev` + local D1 with `?dry=1&date=`; reconcilers with `--dry-run`; idempotency check = run twice, `cmp` output.
|
||||||
|
- Docs changes: `bunx blume build --isolated` (from `apps/docs/`) must pass with no new warnings (then `rm -rf apps/docs/.blume-verify`); after sync, review only *created* pages (prose transforms are heuristic).
|
||||||
@@ -1,15 +1,30 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="header" src="public/dependency-spine.svg" />
|
<img alt="header" src="https://shieldcn.dev/header/gradient.svg?title=prdlk%2Fleetcode&subtitle=My+Personal+LeetCode+Solutions&logo=leetcode&logoColor=eab308&mode=dark" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://x.com/basedprad"><img alt="follow" src="https://shieldcn.dev/x/follow/basedprad.svg" /></a>
|
<a href="https://x.com/basedprad"><img alt="follow" src="https://shieldcn.dev/x/follow/basedprad.svg" /></a>
|
||||||
<a href="https://lc.prad.nu"><img alt="badge" src="https://shieldcn.dev/badge/Read-Documentation.svg?logo=lu%3ABook" /></a>
|
<a href="https://lc.prad.nu"><img alt="badge" src="https://shieldcn.dev/badge/Read-Documentation.svg?logo=lu%3ABook" /></a>
|
||||||
|
<a href="https://lc.prad.nu/progress"><img alt="gate pass rate" src="https://srs-api.prdlk.workers.dev/badge/gate.svg" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<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, never committed: pushing a solution or tapping the daily digest moves them within ~5 min.</sub>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table align="center">
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" align="center"><img alt="phase progress" width="100%" src="https://srs-api.prdlk.workers.dev/chart/progress.svg" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="53%"><img alt="SRS ladder" width="100%" src="https://srs-api.prdlk.workers.dev/chart/ladder.svg" /></td>
|
||||||
|
<td align="center" width="47%"><img alt="attempt heatmap" width="100%" src="https://srs-api.prdlk.workers.dev/chart/heatmap.svg" /></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Phase I — Linear Structures · Weeks 1–2
|
||||||
|
|
||||||
## Phase I — Linear Structures (Week 1)
|
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -21,7 +36,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="9"><strong>1. Hash-Based Lookup</strong><br><sub>builds on nothing</sub></td>
|
<td rowspan="9"><a href="https://github.com/prdlk/leetcode/issues/3"><strong>1. Hash-Based Lookup</strong></a><br><sub>Wk 1 · Mon Aug 17</sub><br><sub>builds on nothing</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">217</td><td><a href="https://leetcode.com/problems/contains-duplicate/">Contains Duplicate</a></td>
|
<td align="center"><code>A</code></td><td align="center">217</td><td><a href="https://leetcode.com/problems/contains-duplicate/">Contains Duplicate</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">242</td><td><a href="https://leetcode.com/problems/valid-anagram/">Valid Anagram</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">242</td><td><a href="https://leetcode.com/problems/valid-anagram/">Valid Anagram</a></td></tr>
|
||||||
@@ -33,7 +48,7 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">128</td><td><a href="https://leetcode.com/problems/longest-consecutive-sequence/">Longest Consecutive Sequence</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">128</td><td><a href="https://leetcode.com/problems/longest-consecutive-sequence/">Longest Consecutive Sequence</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code></td><td align="center">290</td><td><a href="https://leetcode.com/problems/word-pattern/">Word Pattern</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">290</td><td><a href="https://leetcode.com/problems/word-pattern/">Word Pattern</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="5"><strong>2. Prefix Sum</strong><br><sub>builds on 1</sub></td>
|
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/4"><strong>2. Prefix Sum</strong></a><br><sub>Wk 1 · Tue Aug 18</sub><br><sub>builds on 1</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">303</td><td><a href="https://leetcode.com/problems/range-sum-query-immutable/">Range Sum Query</a></td>
|
<td align="center"><code>A</code></td><td align="center">303</td><td><a href="https://leetcode.com/problems/range-sum-query-immutable/">Range Sum Query</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">238</td><td><a href="https://leetcode.com/problems/product-of-array-except-self/">Product of Array Except Self</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">238</td><td><a href="https://leetcode.com/problems/product-of-array-except-self/">Product of Array Except Self</a></td></tr>
|
||||||
@@ -41,7 +56,7 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">525</td><td><a href="https://leetcode.com/problems/contiguous-array/">Contiguous Array</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">525</td><td><a href="https://leetcode.com/problems/contiguous-array/">Contiguous Array</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code></td><td align="center">974</td><td><a href="https://leetcode.com/problems/subarray-sums-divisible-by-k/">Subarray Sums Divisible by K</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">974</td><td><a href="https://leetcode.com/problems/subarray-sums-divisible-by-k/">Subarray Sums Divisible by K</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="9"><strong>3. Two Pointers</strong><br><sub>builds on ordered iteration</sub></td>
|
<td rowspan="9"><a href="https://github.com/prdlk/leetcode/issues/5"><strong>3. Two Pointers</strong></a><br><sub>Wk 2 · Mon Aug 24</sub><br><sub>builds on ordered iteration</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">125</td><td><a href="https://leetcode.com/problems/valid-palindrome/">Valid Palindrome</a></td>
|
<td align="center"><code>A</code></td><td align="center">125</td><td><a href="https://leetcode.com/problems/valid-palindrome/">Valid Palindrome</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">167</td><td><a href="https://leetcode.com/problems/two-sum-ii-input-array-is-sorted/">Two Sum II</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">167</td><td><a href="https://leetcode.com/problems/two-sum-ii-input-array-is-sorted/">Two Sum II</a></td></tr>
|
||||||
@@ -53,7 +68,7 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">189</td><td><a href="https://leetcode.com/problems/rotate-array/">Rotate Array</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">189</td><td><a href="https://leetcode.com/problems/rotate-array/">Rotate Array</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code></td><td align="center">80</td><td><a href="https://leetcode.com/problems/remove-duplicates-from-sorted-array-ii/">Remove Duplicates II</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">80</td><td><a href="https://leetcode.com/problems/remove-duplicates-from-sorted-array-ii/">Remove Duplicates II</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="8"><strong>4. Sliding Window</strong><br><sub>builds on 3</sub></td>
|
<td rowspan="8"><a href="https://github.com/prdlk/leetcode/issues/6"><strong>4. Sliding Window</strong></a><br><sub>Wk 2 · Tue Aug 25</sub><br><sub>builds on 3</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">121</td><td><a href="https://leetcode.com/problems/best-time-to-buy-and-sell-stock/">Best Time to Buy and Sell Stock</a></td>
|
<td align="center"><code>A</code></td><td align="center">121</td><td><a href="https://leetcode.com/problems/best-time-to-buy-and-sell-stock/">Best Time to Buy and Sell Stock</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">3</td><td><a href="https://leetcode.com/problems/longest-substring-without-repeating-characters/">Longest Substring Without Repeating Characters</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">3</td><td><a href="https://leetcode.com/problems/longest-substring-without-repeating-characters/">Longest Substring Without Repeating Characters</a></td></tr>
|
||||||
@@ -64,7 +79,7 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">1004</td><td><a href="https://leetcode.com/problems/max-consecutive-ones-iii/">Max Consecutive Ones III</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">1004</td><td><a href="https://leetcode.com/problems/max-consecutive-ones-iii/">Max Consecutive Ones III</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code></td><td align="center">643</td><td><a href="https://leetcode.com/problems/maximum-average-subarray-i/">Maximum Average Subarray</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">643</td><td><a href="https://leetcode.com/problems/maximum-average-subarray-i/">Maximum Average Subarray</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="9"><strong>5. Binary Search</strong><br><sub>builds on ordered / monotonic invariants</sub></td>
|
<td rowspan="9"><a href="https://github.com/prdlk/leetcode/issues/7"><strong>5. Binary Search</strong></a><br><sub>Wk 2 · Wed Aug 26</sub><br><sub>builds on ordered / monotonic invariants</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">704</td><td><a href="https://leetcode.com/problems/binary-search/">Binary Search</a></td>
|
<td align="center"><code>A</code></td><td align="center">704</td><td><a href="https://leetcode.com/problems/binary-search/">Binary Search</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">74</td><td><a href="https://leetcode.com/problems/search-a-2d-matrix/">Search a 2D Matrix</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">74</td><td><a href="https://leetcode.com/problems/search-a-2d-matrix/">Search a 2D Matrix</a></td></tr>
|
||||||
@@ -76,7 +91,7 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">34</td><td><a href="https://leetcode.com/problems/find-first-and-last-position-of-element-in-sorted-array/">First & Last Position</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">34</td><td><a href="https://leetcode.com/problems/find-first-and-last-position-of-element-in-sorted-array/">First & Last Position</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code> ⭐</td><td align="center">4</td><td><a href="https://leetcode.com/problems/median-of-two-sorted-arrays/">Median of Two Sorted Arrays</a></td></tr>
|
<tr><td align="center"><code>B</code> ⭐</td><td align="center">4</td><td><a href="https://leetcode.com/problems/median-of-two-sorted-arrays/">Median of Two Sorted Arrays</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="5"><strong>6. Stack</strong><br><sub>independent primitive</sub></td>
|
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/8"><strong>6. Stack</strong></a><br><sub>Wk 2 · Thu Aug 27</sub><br><sub>independent primitive</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">20</td><td><a href="https://leetcode.com/problems/valid-parentheses/">Valid Parentheses</a></td>
|
<td align="center"><code>A</code></td><td align="center">20</td><td><a href="https://leetcode.com/problems/valid-parentheses/">Valid Parentheses</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">155</td><td><a href="https://leetcode.com/problems/min-stack/">Min Stack</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">155</td><td><a href="https://leetcode.com/problems/min-stack/">Min Stack</a></td></tr>
|
||||||
@@ -84,7 +99,7 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">71</td><td><a href="https://leetcode.com/problems/simplify-path/">Simplify Path</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">71</td><td><a href="https://leetcode.com/problems/simplify-path/">Simplify Path</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code> ⭐</td><td align="center">224</td><td><a href="https://leetcode.com/problems/basic-calculator/">Basic Calculator</a></td></tr>
|
<tr><td align="center"><code>B</code> ⭐</td><td align="center">224</td><td><a href="https://leetcode.com/problems/basic-calculator/">Basic Calculator</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="5"><strong>7. Monotonic Stack</strong><br><sub>builds on 6</sub></td>
|
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/9"><strong>7. Monotonic Stack</strong></a><br><sub>Wk 2 · Fri Aug 28</sub><br><sub>builds on 6</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">739</td><td><a href="https://leetcode.com/problems/daily-temperatures/">Daily Temperatures</a></td>
|
<td align="center"><code>A</code></td><td align="center">739</td><td><a href="https://leetcode.com/problems/daily-temperatures/">Daily Temperatures</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">853</td><td><a href="https://leetcode.com/problems/car-fleet/">Car Fleet</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">853</td><td><a href="https://leetcode.com/problems/car-fleet/">Car Fleet</a></td></tr>
|
||||||
@@ -93,10 +108,10 @@
|
|||||||
<tr><td align="center"><code>B</code> ⭐</td><td align="center">84</td><td><a href="https://leetcode.com/problems/largest-rectangle-in-histogram/">Largest Rectangle in Histogram</a></td></tr>
|
<tr><td align="center"><code>B</code> ⭐</td><td align="center">84</td><td><a href="https://leetcode.com/problems/largest-rectangle-in-histogram/">Largest Rectangle in Histogram</a></td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
---
|
|
||||||
|
|
||||||
## Phase II — Nodal & Grid Structures (Week 2)
|
## Phase II — Nodal & Grid Structures · Week 3
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -108,7 +123,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="10"><strong>8. Linked List</strong><br><sub>independent primitive</sub></td>
|
<td rowspan="10"><a href="https://github.com/prdlk/leetcode/issues/10"><strong>8. Linked List</strong></a><br><sub>Wk 3 · Mon Aug 31</sub><br><sub>independent primitive</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">206</td><td><a href="https://leetcode.com/problems/reverse-linked-list/">Reverse Linked List</a></td>
|
<td align="center"><code>A</code></td><td align="center">206</td><td><a href="https://leetcode.com/problems/reverse-linked-list/">Reverse Linked List</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">21</td><td><a href="https://leetcode.com/problems/merge-two-sorted-lists/">Merge Two Sorted Lists</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">21</td><td><a href="https://leetcode.com/problems/merge-two-sorted-lists/">Merge Two Sorted Lists</a></td></tr>
|
||||||
@@ -121,14 +136,14 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">61</td><td><a href="https://leetcode.com/problems/rotate-list/">Rotate List</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">61</td><td><a href="https://leetcode.com/problems/rotate-list/">Rotate List</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code></td><td align="center">86</td><td><a href="https://leetcode.com/problems/partition-list/">Partition List</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">86</td><td><a href="https://leetcode.com/problems/partition-list/">Partition List</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="4"><strong>9. Hybrid Structures</strong><br><sub>builds on 1 + 8</sub></td>
|
<td rowspan="4"><a href="https://github.com/prdlk/leetcode/issues/11"><strong>9. Hybrid Structures</strong></a><br><sub>Wk 3 · Tue Sep 1</sub><br><sub>builds on 1 + 8</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">146</td><td><a href="https://leetcode.com/problems/lru-cache/">LRU Cache</a></td>
|
<td align="center"><code>A</code></td><td align="center">146</td><td><a href="https://leetcode.com/problems/lru-cache/">LRU Cache</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">2</td><td><a href="https://leetcode.com/problems/add-two-numbers/">Add Two Numbers</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">2</td><td><a href="https://leetcode.com/problems/add-two-numbers/">Add Two Numbers</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code></td><td align="center">138</td><td><a href="https://leetcode.com/problems/copy-list-with-random-pointer/">Copy List with Random Pointer</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">138</td><td><a href="https://leetcode.com/problems/copy-list-with-random-pointer/">Copy List with Random Pointer</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code></td><td align="center">380</td><td><a href="https://leetcode.com/problems/insert-delete-getrandom-o1/">Insert Delete GetRandom O(1)</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">380</td><td><a href="https://leetcode.com/problems/insert-delete-getrandom-o1/">Insert Delete GetRandom O(1)</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="5"><strong>10. Matrix Index Math</strong><br><sub>builds on 3 — NOT graph traversal</sub></td>
|
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/12"><strong>10. Matrix Index Math</strong></a><br><sub>Wk 3 · Wed Sep 2</sub><br><sub>builds on 3 — NOT graph traversal</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">48</td><td><a href="https://leetcode.com/problems/rotate-image/">Rotate Image</a></td>
|
<td align="center"><code>A</code></td><td align="center">48</td><td><a href="https://leetcode.com/problems/rotate-image/">Rotate Image</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">54</td><td><a href="https://leetcode.com/problems/spiral-matrix/">Spiral Matrix</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">54</td><td><a href="https://leetcode.com/problems/spiral-matrix/">Spiral Matrix</a></td></tr>
|
||||||
@@ -137,10 +152,10 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">289</td><td><a href="https://leetcode.com/problems/game-of-life/">Game of Life</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">289</td><td><a href="https://leetcode.com/problems/game-of-life/">Game of Life</a></td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
---
|
|
||||||
|
|
||||||
## Phase III — Hierarchical Structures (Week 3)
|
## Phase III — Hierarchical Structures · Weeks 3–4
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -152,7 +167,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="10"><strong>11. Tree DFS</strong><br><sub>builds on recursion</sub></td>
|
<td rowspan="10"><a href="https://github.com/prdlk/leetcode/issues/13"><strong>11. Tree DFS</strong></a><br><sub>Wk 3 · Thu Sep 3</sub><br><sub>builds on recursion</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">226</td><td><a href="https://leetcode.com/problems/invert-binary-tree/">Invert Binary Tree</a></td>
|
<td align="center"><code>A</code></td><td align="center">226</td><td><a href="https://leetcode.com/problems/invert-binary-tree/">Invert Binary Tree</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">104</td><td><a href="https://leetcode.com/problems/maximum-depth-of-binary-tree/">Maximum Depth of Binary Tree</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">104</td><td><a href="https://leetcode.com/problems/maximum-depth-of-binary-tree/">Maximum Depth of Binary Tree</a></td></tr>
|
||||||
@@ -165,7 +180,7 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">129</td><td><a href="https://leetcode.com/problems/sum-root-to-leaf-numbers/">Sum Root to Leaf Numbers</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">129</td><td><a href="https://leetcode.com/problems/sum-root-to-leaf-numbers/">Sum Root to Leaf Numbers</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code></td><td align="center">543</td><td><a href="https://leetcode.com/problems/diameter-of-binary-tree/">Diameter of Binary Tree</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">543</td><td><a href="https://leetcode.com/problems/diameter-of-binary-tree/">Diameter of Binary Tree</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="5"><strong>12. Tree BFS</strong><br><sub>builds on 6's sibling (queue) + 11</sub></td>
|
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/14"><strong>12. Tree BFS</strong></a><br><sub>Wk 3 · Fri Sep 4</sub><br><sub>builds on 6's sibling (queue) + 11</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">102</td><td><a href="https://leetcode.com/problems/binary-tree-level-order-traversal/">Level Order Traversal</a></td>
|
<td align="center"><code>A</code></td><td align="center">102</td><td><a href="https://leetcode.com/problems/binary-tree-level-order-traversal/">Level Order Traversal</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">199</td><td><a href="https://leetcode.com/problems/binary-tree-right-side-view/">Right Side View</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">199</td><td><a href="https://leetcode.com/problems/binary-tree-right-side-view/">Right Side View</a></td></tr>
|
||||||
@@ -173,7 +188,7 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">103</td><td><a href="https://leetcode.com/problems/binary-tree-zigzag-level-order-traversal/">Zigzag Level Order</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">103</td><td><a href="https://leetcode.com/problems/binary-tree-zigzag-level-order-traversal/">Zigzag Level Order</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code></td><td align="center">637</td><td><a href="https://leetcode.com/problems/average-of-levels-in-binary-tree/">Average of Levels</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">637</td><td><a href="https://leetcode.com/problems/average-of-levels-in-binary-tree/">Average of Levels</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="7"><strong>13. Binary Search Tree</strong><br><sub>builds on 5 + 11</sub></td>
|
<td rowspan="7"><a href="https://github.com/prdlk/leetcode/issues/15"><strong>13. Binary Search Tree</strong></a><br><sub>Wk 4 · Mon Sep 7</sub><br><sub>builds on 5 + 11</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">98</td><td><a href="https://leetcode.com/problems/validate-binary-search-tree/">Validate BST</a></td>
|
<td align="center"><code>A</code></td><td align="center">98</td><td><a href="https://leetcode.com/problems/validate-binary-search-tree/">Validate BST</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">230</td><td><a href="https://leetcode.com/problems/kth-smallest-element-in-a-bst/">Kth Smallest in BST</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">230</td><td><a href="https://leetcode.com/problems/kth-smallest-element-in-a-bst/">Kth Smallest in BST</a></td></tr>
|
||||||
@@ -183,7 +198,7 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">173</td><td><a href="https://leetcode.com/problems/binary-search-tree-iterator/">BST Iterator</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">173</td><td><a href="https://leetcode.com/problems/binary-search-tree-iterator/">BST Iterator</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code></td><td align="center">108</td><td><a href="https://leetcode.com/problems/convert-sorted-array-to-binary-search-tree/">Sorted Array to BST</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">108</td><td><a href="https://leetcode.com/problems/convert-sorted-array-to-binary-search-tree/">Sorted Array to BST</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="9"><strong>14. Heap / Priority Queue</strong><br><sub>implicit tree over array</sub></td>
|
<td rowspan="9"><a href="https://github.com/prdlk/leetcode/issues/16"><strong>14. Heap / Priority Queue</strong></a><br><sub>Wk 4 · Tue Sep 8</sub><br><sub>implicit tree over array</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">215</td><td><a href="https://leetcode.com/problems/kth-largest-element-in-an-array/">Kth Largest in Array</a></td>
|
<td align="center"><code>A</code></td><td align="center">215</td><td><a href="https://leetcode.com/problems/kth-largest-element-in-an-array/">Kth Largest in Array</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">703</td><td><a href="https://leetcode.com/problems/kth-largest-element-in-a-stream/">Kth Largest in Stream</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">703</td><td><a href="https://leetcode.com/problems/kth-largest-element-in-a-stream/">Kth Largest in Stream</a></td></tr>
|
||||||
@@ -195,17 +210,17 @@
|
|||||||
<tr><td align="center"><code>B</code> ⭐</td><td align="center">295</td><td><a href="https://leetcode.com/problems/find-median-from-data-stream/">Find Median from Data Stream</a></td></tr>
|
<tr><td align="center"><code>B</code> ⭐</td><td align="center">295</td><td><a href="https://leetcode.com/problems/find-median-from-data-stream/">Find Median from Data Stream</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code> ⭐</td><td align="center">502</td><td><a href="https://leetcode.com/problems/ipo/">IPO</a></td></tr>
|
<tr><td align="center"><code>B</code> ⭐</td><td align="center">502</td><td><a href="https://leetcode.com/problems/ipo/">IPO</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="3"><strong>15. Trie</strong><br><sub>builds on 11</sub></td>
|
<td rowspan="3"><a href="https://github.com/prdlk/leetcode/issues/17"><strong>15. Trie</strong></a><br><sub>Wk 4 · Wed Sep 9</sub><br><sub>builds on 11</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">208</td><td><a href="https://leetcode.com/problems/implement-trie-prefix-tree/">Implement Trie</a></td>
|
<td align="center"><code>A</code></td><td align="center">208</td><td><a href="https://leetcode.com/problems/implement-trie-prefix-tree/">Implement Trie</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">211</td><td><a href="https://leetcode.com/problems/design-add-and-search-words-data-structure/">Add & Search Words</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">211</td><td><a href="https://leetcode.com/problems/design-add-and-search-words-data-structure/">Add & Search Words</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code></td><td align="center">212</td><td><a href="https://leetcode.com/problems/word-search-ii/">Word Search II</a> <sub>(needs topic 20 — schedule after)</sub></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">212</td><td><a href="https://leetcode.com/problems/word-search-ii/">Word Search II</a> <sub>(needs topic 20 — schedule after)</sub></td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
---
|
|
||||||
|
|
||||||
## Phase IV — Relational Structures (Week 4)
|
## Phase IV — Relational Structures · Weeks 4–5
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -217,7 +232,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="7"><strong>16. Graph DFS</strong><br><sub>builds on 11 + visited set</sub></td>
|
<td rowspan="7"><a href="https://github.com/prdlk/leetcode/issues/18"><strong>16. Graph DFS</strong></a><br><sub>Wk 4 · Thu Sep 10</sub><br><sub>builds on 11 + visited set</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">200</td><td><a href="https://leetcode.com/problems/number-of-islands/">Number of Islands</a></td>
|
<td align="center"><code>A</code></td><td align="center">200</td><td><a href="https://leetcode.com/problems/number-of-islands/">Number of Islands</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">695</td><td><a href="https://leetcode.com/problems/max-area-of-island/">Max Area of Island</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">695</td><td><a href="https://leetcode.com/problems/max-area-of-island/">Max Area of Island</a></td></tr>
|
||||||
@@ -227,7 +242,7 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">399</td><td><a href="https://leetcode.com/problems/evaluate-division/">Evaluate Division</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">399</td><td><a href="https://leetcode.com/problems/evaluate-division/">Evaluate Division</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code></td><td align="center">261</td><td><a href="https://leetcode.com/problems/graph-valid-tree/">Graph Valid Tree</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">261</td><td><a href="https://leetcode.com/problems/graph-valid-tree/">Graph Valid Tree</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="5"><strong>17. Graph BFS</strong><br><sub>builds on 12 + visited set — shortest path unweighted</sub></td>
|
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/19"><strong>17. Graph BFS</strong></a><br><sub>Wk 4 · Fri Sep 11</sub><br><sub>builds on 12 + visited set — shortest path unweighted</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">994</td><td><a href="https://leetcode.com/problems/rotting-oranges/">Rotting Oranges</a></td>
|
<td align="center"><code>A</code></td><td align="center">994</td><td><a href="https://leetcode.com/problems/rotting-oranges/">Rotting Oranges</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">127</td><td><a href="https://leetcode.com/problems/word-ladder/">Word Ladder</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">127</td><td><a href="https://leetcode.com/problems/word-ladder/">Word Ladder</a></td></tr>
|
||||||
@@ -235,14 +250,14 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">433</td><td><a href="https://leetcode.com/problems/minimum-genetic-mutation/">Minimum Genetic Mutation</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">433</td><td><a href="https://leetcode.com/problems/minimum-genetic-mutation/">Minimum Genetic Mutation</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code></td><td align="center">286</td><td><a href="https://leetcode.com/problems/walls-and-gates/">Walls and Gates</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">286</td><td><a href="https://leetcode.com/problems/walls-and-gates/">Walls and Gates</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="4"><strong>18. Topological Sort</strong><br><sub>builds on 16</sub></td>
|
<td rowspan="4"><a href="https://github.com/prdlk/leetcode/issues/20"><strong>18. Topological Sort</strong></a><br><sub>Wk 5 · Mon Sep 14</sub><br><sub>builds on 16</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">207</td><td><a href="https://leetcode.com/problems/course-schedule/">Course Schedule</a></td>
|
<td align="center"><code>A</code></td><td align="center">207</td><td><a href="https://leetcode.com/problems/course-schedule/">Course Schedule</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">210</td><td><a href="https://leetcode.com/problems/course-schedule-ii/">Course Schedule II</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">210</td><td><a href="https://leetcode.com/problems/course-schedule-ii/">Course Schedule II</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code> ⭐</td><td align="center">269</td><td><a href="https://leetcode.com/problems/alien-dictionary/">Alien Dictionary</a></td></tr>
|
<tr><td align="center"><code>B</code> ⭐</td><td align="center">269</td><td><a href="https://leetcode.com/problems/alien-dictionary/">Alien Dictionary</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code></td><td align="center">1462</td><td><a href="https://leetcode.com/problems/course-schedule-iv/">Course Schedule IV</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">1462</td><td><a href="https://leetcode.com/problems/course-schedule-iv/">Course Schedule IV</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="4"><strong>19. Union-Find</strong><br><sub>builds on 16's problem class</sub></td>
|
<td rowspan="4"><a href="https://github.com/prdlk/leetcode/issues/21"><strong>19. Union-Find</strong></a><br><sub>Wk 5 · Tue Sep 15</sub><br><sub>builds on 16's problem class</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">684</td><td><a href="https://leetcode.com/problems/redundant-connection/">Redundant Connection</a></td>
|
<td align="center"><code>A</code></td><td align="center">684</td><td><a href="https://leetcode.com/problems/redundant-connection/">Redundant Connection</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">323</td><td><a href="https://leetcode.com/problems/number-of-connected-components-in-an-undirected-graph/">Number of Connected Components</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">323</td><td><a href="https://leetcode.com/problems/number-of-connected-components-in-an-undirected-graph/">Number of Connected Components</a></td></tr>
|
||||||
@@ -250,10 +265,10 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">721</td><td><a href="https://leetcode.com/problems/accounts-merge/">Accounts Merge</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">721</td><td><a href="https://leetcode.com/problems/accounts-merge/">Accounts Merge</a></td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
---
|
|
||||||
|
|
||||||
## Phase V — Decision-Space Exploration (Weeks 4–5)
|
## Phase V — Decision-Space Exploration · Weeks 5–6
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -265,7 +280,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="8"><strong>20. Backtracking</strong><br><sub>builds on 16 — then unlock LC 212 from topic 15</sub></td>
|
<td rowspan="8"><a href="https://github.com/prdlk/leetcode/issues/22"><strong>20. Backtracking</strong></a><br><sub>Wk 5 · Wed Sep 16</sub><br><sub>builds on 16 — then unlock LC 212 from topic 15</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">78</td><td><a href="https://leetcode.com/problems/subsets/">Subsets</a></td>
|
<td align="center"><code>A</code></td><td align="center">78</td><td><a href="https://leetcode.com/problems/subsets/">Subsets</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">39</td><td><a href="https://leetcode.com/problems/combination-sum/">Combination Sum</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">39</td><td><a href="https://leetcode.com/problems/combination-sum/">Combination Sum</a></td></tr>
|
||||||
@@ -276,7 +291,7 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">22</td><td><a href="https://leetcode.com/problems/generate-parentheses/">Generate Parentheses</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">22</td><td><a href="https://leetcode.com/problems/generate-parentheses/">Generate Parentheses</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code> ⭐</td><td align="center">52</td><td><a href="https://leetcode.com/problems/n-queens-ii/">N-Queens II</a></td></tr>
|
<tr><td align="center"><code>B</code> ⭐</td><td align="center">52</td><td><a href="https://leetcode.com/problems/n-queens-ii/">N-Queens II</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="9"><strong>21. 1-D Dynamic Programming</strong><br><sub>builds on 20</sub></td>
|
<td rowspan="9"><a href="https://github.com/prdlk/leetcode/issues/23"><strong>21. 1-D Dynamic Programming</strong></a><br><sub>Wk 5 · Thu Sep 17</sub><br><sub>builds on 20</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">70</td><td><a href="https://leetcode.com/problems/climbing-stairs/">Climbing Stairs</a></td>
|
<td align="center"><code>A</code></td><td align="center">70</td><td><a href="https://leetcode.com/problems/climbing-stairs/">Climbing Stairs</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">746</td><td><a href="https://leetcode.com/problems/min-cost-climbing-stairs/">Min Cost Climbing Stairs</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">746</td><td><a href="https://leetcode.com/problems/min-cost-climbing-stairs/">Min Cost Climbing Stairs</a></td></tr>
|
||||||
@@ -288,7 +303,7 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">91</td><td><a href="https://leetcode.com/problems/decode-ways/">Decode Ways</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">91</td><td><a href="https://leetcode.com/problems/decode-ways/">Decode Ways</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code></td><td align="center">647</td><td><a href="https://leetcode.com/problems/palindromic-substrings/">Palindromic Substrings</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">647</td><td><a href="https://leetcode.com/problems/palindromic-substrings/">Palindromic Substrings</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="8"><strong>22. Multi-D / Grid DP</strong><br><sub>builds on 21</sub></td>
|
<td rowspan="8"><a href="https://github.com/prdlk/leetcode/issues/24"><strong>22. Multi-D / Grid DP</strong></a><br><sub>Wk 5 · Fri Sep 18</sub><br><sub>builds on 21</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">62</td><td><a href="https://leetcode.com/problems/unique-paths/">Unique Paths</a></td>
|
<td align="center"><code>A</code></td><td align="center">62</td><td><a href="https://leetcode.com/problems/unique-paths/">Unique Paths</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">64</td><td><a href="https://leetcode.com/problems/minimum-path-sum/">Minimum Path Sum</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">64</td><td><a href="https://leetcode.com/problems/minimum-path-sum/">Minimum Path Sum</a></td></tr>
|
||||||
@@ -299,7 +314,7 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">221</td><td><a href="https://leetcode.com/problems/maximal-square/">Maximal Square</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">221</td><td><a href="https://leetcode.com/problems/maximal-square/">Maximal Square</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code> ⭐</td><td align="center">97</td><td><a href="https://leetcode.com/problems/interleaving-string/">Interleaving String</a></td></tr>
|
<tr><td align="center"><code>B</code> ⭐</td><td align="center">97</td><td><a href="https://leetcode.com/problems/interleaving-string/">Interleaving String</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="7"><strong>23. Greedy</strong><br><sub>builds on 21 — includes Kadane's</sub></td>
|
<td rowspan="7"><a href="https://github.com/prdlk/leetcode/issues/25"><strong>23. Greedy</strong></a><br><sub>Wk 6 · Mon Sep 21</sub><br><sub>builds on 21 — includes Kadane's</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">53</td><td><a href="https://leetcode.com/problems/maximum-subarray/">Maximum Subarray</a></td>
|
<td align="center"><code>A</code></td><td align="center">53</td><td><a href="https://leetcode.com/problems/maximum-subarray/">Maximum Subarray</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">55</td><td><a href="https://leetcode.com/problems/jump-game/">Jump Game</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">55</td><td><a href="https://leetcode.com/problems/jump-game/">Jump Game</a></td></tr>
|
||||||
@@ -309,7 +324,7 @@
|
|||||||
<tr><td align="center"><code>B</code></td><td align="center">918</td><td><a href="https://leetcode.com/problems/maximum-sum-circular-subarray/">Maximum Sum Circular Subarray</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">918</td><td><a href="https://leetcode.com/problems/maximum-sum-circular-subarray/">Maximum Sum Circular Subarray</a></td></tr>
|
||||||
<tr><td align="center"><code>B</code></td><td align="center">763</td><td><a href="https://leetcode.com/problems/partition-labels/">Partition Labels</a></td></tr>
|
<tr><td align="center"><code>B</code></td><td align="center">763</td><td><a href="https://leetcode.com/problems/partition-labels/">Partition Labels</a></td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="7"><strong>24. Intervals</strong><br><sub>builds on 23</sub></td>
|
<td rowspan="7"><a href="https://github.com/prdlk/leetcode/issues/26"><strong>24. Intervals</strong></a><br><sub>Wk 6 · Tue Sep 22</sub><br><sub>builds on 23</sub></td>
|
||||||
<td align="center"><code>A</code></td><td align="center">57</td><td><a href="https://leetcode.com/problems/insert-interval/">Insert Interval</a></td>
|
<td align="center"><code>A</code></td><td align="center">57</td><td><a href="https://leetcode.com/problems/insert-interval/">Insert Interval</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td align="center"><code>A</code></td><td align="center">56</td><td><a href="https://leetcode.com/problems/merge-intervals/">Merge Intervals</a></td></tr>
|
<tr><td align="center"><code>A</code></td><td align="center">56</td><td><a href="https://leetcode.com/problems/merge-intervals/">Merge Intervals</a></td></tr>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
GH_PAT=github_pat_or_gho_token_with_issues_and_projects_rw
|
||||||
|
WEBHOOK_SECRET=random_hex_matching_the_repo_webhook
|
||||||
|
LINK_KEY=random_hex_signing_one_tap_links
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# srs-api
|
||||||
|
|
||||||
|
Cloudflare Worker running the spaced-repetition system: daily digest email,
|
||||||
|
one-tap logging, `/done` webhook, Saturday review issues, live SVG charts,
|
||||||
|
and the `/api/stats` feed for the docs progress page.
|
||||||
|
|
||||||
|
Live at `https://srs-api.prdlk.workers.dev`.
|
||||||
|
|
||||||
|
## Direction of truth
|
||||||
|
|
||||||
|
- **D1 owns SRS state** — stages, review dates, attempt history, gate
|
||||||
|
scores, boost flags. Nothing else is authoritative.
|
||||||
|
- **GitHub issues own the catalog** — topics, problems, `set:*`/`diff:*`
|
||||||
|
labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via
|
||||||
|
`/admin/reconcile`); it recomputes from scratch, never invents rows, and
|
||||||
|
never overwrites SRS-owned columns (`stage`, `next_review`).
|
||||||
|
- **The repo owns the schedule** — `data/schedule.json`, human-edited,
|
||||||
|
bundled at deploy. Git history is its audit log.
|
||||||
|
- The GitHub Project mirror (`Target Date`, `SRS Stage`, `First Attempt`)
|
||||||
|
is best-effort: failures log warnings, never block a D1 write. Topic rows
|
||||||
|
are never written.
|
||||||
|
|
||||||
|
## The ladder
|
||||||
|
|
||||||
|
`new → +2 → +5 → +10 → retired`; pass advances, fail resets to `+2`.
|
||||||
|
A problem's first-ever log enters at `+2` regardless of result. Stage names
|
||||||
|
the NEXT review's interval. All dates are ET calendar strings anchored at
|
||||||
|
noon UTC (`src/srs.ts`) — never raw `Date` math.
|
||||||
|
|
||||||
|
Blind drills draw only from topics **already learned**: scheduled in an
|
||||||
|
earlier week (the current week's optional pool is reserved for Saturday's
|
||||||
|
gate) and with at least one core problem on the ladder — a skipped learning
|
||||||
|
day never feeds drills just because its calendar week lapsed.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Route | Auth | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /log?p&r&d&sig` | HMAC (`LINK_KEY`) | one-tap pass/fail; idempotent per (problem, date, kind); links expire after 3 days |
|
||||||
|
| `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)
|
||||||
|
|
||||||
|
Each event has two UTC crons; code fires only when the computed ET hour
|
||||||
|
matches (`etHour` in `src/srs.ts`, unit-checked in `src/srs.test.ts`):
|
||||||
|
|
||||||
|
- `0 12,13 * * *` → 8 AM ET: catalog reconcile, then the digest.
|
||||||
|
- `0 4,5 * * 6` → midnight ET Saturday: create `Review — Week N` (so the
|
||||||
|
8 AM digest can link to it).
|
||||||
|
|
||||||
|
## Secrets & bindings
|
||||||
|
|
||||||
|
`wrangler secret put` — `GH_PAT` (Issues + Projects RW), `WEBHOOK_SECRET`
|
||||||
|
(matches the repo webhook), `LINK_KEY` (signs one-tap links, gates admin
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .dev.vars.example .dev.vars # or fill GH_PAT/WEBHOOK_SECRET/LINK_KEY
|
||||||
|
bun install
|
||||||
|
bunx wrangler d1 migrations apply srs --local
|
||||||
|
bun run dev # wrangler dev on :8787, local D1
|
||||||
|
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
||||||
|
"localhost:8787/admin/digest?dry=1&date=2026-08-31" # prints HTML, sends nothing
|
||||||
|
bun test src # DST guard + date math
|
||||||
|
```
|
||||||
|
|
||||||
|
`?date=` on admin routes is the `SRS_TODAY` equivalent. The one-shot
|
||||||
|
migration from the retired `.github/srs/srs.json` lives at
|
||||||
|
`scripts/import-srs.ts` (`--remote` for production D1); it is re-runnable —
|
||||||
|
import-sourced attempts are wiped and re-inserted.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run deploy # from apps/api/, or `bun run api:deploy` at the repo root
|
||||||
|
```
|
||||||
|
|
||||||
|
CI deploys on every push to `main` (`deploy.yml`, gated on `bun run api:test`,
|
||||||
|
using the `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_ACCOUNT_ID` repo secrets); the
|
||||||
|
commands above are for out-of-band deploys. After changing bindings, rerun
|
||||||
|
`bunx wrangler types`.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"2026-08-17": 3,
|
||||||
|
"2026-08-18": 4,
|
||||||
|
"2026-08-24": 5,
|
||||||
|
"2026-08-25": 6,
|
||||||
|
"2026-08-26": 7,
|
||||||
|
"2026-08-27": 8,
|
||||||
|
"2026-08-28": 9,
|
||||||
|
"2026-08-31": 10,
|
||||||
|
"2026-09-01": 11,
|
||||||
|
"2026-09-02": 12,
|
||||||
|
"2026-09-03": 13,
|
||||||
|
"2026-09-04": 14,
|
||||||
|
"2026-09-07": 15,
|
||||||
|
"2026-09-08": 16,
|
||||||
|
"2026-09-09": 17,
|
||||||
|
"2026-09-10": 18,
|
||||||
|
"2026-09-11": 19,
|
||||||
|
"2026-09-14": 20,
|
||||||
|
"2026-09-15": 21,
|
||||||
|
"2026-09-16": 22,
|
||||||
|
"2026-09-17": 23,
|
||||||
|
"2026-09-18": 24,
|
||||||
|
"2026-09-21": 25,
|
||||||
|
"2026-09-22": 26
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
-- SRS schema. D1 owns SRS state (stage, next_review, attempts, gates,
|
||||||
|
-- boosts); GitHub issues own the catalog columns, reconciled nightly.
|
||||||
|
CREATE TABLE problems (
|
||||||
|
lc_number INTEGER PRIMARY KEY,
|
||||||
|
issue INTEGER NOT NULL,
|
||||||
|
topic_issue INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
difficulty TEXT NOT NULL, -- easy|medium|hard
|
||||||
|
set_label TEXT NOT NULL, -- core|optional|deferred
|
||||||
|
stage TEXT NOT NULL DEFAULT 'new', -- new|+2|+5|+10|retired
|
||||||
|
next_review TEXT, -- YYYY-MM-DD ET, NULL when retired/unsolved
|
||||||
|
defer_until TEXT -- deferred Hards: 2026-09-28+
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE attempts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
lc_number INTEGER NOT NULL REFERENCES problems(lc_number),
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL, -- first|review|drill|gate
|
||||||
|
result TEXT NOT NULL, -- pass|fail
|
||||||
|
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. 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);
|
||||||
|
|
||||||
|
CREATE TABLE topics (
|
||||||
|
issue INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
misses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
boost INTEGER NOT NULL DEFAULT 0,
|
||||||
|
milestone INTEGER -- phase; from the topic issue
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE gates (
|
||||||
|
week INTEGER PRIMARY KEY, -- ISO week
|
||||||
|
issue INTEGER,
|
||||||
|
problems TEXT NOT NULL, -- JSON array of lc_numbers
|
||||||
|
pass_rate REAL,
|
||||||
|
closed_on TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE drill_pool_used (lc_number INTEGER PRIMARY KEY);
|
||||||
|
|
||||||
|
-- Digest idempotency: one email per ET date.
|
||||||
|
CREATE TABLE email_log (
|
||||||
|
date TEXT PRIMARY KEY, -- YYYY-MM-DD ET
|
||||||
|
sent_at TEXT NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "srs-api",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev --test-scheduled",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"types": "wrangler types",
|
||||||
|
"test": "bun test src"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"wrangler": "^4.125.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-email/components": "^1.0.12",
|
||||||
|
"@react-email/render": "^2.1.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+94
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* One-shot migration: .github/srs/srs.json → D1.
|
||||||
|
*
|
||||||
|
* Run AFTER the catalog reconcile has filled `problems` (the import only
|
||||||
|
* overlays SRS-owned fields — stage, next_review, defer_until — and inserts
|
||||||
|
* attempts/topic counters). Re-runnable without duplicates: import-sourced
|
||||||
|
* attempts are wiped and re-inserted, everything else upserts.
|
||||||
|
*
|
||||||
|
* bun apps/api/scripts/import-srs.ts # local D1 (wrangler dev state)
|
||||||
|
* bun apps/api/scripts/import-srs.ts --remote # production D1
|
||||||
|
*
|
||||||
|
* Prints row counts; verify them against srs.json before deleting anything.
|
||||||
|
*/
|
||||||
|
import { $ } from "bun";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
|
const API = join(ROOT, "api");
|
||||||
|
const REMOTE = process.argv.includes("--remote");
|
||||||
|
|
||||||
|
interface Attempt {
|
||||||
|
date: string;
|
||||||
|
kind: string;
|
||||||
|
result: string;
|
||||||
|
}
|
||||||
|
interface Problem {
|
||||||
|
issue: number;
|
||||||
|
topic: number;
|
||||||
|
difficulty: string;
|
||||||
|
set: string;
|
||||||
|
solved_on?: string;
|
||||||
|
stage: string;
|
||||||
|
next_review?: string;
|
||||||
|
defer_until?: string;
|
||||||
|
history: Attempt[];
|
||||||
|
}
|
||||||
|
interface State {
|
||||||
|
problems: Record<string, Problem>;
|
||||||
|
topics: Record<string, { misses: number; boost: boolean }>;
|
||||||
|
drill_pool_used: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: State = JSON.parse(
|
||||||
|
await Bun.file(join(ROOT, ".github", "srs", "srs.json")).text(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const q = (v: string | null | undefined) => (v == null ? "NULL" : `'${v}'`);
|
||||||
|
const lines: string[] = ["DELETE FROM attempts WHERE source = 'import';"];
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
for (const [lc, p] of Object.entries(state.problems)) {
|
||||||
|
lines.push(
|
||||||
|
`UPDATE problems SET stage = ${q(p.stage)}, next_review = ${q(p.next_review ?? null)}, ` +
|
||||||
|
`defer_until = ${q(p.defer_until ?? null)} WHERE lc_number = ${Number(lc)};`,
|
||||||
|
);
|
||||||
|
for (const a of p.history) {
|
||||||
|
lines.push(
|
||||||
|
`INSERT INTO attempts (lc_number, date, kind, result, source) ` +
|
||||||
|
`VALUES (${Number(lc)}, ${q(a.date)}, ${q(a.kind)}, ${q(a.result)}, 'import');`,
|
||||||
|
);
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [topic, t] of Object.entries(state.topics)) {
|
||||||
|
lines.push(
|
||||||
|
`UPDATE topics SET misses = ${t.misses}, boost = ${t.boost ? 1 : 0} WHERE issue = ${Number(topic)};`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const lc of state.drill_pool_used) {
|
||||||
|
lines.push(`INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (${lc});`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlPath = join(API, "migrations", ".import.sql");
|
||||||
|
await Bun.write(sqlPath, lines.join("\n") + "\n");
|
||||||
|
const flag = REMOTE ? "--remote" : "--local";
|
||||||
|
await $`bunx wrangler d1 execute srs ${flag} --file ${sqlPath}`.cwd(API);
|
||||||
|
await $`rm ${sqlPath}`;
|
||||||
|
|
||||||
|
const counts =
|
||||||
|
await $`bunx wrangler d1 execute srs ${flag} --json --command ${"SELECT (SELECT COUNT(*) FROM problems) AS problems, (SELECT COUNT(*) FROM problems WHERE stage != 'new') AS laddered, (SELECT COUNT(*) FROM problems WHERE defer_until IS NOT NULL) AS deferred, (SELECT COUNT(*) FROM attempts WHERE source='import') AS imported_attempts, (SELECT COUNT(*) FROM topics) AS topics, (SELECT COUNT(*) FROM drill_pool_used) AS drills_used"}`
|
||||||
|
.cwd(API)
|
||||||
|
.json();
|
||||||
|
|
||||||
|
const expected = {
|
||||||
|
json_problems: Object.keys(state.problems).length,
|
||||||
|
json_attempts: attempts,
|
||||||
|
json_laddered: Object.values(state.problems).filter((p) => p.stage !== "new").length,
|
||||||
|
json_deferred: Object.values(state.problems).filter((p) => p.defer_until).length,
|
||||||
|
json_topics: Object.keys(state.topics).length,
|
||||||
|
json_drills_used: state.drill_pool_used.length,
|
||||||
|
};
|
||||||
|
console.log("expected from srs.json:", JSON.stringify(expected));
|
||||||
|
console.log("in D1:", JSON.stringify(counts[0]?.results?.[0] ?? counts));
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Nightly GitHub → D1 catalog reconcile. GitHub issues own the catalog
|
||||||
|
* (topics, problems, set/diff labels, milestones); this recomputes desired
|
||||||
|
* rows from scratch on every run — re-runs are no-ops. SRS-owned columns
|
||||||
|
* (stage, next_review) are NEVER overwritten; defer_until is set once, on
|
||||||
|
* first sight of a deferred problem. The Worker never invents catalog rows.
|
||||||
|
*/
|
||||||
|
import { addDays } from "./srs.ts";
|
||||||
|
import type { GitHub } from "./github.ts";
|
||||||
|
|
||||||
|
const TITLE_RE = /^LC (\d+) · (.+) · (Easy|Medium|Hard) · (core|optional|deferred)$/;
|
||||||
|
|
||||||
|
/** Deferred Hards enter the queue from this date, two per day. */
|
||||||
|
const DEFER_FROM = "2026-09-28";
|
||||||
|
|
||||||
|
export interface ReconcileReport {
|
||||||
|
topics: number;
|
||||||
|
problems: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<ReconcileReport> {
|
||||||
|
interface TopicIssue {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
milestone: number | null;
|
||||||
|
}
|
||||||
|
const topics: TopicIssue[] = [];
|
||||||
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
||||||
|
if (!raw || typeof raw !== "object") continue;
|
||||||
|
if ("pull_request" in raw) continue;
|
||||||
|
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||||
|
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||||
|
let milestone: number | null = null;
|
||||||
|
if (
|
||||||
|
"milestone" in raw &&
|
||||||
|
raw.milestone &&
|
||||||
|
typeof raw.milestone === "object" &&
|
||||||
|
"number" in raw.milestone &&
|
||||||
|
typeof raw.milestone.number === "number"
|
||||||
|
) {
|
||||||
|
milestone = raw.milestone.number;
|
||||||
|
}
|
||||||
|
topics.push({ number: raw.number, title: raw.title, milestone });
|
||||||
|
}
|
||||||
|
|
||||||
|
const statements: D1PreparedStatement[] = [];
|
||||||
|
for (const t of topics) {
|
||||||
|
statements.push(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO topics (issue, name, milestone) VALUES (?1, ?2, ?3)
|
||||||
|
ON CONFLICT(issue) DO UPDATE SET name = ?2, milestone = ?3`,
|
||||||
|
)
|
||||||
|
.bind(t.number, t.title.replace(/^Topic \d+ — /, ""), t.milestone),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let problems = 0;
|
||||||
|
let deferredSeen = 0;
|
||||||
|
for (const t of topics) {
|
||||||
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) {
|
||||||
|
if (!raw || typeof raw !== "object") continue;
|
||||||
|
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||||
|
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||||
|
const m = raw.title.match(TITLE_RE);
|
||||||
|
if (!m) continue; // non-curriculum sub-issue
|
||||||
|
const lc = Number(m[1]);
|
||||||
|
const set = m[4]!;
|
||||||
|
// Two deferred Hards per day from DEFER_FROM, in catalog walk order —
|
||||||
|
// applied only when the row is first created (SRS owns it afterwards).
|
||||||
|
const defer = set === "deferred" ? addDays(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
|
||||||
|
if (set === "deferred") deferredSeen++;
|
||||||
|
statements.push(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO problems (lc_number, issue, topic_issue, title, difficulty, set_label, defer_until, next_review)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
|
||||||
|
ON CONFLICT(lc_number) DO UPDATE SET
|
||||||
|
issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`,
|
||||||
|
)
|
||||||
|
.bind(lc, raw.number, t.number, m[2]!, m[3]!.toLowerCase(), set, defer),
|
||||||
|
);
|
||||||
|
problems++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// D1 batches are transactional; chunk to stay under statement limits.
|
||||||
|
for (let i = 0; i < statements.length; i += 50) {
|
||||||
|
await db.batch(statements.slice(i, i + 50));
|
||||||
|
}
|
||||||
|
return { topics: topics.length, problems };
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
/**
|
||||||
|
* Hand-rolled SVG chart generation for the SRS Worker.
|
||||||
|
*
|
||||||
|
* Feeds five endpoints — /chart/progress.svg, /chart/ladder.svg,
|
||||||
|
* /chart/heatmap.svg, /chart/heatmap.png, /badge/gate.svg — each served with
|
||||||
|
* `Cache-Control: public, max-age=300`. The heatmap has a raster twin because
|
||||||
|
* every major email client blocks SVG, so the digest cannot embed the vector.
|
||||||
|
*
|
||||||
|
* The SVG is string-built: no chart library, no dependencies, and the one
|
||||||
|
* import is the hand-rolled PNG encoder next door. The only dynamic values
|
||||||
|
* entering the markup are numbers, percentages, and dates the Worker itself
|
||||||
|
* computes, plus the fixed phase/stage labels — so nothing here needs XML
|
||||||
|
* escaping. Styling is shadcn dark zinc to match the README's shieldcn
|
||||||
|
* badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode
|
||||||
|
* green ramp. Every function renders on a zero-row DB.
|
||||||
|
*/
|
||||||
|
import { Canvas, textWidth } from "./png.ts";
|
||||||
|
|
||||||
|
// D1Database comes from the generated worker-configuration.d.ts runtime types.
|
||||||
|
|
||||||
|
const FONT = "Verdana,DejaVu Sans,sans-serif";
|
||||||
|
|
||||||
|
// shadcn dark-zinc palette, matching the README's shieldcn badges.
|
||||||
|
const BG = "#09090b"; // zinc-950 card
|
||||||
|
const FG = "#fafafa"; // zinc-50 text
|
||||||
|
const MUTED = "#a1a1aa"; // zinc-400 secondary text
|
||||||
|
const LINE = "#27272a"; // zinc-800 structure / empty
|
||||||
|
const GREEN = "#16a34a"; // green-600 (core / pass)
|
||||||
|
const BLUE = "#2563eb"; // blue-600 (optional)
|
||||||
|
const RED = "#f87171"; // red-400 (fail, on dark)
|
||||||
|
|
||||||
|
// Green ramp shared by the heatmap and the ladder (GitHub dark-mode
|
||||||
|
// contribution hues; level 0 is the empty zinc cell).
|
||||||
|
const GREENS = ["#27272a", "#0e4429", "#006d32", "#26a641", "#39d353"];
|
||||||
|
|
||||||
|
// Labels are stored SVG-ready: phase II's "&" is pre-escaped since these
|
||||||
|
// strings go straight into markup and nothing else here needs escaping.
|
||||||
|
const PHASES = [
|
||||||
|
{ milestone: 1, name: "I — Linear" },
|
||||||
|
{ milestone: 2, name: "II — Nodal & Grid" },
|
||||||
|
{ milestone: 3, name: "III — Hierarchical" },
|
||||||
|
{ milestone: 4, name: "IV — Relational" },
|
||||||
|
{ milestone: 5, name: "V — Decision Space" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STAGES = ["new", "+2", "+5", "+10", "retired"];
|
||||||
|
|
||||||
|
// ── svg helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Opening tag plus the rounded dark card every chart starts with. */
|
||||||
|
function svgOpen(width: number, height: number): string {
|
||||||
|
return (
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
|
||||||
|
`viewBox="0 0 ${width} ${height}" font-family="${FONT}">` +
|
||||||
|
`<rect width="${width}" height="${height}" rx="6" fill="${BG}"/>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A <text> element; charts place dozens of these with the same defaults. */
|
||||||
|
function text(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
content: string,
|
||||||
|
attrs: { size?: number; fill?: string; anchor?: string; weight?: string } = {},
|
||||||
|
): string {
|
||||||
|
const { size = 12, fill = FG, anchor = "start", weight } = attrs;
|
||||||
|
const bold = weight ? ` font-weight="${weight}"` : "";
|
||||||
|
return `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" text-anchor="${anchor}"${bold}>${content}</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── progress: stacked bar per phase ──────────────────────────────
|
||||||
|
|
||||||
|
interface ProgressRow {
|
||||||
|
milestone: number;
|
||||||
|
set_label: string;
|
||||||
|
done: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function progressChart(db: D1Database): Promise<string> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT t.milestone AS milestone, p.set_label AS set_label,
|
||||||
|
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done,
|
||||||
|
COUNT(*) AS total
|
||||||
|
FROM problems p JOIN topics t ON p.topic_issue = t.issue
|
||||||
|
WHERE t.milestone IS NOT NULL
|
||||||
|
GROUP BY t.milestone, p.set_label`,
|
||||||
|
)
|
||||||
|
.all<ProgressRow>();
|
||||||
|
|
||||||
|
// Per phase: core done / optional done / remaining (all-set total − all done).
|
||||||
|
const phases = PHASES.map((phase) => {
|
||||||
|
const rows = results.filter((r) => r.milestone === phase.milestone);
|
||||||
|
const total = rows.reduce((n, r) => n + r.total, 0);
|
||||||
|
const done = rows.reduce((n, r) => n + r.done, 0);
|
||||||
|
return {
|
||||||
|
name: phase.name,
|
||||||
|
coreDone: rows.find((r) => r.set_label === "core")?.done ?? 0,
|
||||||
|
optionalDone: rows.find((r) => r.set_label === "optional")?.done ?? 0,
|
||||||
|
remaining: total - done,
|
||||||
|
done,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const width = 640;
|
||||||
|
const height = 300;
|
||||||
|
const barX = 168;
|
||||||
|
const barMaxW = 400;
|
||||||
|
const barH = 22;
|
||||||
|
const rowStep = 48;
|
||||||
|
const scale = Math.max(1, ...phases.map((p) => p.total));
|
||||||
|
|
||||||
|
const parts = [svgOpen(width, height)];
|
||||||
|
|
||||||
|
// Legend on top.
|
||||||
|
const legend: [string, string][] = [
|
||||||
|
[GREEN, "core done"],
|
||||||
|
[BLUE, "optional done"],
|
||||||
|
[LINE, "remaining"],
|
||||||
|
];
|
||||||
|
let lx = barX;
|
||||||
|
for (const [color, label] of legend) {
|
||||||
|
parts.push(`<rect x="${lx}" y="12" width="12" height="12" rx="2" fill="${color}"/>`);
|
||||||
|
parts.push(text(lx + 17, 22, label, { size: 11, fill: MUTED }));
|
||||||
|
lx += 17 + label.length * 7 + 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
phases.forEach((p, i) => {
|
||||||
|
const y = 52 + i * rowStep;
|
||||||
|
const midY = y + barH / 2 + 4;
|
||||||
|
parts.push(text(barX - 12, midY, p.name, { anchor: "end" }));
|
||||||
|
|
||||||
|
let x = barX;
|
||||||
|
const segments: [number, string][] = [
|
||||||
|
[p.coreDone, GREEN],
|
||||||
|
[p.optionalDone, BLUE],
|
||||||
|
[p.remaining, LINE],
|
||||||
|
];
|
||||||
|
for (const [count, color] of segments) {
|
||||||
|
const w = (count / scale) * barMaxW;
|
||||||
|
if (w > 0) {
|
||||||
|
parts.push(`<rect x="${x.toFixed(1)}" y="${y}" width="${w.toFixed(1)}" height="${barH}" fill="${color}"/>`);
|
||||||
|
// Count inside the segment when it fits; tiny slivers stay unlabeled.
|
||||||
|
if (w >= 20) {
|
||||||
|
const labelFill = color === LINE ? MUTED : FG;
|
||||||
|
parts.push(text(x + w / 2, midY, String(count), { size: 11, fill: labelFill, anchor: "middle" }));
|
||||||
|
}
|
||||||
|
x += w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(text(x + 8, midY, `${p.done}/${p.total}`, { size: 11, fill: MUTED }));
|
||||||
|
});
|
||||||
|
|
||||||
|
parts.push("</svg>");
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ladder: bar per stage ────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function ladderChart(db: D1Database): Promise<string> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`)
|
||||||
|
.all<{ stage: string; count: number }>();
|
||||||
|
|
||||||
|
const counts = STAGES.map((s) => results.find((r) => r.stage === s)?.count ?? 0);
|
||||||
|
|
||||||
|
const width = 640;
|
||||||
|
const height = 220;
|
||||||
|
const plotTop = 26;
|
||||||
|
const plotBottom = height - 32;
|
||||||
|
const plotH = plotBottom - plotTop;
|
||||||
|
const slotW = (width - 80) / STAGES.length;
|
||||||
|
const barW = 64;
|
||||||
|
const scale = Math.max(1, ...counts);
|
||||||
|
|
||||||
|
const parts = [svgOpen(width, height)];
|
||||||
|
|
||||||
|
counts.forEach((count, i) => {
|
||||||
|
const cx = 40 + slotW * i + slotW / 2;
|
||||||
|
const barH = (count / scale) * plotH;
|
||||||
|
const y = plotBottom - barH;
|
||||||
|
if (barH > 0) {
|
||||||
|
parts.push(
|
||||||
|
`<rect x="${(cx - barW / 2).toFixed(1)}" y="${y.toFixed(1)}" width="${barW}" height="${barH.toFixed(1)}" rx="3" fill="${GREENS[i]}"/>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parts.push(text(cx, Math.max(y - 6, 16), String(count), { size: 12, anchor: "middle", weight: "bold" }));
|
||||||
|
parts.push(text(cx, plotBottom + 18, STAGES[i]!, { size: 12, fill: MUTED, anchor: "middle" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
parts.push(`<line x1="40" y1="${plotBottom}" x2="${width - 40}" y2="${plotBottom}" stroke="${LINE}"/>`);
|
||||||
|
parts.push("</svg>");
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── heatmap: attempts per campaign day ───────────────────────────
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
// Layout in CSS pixels. heatmapPng multiplies every one of these by its device
|
||||||
|
// scale, so the vector and raster pictures cannot drift apart.
|
||||||
|
const HEAT_W = 560;
|
||||||
|
const HEAT_H = 160;
|
||||||
|
const HEAT_GRID_X = 34;
|
||||||
|
const HEAT_GRID_Y = 24;
|
||||||
|
const HEAT_CELL = 16;
|
||||||
|
const HEAT_STEP = 19; // cell + 3px gap
|
||||||
|
|
||||||
|
// Grid row → left-hand label; both renderers print only these three.
|
||||||
|
const WEEKDAYS: [number, string][] = [
|
||||||
|
[0, "Mon"],
|
||||||
|
[2, "Wed"],
|
||||||
|
[4, "Fri"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** One grid square: column, Mon-based row, and index into GREENS. */
|
||||||
|
interface HeatmapCell {
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The heatmap's whole data model — one cell per campaign day, plus the week
|
||||||
|
* count that positions the legend — shared by both renderers.
|
||||||
|
*
|
||||||
|
* Every per-day date derives from one UTC-midnight timestamp, so local-timezone
|
||||||
|
* drift never shifts a cell. The campaign starts on a Monday, so day i sits at
|
||||||
|
* column i/7; the row comes from the real weekday, so an off-Monday start still
|
||||||
|
* lands correctly.
|
||||||
|
*/
|
||||||
|
async function heatmapCells(
|
||||||
|
db: D1Database,
|
||||||
|
start: string,
|
||||||
|
days: number,
|
||||||
|
): Promise<{ cells: HeatmapCell[]; weeks: number }> {
|
||||||
|
const [sy, sm, sd] = start.split("-").map(Number);
|
||||||
|
const base = Date.UTC(sy!, sm! - 1, sd!);
|
||||||
|
const end = new Date(base + (days - 1) * DAY_MS).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(`SELECT date, COUNT(*) AS attempts FROM attempts WHERE date >= ?1 AND date <= ?2 GROUP BY date`)
|
||||||
|
.bind(start, end)
|
||||||
|
.all<{ date: string; attempts: number }>();
|
||||||
|
|
||||||
|
const byDate = new Map(results.map((r) => [r.date, r.attempts]));
|
||||||
|
|
||||||
|
const cells: HeatmapCell[] = [];
|
||||||
|
for (let i = 0; i < days; i++) {
|
||||||
|
const day = new Date(base + i * DAY_MS);
|
||||||
|
const attempts = byDate.get(day.toISOString().slice(0, 10)) ?? 0;
|
||||||
|
cells.push({
|
||||||
|
col: Math.floor(i / 7),
|
||||||
|
row: (day.getUTCDay() + 6) % 7, // Mon = 0
|
||||||
|
level: Math.min(attempts, 4),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cells, weeks: Math.ceil(days / 7) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function heatmapChart(db: D1Database, start: string, days: number): Promise<string> {
|
||||||
|
const { cells, weeks } = await heatmapCells(db, start, days);
|
||||||
|
|
||||||
|
const parts = [svgOpen(HEAT_W, HEAT_H)];
|
||||||
|
|
||||||
|
// Week numbers across the top, Mon/Wed/Fri down the left.
|
||||||
|
for (let w = 0; w < weeks; w++) {
|
||||||
|
parts.push(
|
||||||
|
text(HEAT_GRID_X + w * HEAT_STEP + HEAT_CELL / 2, HEAT_GRID_Y - 7, `W${w + 1}`, {
|
||||||
|
size: 10,
|
||||||
|
fill: MUTED,
|
||||||
|
anchor: "middle",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [row, label] of WEEKDAYS) {
|
||||||
|
parts.push(
|
||||||
|
text(HEAT_GRID_X - 6, HEAT_GRID_Y + row * HEAT_STEP + HEAT_CELL - 4, label, {
|
||||||
|
size: 10,
|
||||||
|
fill: MUTED,
|
||||||
|
anchor: "end",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { col, row, level } of cells) {
|
||||||
|
parts.push(
|
||||||
|
`<rect x="${HEAT_GRID_X + col * HEAT_STEP}" y="${HEAT_GRID_Y + row * HEAT_STEP}" width="${HEAT_CELL}" height="${HEAT_CELL}" rx="2" fill="${GREENS[level]}"/>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Less → More ramp fills the space right of the grid.
|
||||||
|
const legendX = HEAT_GRID_X + weeks * HEAT_STEP + 40;
|
||||||
|
const legendY = HEAT_GRID_Y + 3 * HEAT_STEP;
|
||||||
|
parts.push(text(legendX - 6, legendY + HEAT_CELL - 4, "Less", { size: 10, fill: MUTED, anchor: "end" }));
|
||||||
|
GREENS.forEach((color, i) => {
|
||||||
|
parts.push(
|
||||||
|
`<rect x="${legendX + i * HEAT_STEP}" y="${legendY}" width="${HEAT_CELL}" height="${HEAT_CELL}" rx="2" fill="${color}"/>`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
parts.push(text(legendX + GREENS.length * HEAT_STEP + 3, legendY + HEAT_CELL - 4, "More", { size: 10, fill: MUTED }));
|
||||||
|
|
||||||
|
parts.push("</svg>");
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The PNG carries no alpha channel, so whatever the rounded card does not cover
|
||||||
|
// has to be painted with the colour sitting behind the image: the digest
|
||||||
|
// email's GitHub-dark card.
|
||||||
|
const EMAIL_CARD = "#0d1117";
|
||||||
|
|
||||||
|
// Device-pixel multiplier. The email hands the image the full 552px card
|
||||||
|
// width, so 3x lands a little over 2x density on a retina screen; the labels
|
||||||
|
// stay 10 CSS px, the size of the SVG's. Flat colour compresses to a few KB
|
||||||
|
// either way, so the extra resolution is close to free.
|
||||||
|
const HEAT_SCALE = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same picture as heatmapChart, rastered for email.
|
||||||
|
*
|
||||||
|
* Two deliberate departures from the SVG. The canvas is trimmed to the width
|
||||||
|
* the content actually occupies instead of HEAT_W: the README's SVG sits in a
|
||||||
|
* narrow table cell and scales up, whereas the email gives the image the full
|
||||||
|
* 552px card, and the SVG's slack right margin would otherwise shrink the
|
||||||
|
* grid to nothing. And the bitmap font hangs off a top-left origin rather than
|
||||||
|
* a baseline, so each SVG baseline becomes "baseline minus one cap height".
|
||||||
|
*/
|
||||||
|
export async function heatmapPng(db: D1Database, start: string, days: number): Promise<Uint8Array> {
|
||||||
|
const { cells, weeks } = await heatmapCells(db, start, days);
|
||||||
|
|
||||||
|
const s = HEAT_SCALE;
|
||||||
|
const gridX = HEAT_GRID_X * s;
|
||||||
|
const gridY = HEAT_GRID_Y * s;
|
||||||
|
const cell = HEAT_CELL * s;
|
||||||
|
const step = HEAT_STEP * s;
|
||||||
|
const capH = 7 * s;
|
||||||
|
|
||||||
|
const legendX = gridX + weeks * step + 40 * s;
|
||||||
|
const legendY = gridY + 3 * step;
|
||||||
|
const moreX = legendX + GREENS.length * step + 3 * s;
|
||||||
|
const width = moreX + textWidth("More", s) + gridX;
|
||||||
|
const height = HEAT_H * s;
|
||||||
|
|
||||||
|
const canvas = new Canvas(width, height, EMAIL_CARD);
|
||||||
|
canvas.rect(0, 0, width, height, BG, 6 * s);
|
||||||
|
|
||||||
|
for (let w = 0; w < weeks; w++) {
|
||||||
|
const label = `W${w + 1}`;
|
||||||
|
const middle = gridX + w * step + cell / 2;
|
||||||
|
canvas.text(middle - textWidth(label, s) / 2, gridY - 7 * s - capH, label, MUTED, s);
|
||||||
|
}
|
||||||
|
for (const [row, label] of WEEKDAYS) {
|
||||||
|
canvas.text(
|
||||||
|
gridX - 6 * s - textWidth(label, s),
|
||||||
|
gridY + row * step + cell - 4 * s - capH,
|
||||||
|
label,
|
||||||
|
MUTED,
|
||||||
|
s,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { col, row, level } of cells) {
|
||||||
|
canvas.rect(gridX + col * step, gridY + row * step, cell, cell, GREENS[level]!, 2 * s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const legendBase = legendY + cell - 4 * s - capH;
|
||||||
|
canvas.text(legendX - 6 * s - textWidth("Less", s), legendBase, "Less", MUTED, s);
|
||||||
|
GREENS.forEach((color, i) => {
|
||||||
|
canvas.rect(legendX + i * step, legendY, cell, cell, color, 2 * s);
|
||||||
|
});
|
||||||
|
canvas.text(moreX, legendBase, "More", MUTED, s);
|
||||||
|
|
||||||
|
return await canvas.encode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── gate badge: shieldcn-style dark pill ─────────────────────────
|
||||||
|
|
||||||
|
export async function gateBadge(db: D1Database): Promise<string> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(`SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1`)
|
||||||
|
.first<{ pass_rate: number }>();
|
||||||
|
|
||||||
|
const label = "gate";
|
||||||
|
const value = row ? `${Math.round(row.pass_rate * 100)}%` : "none yet";
|
||||||
|
const valueFill = row ? (row.pass_rate >= 0.7 ? "#4ade80" : RED) : MUTED;
|
||||||
|
|
||||||
|
// shieldcn geometry: height 32, rx 6, one flat zinc-900 pill. Verdana 13px
|
||||||
|
// ≈ 7.5px per char; 12px outer padding, 8px between label and value.
|
||||||
|
const labelW = Math.round(label.length * 7.5);
|
||||||
|
const valueW = Math.round(value.length * 7.5);
|
||||||
|
const total = 12 + labelW + 8 + valueW + 12;
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="32" ` +
|
||||||
|
`viewBox="0 0 ${total} 32" role="img" aria-label="${label}: ${value}" font-family="${FONT}">` +
|
||||||
|
`<rect width="${total}" height="32" rx="6" fill="#18181b"/>` +
|
||||||
|
`<g font-size="13">` +
|
||||||
|
`<text x="12" y="21" fill="${FG}" fill-opacity=".7">${label}</text>` +
|
||||||
|
`<text x="${12 + labelW + 8}" y="21" fill="${valueFill}" font-weight="bold">${value}</text>` +
|
||||||
|
`</g>` +
|
||||||
|
`</svg>`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* The daily digest email — built from D1 + the bundled schedule, sent at
|
||||||
|
* 8 AM ET via the send_email binding. This module owns data only: it turns
|
||||||
|
* D1 rows into a `DigestData` and hands it to email.tsx, which renders both
|
||||||
|
* the HTML and the plain-text alternative from that single tree (React Email
|
||||||
|
* `render`), so the two can never drift.
|
||||||
|
*
|
||||||
|
* 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. 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). 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,
|
||||||
|
weekdayOf,
|
||||||
|
} from "./srs.ts";
|
||||||
|
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 {
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A problem row plus its signed one-tap pass/fail URLs. */
|
||||||
|
async function tapRow(env: Env, p: ProblemRow, date: string, staged: boolean): Promise<DigestRow> {
|
||||||
|
const [pass, fail] = await Promise.all([
|
||||||
|
signLink(env.LINK_KEY, p.lc_number, "pass", date),
|
||||||
|
signLink(env.LINK_KEY, p.lc_number, "fail", date),
|
||||||
|
]);
|
||||||
|
const base = `${env.PUBLIC_URL}/log?p=${p.lc_number}&d=${date}&r=`;
|
||||||
|
return {
|
||||||
|
lc: p.lc_number,
|
||||||
|
difficulty: p.difficulty,
|
||||||
|
...(staged ? { stage: p.stage } : {}),
|
||||||
|
passUrl: `${base}pass&sig=${pass}`,
|
||||||
|
failUrl: `${base}fail&sig=${fail}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the email needs, read straight out of D1 and the schedule. */
|
||||||
|
export async function collectDigest(env: Env, date: string): Promise<DigestData> {
|
||||||
|
const db = env.DB;
|
||||||
|
const rest = weekdayOf(date) === 0;
|
||||||
|
|
||||||
|
// 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, REVIEW_CAP);
|
||||||
|
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
|
||||||
|
|
||||||
|
const data: DigestData = {
|
||||||
|
day: prettyDate(date),
|
||||||
|
progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`,
|
||||||
|
streak: await streak(db, date),
|
||||||
|
rest,
|
||||||
|
reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))),
|
||||||
|
carried: due.length - capped.length,
|
||||||
|
drills: await Promise.all(drills.map((p) => tapRow(env, p, date, false))),
|
||||||
|
yesterday: { total: 0, failed: [] },
|
||||||
|
heatmapUrl: `${env.PUBLIC_URL}/chart/heatmap.png`,
|
||||||
|
progressUrl: `${env.DOCS_URL}/progress`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// New topic — the only section allowed to name problems and link them.
|
||||||
|
const topicIssue = rest ? undefined : SCHEDULE[date];
|
||||||
|
if (topicIssue !== undefined) {
|
||||||
|
const topic = await db
|
||||||
|
.prepare("SELECT name FROM topics WHERE issue = ?")
|
||||||
|
.bind(topicIssue)
|
||||||
|
.first<{ name: string }>();
|
||||||
|
const { results: core } = await db
|
||||||
|
.prepare(
|
||||||
|
"SELECT * FROM problems WHERE topic_issue = ? AND set_label = 'core' ORDER BY lc_number",
|
||||||
|
)
|
||||||
|
.bind(topicIssue)
|
||||||
|
.all<ProblemRow>();
|
||||||
|
data.topic = {
|
||||||
|
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",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saturday: the review issue already exists (created at midnight ET).
|
||||||
|
if (weekdayOf(date) === 6) {
|
||||||
|
const gate = await db
|
||||||
|
.prepare("SELECT issue FROM gates WHERE week = ? AND issue IS NOT NULL")
|
||||||
|
.bind(isoWeek(date))
|
||||||
|
.first<{ issue: number }>();
|
||||||
|
if (gate) {
|
||||||
|
data.gate = {
|
||||||
|
week: campaignWeek(date),
|
||||||
|
url: `https://github.com/${env.REPO}/issues/${gate.issue}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer: yesterday's log summarised (failures named — they are the
|
||||||
|
// actionable part) and the gate rate to date.
|
||||||
|
const { results: logged } = await db
|
||||||
|
.prepare("SELECT lc_number, result FROM attempts WHERE date = ? ORDER BY id")
|
||||||
|
.bind(addDays(date, -1))
|
||||||
|
.all<{ lc_number: number; result: string }>();
|
||||||
|
data.yesterday = {
|
||||||
|
total: logged.length,
|
||||||
|
failed: logged.filter((a) => a.result !== "pass").map((a) => a.lc_number),
|
||||||
|
};
|
||||||
|
const lastGate = await db
|
||||||
|
.prepare("SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1")
|
||||||
|
.first<{ pass_rate: number }>();
|
||||||
|
if (lastGate) data.gateRate = lastGate.pass_rate;
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildDigest(env: Env, date: string): Promise<Digest> {
|
||||||
|
const data = await collectDigest(env, date);
|
||||||
|
const { html, text } = await renderDigest(data);
|
||||||
|
return {
|
||||||
|
subject: `(Day ${campaignDay(date)}/${CAMPAIGN_DAYS}) LeetCode Daily Digest`,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendReport {
|
||||||
|
sent: boolean;
|
||||||
|
reason: string;
|
||||||
|
digest: Digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send today's digest exactly once per ET date (unless forced). */
|
||||||
|
export async function sendDigest(
|
||||||
|
env: Env,
|
||||||
|
date: string,
|
||||||
|
opts: { force?: boolean; dry?: boolean } = {},
|
||||||
|
): Promise<SendReport> {
|
||||||
|
const digest = await buildDigest(env, date);
|
||||||
|
if (opts.dry) return { sent: false, reason: "dry run", digest };
|
||||||
|
|
||||||
|
const already = await env.DB.prepare("SELECT sent_at FROM email_log WHERE date = ?")
|
||||||
|
.bind(date)
|
||||||
|
.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" },
|
||||||
|
subject: digest.subject,
|
||||||
|
html: digest.html,
|
||||||
|
text: digest.text,
|
||||||
|
});
|
||||||
|
await env.DB.prepare(
|
||||||
|
"INSERT INTO email_log (date, sent_at) VALUES (?, ?) ON CONFLICT(date) DO UPDATE SET sent_at = excluded.sent_at",
|
||||||
|
)
|
||||||
|
.bind(date, new Date().toISOString())
|
||||||
|
.run();
|
||||||
|
return { sent: true, reason: already ? "forced re-send" : "sent", digest };
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
/**
|
||||||
|
* Presentation layer for the daily digest — React Email components rendered
|
||||||
|
* to HTML (and to the plain-text alternative) inside the Worker.
|
||||||
|
*
|
||||||
|
* This file owns *only* layout and wording. Every number, URL and signature
|
||||||
|
* is computed in digest.ts and handed over as `DigestData`, so the retrieval
|
||||||
|
* rules (review/drill rows carry number + difficulty only — never the topic,
|
||||||
|
* never a solution link) are enforced by what the data model can express:
|
||||||
|
* `DigestRow` has no title and no issue field.
|
||||||
|
*
|
||||||
|
* Dark by design. The theme is hard-coded rather than left to the client's
|
||||||
|
* dark-mode heuristics: `color-scheme: dark` tells Apple Mail and Outlook not
|
||||||
|
* to re-invert it, and the canvas colour is painted by a full-width <Section>
|
||||||
|
* table because Gmail drops styles on <body>.
|
||||||
|
*
|
||||||
|
* Other email constraints that shape the markup: inline styles only (clients
|
||||||
|
* strip <style>), real <table> layout for the problem lists so Outlook and
|
||||||
|
* Gmail agree on column alignment, React Email's <Button> for the one-tap
|
||||||
|
* links (it emits the MSO padding conditionals a bare <a> lacks), and the
|
||||||
|
* heatmap arrives as PNG — every major client refuses remote SVG.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Hr,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Link,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
} 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";
|
||||||
|
const CARD = "#0d1117";
|
||||||
|
const BORDER = "#30363d";
|
||||||
|
const RULE = "#21262d";
|
||||||
|
const INK = "#e6edf3";
|
||||||
|
const MUTED = "#8b949e";
|
||||||
|
const LINK = "#58a6ff";
|
||||||
|
const GREEN = "#3fb950";
|
||||||
|
const AMBER = "#d29922";
|
||||||
|
const RED = "#f85149";
|
||||||
|
const PASS_BG = "#238636";
|
||||||
|
const FAIL_BG = "#da3633";
|
||||||
|
const GATE_BG = "#1f6feb";
|
||||||
|
|
||||||
|
const FONT = '-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif';
|
||||||
|
|
||||||
|
// Outlook's Word engine is unreliable with text-transform, so display labels
|
||||||
|
// are table lookups rather than a CSS trick.
|
||||||
|
const DIFFICULTY: Record<string, { label: string; color: string }> = {
|
||||||
|
easy: { label: "Easy", color: GREEN },
|
||||||
|
medium: { label: "Medium", color: AMBER },
|
||||||
|
hard: { label: "Hard", color: RED },
|
||||||
|
};
|
||||||
|
|
||||||
|
// The SRS ladder in plain English — "+5" means the last look was 5 days back.
|
||||||
|
const LAST_SEEN: Record<string, string> = {
|
||||||
|
new: "first look",
|
||||||
|
"+2": "2 days ago",
|
||||||
|
"+5": "5 days ago",
|
||||||
|
"+10": "10 days ago",
|
||||||
|
retired: "retired",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── data model ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A review or drill line: number + difficulty + one-tap links, nothing else. */
|
||||||
|
export interface DigestRow {
|
||||||
|
lc: number;
|
||||||
|
difficulty: string;
|
||||||
|
/** Reviews only — drills are unstaged by design. */
|
||||||
|
stage?: string;
|
||||||
|
passUrl: string;
|
||||||
|
failUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A learning-day core problem — the only rows allowed a link. */
|
||||||
|
export interface TopicRow {
|
||||||
|
lc: number;
|
||||||
|
difficulty: string;
|
||||||
|
url: string;
|
||||||
|
solved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigestData {
|
||||||
|
/** "Tuesday, August 25" */
|
||||||
|
day: string;
|
||||||
|
/** "Day 9 of 56 · Week 2" */
|
||||||
|
progress: string;
|
||||||
|
streak: number;
|
||||||
|
rest: boolean;
|
||||||
|
topic?: { name: string; core: TopicRow[] };
|
||||||
|
reviews: DigestRow[];
|
||||||
|
/** Reviews past the daily cap; levelled onto the coming days. */
|
||||||
|
carried: number;
|
||||||
|
drills: DigestRow[];
|
||||||
|
gate?: { week: number; url: string };
|
||||||
|
yesterday: { total: number; failed: number[] };
|
||||||
|
gateRate?: number;
|
||||||
|
/** PNG, not SVG — email clients refuse the latter. */
|
||||||
|
heatmapUrl: string;
|
||||||
|
progressUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── styles ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const page: CSSProperties = { backgroundColor: CANVAS, padding: "28px 0" };
|
||||||
|
const card: CSSProperties = {
|
||||||
|
backgroundColor: CARD,
|
||||||
|
border: `1px solid ${BORDER}`,
|
||||||
|
borderRadius: "10px",
|
||||||
|
margin: "0 auto",
|
||||||
|
maxWidth: "600px",
|
||||||
|
padding: "28px 24px",
|
||||||
|
};
|
||||||
|
const h1: CSSProperties = { color: INK, fontSize: "20px", fontWeight: 600, lineHeight: "26px", margin: 0 };
|
||||||
|
const label: CSSProperties = {
|
||||||
|
color: MUTED,
|
||||||
|
fontSize: "11px",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.8px",
|
||||||
|
margin: "0 0 10px",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
};
|
||||||
|
const hint: CSSProperties = { color: MUTED, fontSize: "13px", lineHeight: "19px", margin: "0 0 12px" };
|
||||||
|
const th: CSSProperties = {
|
||||||
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
|
color: MUTED,
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 400,
|
||||||
|
padding: "0 0 8px",
|
||||||
|
textAlign: "left",
|
||||||
|
};
|
||||||
|
const td: CSSProperties = {
|
||||||
|
borderBottom: `1px solid ${RULE}`,
|
||||||
|
color: INK,
|
||||||
|
fontSize: "14px",
|
||||||
|
padding: "12px 0",
|
||||||
|
textAlign: "left",
|
||||||
|
};
|
||||||
|
const tap: CSSProperties = {
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "#ffffff",
|
||||||
|
display: "inline-block",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: "12px",
|
||||||
|
padding: "9px 15px",
|
||||||
|
textDecoration: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A problem list as a real table. The "last seen" column appears only for
|
||||||
|
* reviews, which keeps drills at three columns on a phone.
|
||||||
|
*/
|
||||||
|
function ProblemTable({ rows, staged }: { rows: DigestRow[]; staged: boolean }) {
|
||||||
|
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>
|
||||||
|
{staged ? <th style={th}>Last seen</th> : null}
|
||||||
|
<th style={{ ...th, textAlign: "right" }}>How did it go?</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const difficulty = DIFFICULTY[row.difficulty];
|
||||||
|
return (
|
||||||
|
<tr key={row.lc}>
|
||||||
|
<td style={{ ...td, fontWeight: 600 }}>LC {row.lc}</td>
|
||||||
|
<td style={{ ...td, color: difficulty?.color ?? INK }}>{difficulty?.label ?? row.difficulty}</td>
|
||||||
|
{staged ? (
|
||||||
|
<td style={{ ...td, color: MUTED }}>{(row.stage && LAST_SEEN[row.stage]) ?? row.stage}</td>
|
||||||
|
) : null}
|
||||||
|
<td style={{ ...td, textAlign: "right", whiteSpace: "nowrap" }}>
|
||||||
|
<Button href={row.passUrl} style={{ ...tap, backgroundColor: PASS_BG }}>
|
||||||
|
got it
|
||||||
|
</Button>
|
||||||
|
<Button href={row.failUrl} style={{ ...tap, backgroundColor: FAIL_BG, marginLeft: "6px" }}>
|
||||||
|
missed it
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
rows,
|
||||||
|
staged,
|
||||||
|
empty,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
note: string;
|
||||||
|
rows: DigestRow[];
|
||||||
|
staged: boolean;
|
||||||
|
empty: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Section style={{ marginTop: "28px" }}>
|
||||||
|
<Text style={label}>{title}</Text>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<Text style={{ ...hint, margin: 0 }}>{empty}</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={hint}>{note}</Text>
|
||||||
|
<ProblemTable rows={rows} staged={staged} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── email ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DigestEmail({ data }: { data: DigestData }) {
|
||||||
|
const load = data.reviews.length + data.drills.length;
|
||||||
|
const preview = data.rest
|
||||||
|
? "Rest day — nothing to do but rest."
|
||||||
|
: `${load} to work through today${data.topic ? `, starting with ${data.topic.name}` : ""}.`;
|
||||||
|
|
||||||
|
const passed = data.yesterday.total - data.yesterday.failed.length;
|
||||||
|
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html lang="en" dir="ltr">
|
||||||
|
<Head>
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<meta name="supported-color-schemes" content="dark" />
|
||||||
|
</Head>
|
||||||
|
<Preview>{preview}</Preview>
|
||||||
|
<Body style={{ backgroundColor: CANVAS, fontFamily: FONT, margin: 0, padding: 0 }}>
|
||||||
|
<Section style={page}>
|
||||||
|
<Container style={card}>
|
||||||
|
<Heading as="h1" style={h1}>
|
||||||
|
{data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`}
|
||||||
|
</Heading>
|
||||||
|
<Text style={{ color: MUTED, fontSize: "13px", margin: "8px 0 0" }}>
|
||||||
|
{data.progress}
|
||||||
|
{data.rest
|
||||||
|
? " · nothing is due today, and anything overdue waits for Monday."
|
||||||
|
: data.streak === 0
|
||||||
|
? " · no streak going yet — today is a good day to start one."
|
||||||
|
: ` · 🔥 ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{data.rest ? null : (
|
||||||
|
<>
|
||||||
|
{data.topic ? (
|
||||||
|
<Section style={{ marginTop: "28px" }}>
|
||||||
|
<Text style={label}>Something new today</Text>
|
||||||
|
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
||||||
|
{data.topic.name}
|
||||||
|
</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}
|
||||||
|
|
||||||
|
<Block
|
||||||
|
title="Time to see these again"
|
||||||
|
note="Solve each one from scratch, and resist opening your old answer first."
|
||||||
|
rows={data.reviews}
|
||||||
|
staged
|
||||||
|
empty="Nothing is due for review today."
|
||||||
|
/>
|
||||||
|
{data.carried > 0 ? (
|
||||||
|
<Text style={{ ...hint, margin: "12px 0 0" }}>
|
||||||
|
{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}
|
||||||
|
|
||||||
|
<Block
|
||||||
|
title="Cold start, no hints"
|
||||||
|
note="You are not told the topic. Say the pattern out loud before you write a line."
|
||||||
|
rows={data.drills}
|
||||||
|
staged={false}
|
||||||
|
empty="No cold starts today."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{data.gate ? (
|
||||||
|
<Section style={{ marginTop: "28px" }}>
|
||||||
|
<Text style={label}>Weekly checkpoint</Text>
|
||||||
|
<Text style={hint}>Timed and blind. Close the issue when you are done.</Text>
|
||||||
|
<Button
|
||||||
|
href={data.gate.url}
|
||||||
|
style={{ ...tap, backgroundColor: GATE_BG, fontSize: "13px", padding: "11px 18px" }}
|
||||||
|
>
|
||||||
|
Open week {data.gate.week} review
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Eight weeks at a glance — the one thing here that is pure
|
||||||
|
encouragement rather than instruction. */}
|
||||||
|
<Section style={{ marginTop: "28px" }}>
|
||||||
|
<Text style={label}>Every day you showed up</Text>
|
||||||
|
<Link href={data.progressUrl}>
|
||||||
|
<Img
|
||||||
|
src={data.heatmapUrl}
|
||||||
|
alt="Attempt heatmap across the eight-week campaign"
|
||||||
|
width="552"
|
||||||
|
style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: BORDER, margin: "28px 0 18px" }} />
|
||||||
|
<Text style={{ color: MUTED, fontSize: "12px", lineHeight: "19px", margin: 0 }}>
|
||||||
|
{data.yesterday.total === 0
|
||||||
|
? "You did not log anything yesterday."
|
||||||
|
: `Yesterday you logged ${data.yesterday.total} and got ${passed} of them` +
|
||||||
|
(missed ? `; ${missed} got away.` : ".")}{" "}
|
||||||
|
{data.gateRate === undefined
|
||||||
|
? "No checkpoints scored yet."
|
||||||
|
: `Checkpoints are running at ${Math.round(data.gateRate * 100)}%.`}{" "}
|
||||||
|
<Link href={data.progressUrl} style={{ color: LINK, textDecoration: "none" }}>
|
||||||
|
See the full picture →
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plain-text alternative. React Email's `plainText` mode flattens the
|
||||||
|
* problem tables into one unreadable run, so text gets its own writer — fed
|
||||||
|
* by the same `DigestData`, so the numbers and links can never disagree with
|
||||||
|
* the HTML even though the wording lives in two places.
|
||||||
|
*/
|
||||||
|
function plainDigest(data: DigestData): string {
|
||||||
|
const out: string[] = [
|
||||||
|
data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`,
|
||||||
|
data.progress +
|
||||||
|
(data.rest
|
||||||
|
? " · nothing is due today, and anything overdue waits for Monday."
|
||||||
|
: data.streak === 0
|
||||||
|
? " · no streak going yet — today is a good day to start one."
|
||||||
|
: ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!data.rest) {
|
||||||
|
if (data.topic) {
|
||||||
|
out.push(
|
||||||
|
"",
|
||||||
|
`SOMETHING NEW TODAY — ${data.topic.name}`,
|
||||||
|
"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}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lists: [string, string, DigestRow[], string][] = [
|
||||||
|
[
|
||||||
|
"TIME TO SEE THESE AGAIN",
|
||||||
|
"Solve each one from scratch, and resist opening your old answer first.",
|
||||||
|
data.reviews,
|
||||||
|
"Nothing is due for review today.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"COLD START, NO HINTS",
|
||||||
|
"You are not told the topic. Say the pattern out loud before you write a line.",
|
||||||
|
data.drills,
|
||||||
|
"No cold starts today.",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const [title, note, rows, empty] of lists) {
|
||||||
|
out.push("", title);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
out.push(empty);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(note);
|
||||||
|
for (const row of rows) {
|
||||||
|
const seen = row.stage ? `, last seen ${LAST_SEEN[row.stage] ?? row.stage}` : "";
|
||||||
|
out.push(
|
||||||
|
` LC ${row.lc} — ${DIFFICULTY[row.difficulty]?.label ?? row.difficulty}${seen}`,
|
||||||
|
` got it: ${row.passUrl}`,
|
||||||
|
` missed it: ${row.failUrl}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.carried > 0) {
|
||||||
|
out.push(
|
||||||
|
"",
|
||||||
|
`${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) {
|
||||||
|
out.push(
|
||||||
|
"",
|
||||||
|
"WEEKLY CHECKPOINT",
|
||||||
|
"Timed and blind. Close the issue when you are done.",
|
||||||
|
` Week ${data.gate.week} review — ${data.gate.url}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passed = data.yesterday.total - data.yesterday.failed.length;
|
||||||
|
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
|
||||||
|
out.push(
|
||||||
|
"",
|
||||||
|
"─".repeat(48),
|
||||||
|
data.yesterday.total === 0
|
||||||
|
? "You did not log anything yesterday."
|
||||||
|
: `Yesterday you logged ${data.yesterday.total} and got ${passed} of them` +
|
||||||
|
(missed ? `; ${missed} got away.` : "."),
|
||||||
|
data.gateRate === undefined
|
||||||
|
? "No checkpoints scored yet."
|
||||||
|
: `Checkpoints are running at ${Math.round(data.gateRate * 100)}%.`,
|
||||||
|
`Every day you showed up: ${data.heatmapUrl}`,
|
||||||
|
`See the full picture: ${data.progressUrl}`,
|
||||||
|
);
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTML body plus its plain-text alternative — always sent as a pair so a
|
||||||
|
* client that refuses HTML still gets the same problems and the same links.
|
||||||
|
*/
|
||||||
|
export async function renderDigest(data: DigestData): Promise<{ html: string; text: string }> {
|
||||||
|
return { html: await render(<DigestEmail data={data} />), text: plainDigest(data) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Saturday review issue: created at midnight ET so the 8 AM digest can link
|
||||||
|
* to it; scored when the issue closes (webhook).
|
||||||
|
*
|
||||||
|
* The gate half is a topic-blind quiz — one unsolved optional problem per
|
||||||
|
* topic scheduled this week (which is why daily drills only draw from
|
||||||
|
* earlier weeks). The recap half is generated data, deliberately NOT a task
|
||||||
|
* list: the week's solved problems are already scheduled by the +2 ladder,
|
||||||
|
* and re-assigning them on Saturday would be massed practice.
|
||||||
|
*
|
||||||
|
* Label is `review` (the retired Actions system owned `gate`). Creation is
|
||||||
|
* idempotent by week; deferred Hards never appear.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
type ProblemRow,
|
||||||
|
type Result,
|
||||||
|
SCHEDULE,
|
||||||
|
campaignWeek,
|
||||||
|
isoWeek,
|
||||||
|
rng,
|
||||||
|
sample,
|
||||||
|
streak,
|
||||||
|
weekMonday,
|
||||||
|
} from "./srs.ts";
|
||||||
|
import type { GitHub } from "./github.ts";
|
||||||
|
|
||||||
|
const PASS_TARGET = 0.7;
|
||||||
|
const REVIEW_LABEL = "review";
|
||||||
|
const MAX_GATE_PROBLEMS = 5;
|
||||||
|
|
||||||
|
function capitalized(difficulty: string): string {
|
||||||
|
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsolved problems of a topic in a set — never attempted, never drilled. */
|
||||||
|
async function freshPool(db: D1Database, topic: number, set: string): Promise<ProblemRow[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM problems
|
||||||
|
WHERE topic_issue = ?1 AND set_label = ?2 AND stage = 'new' AND defer_until IS NULL
|
||||||
|
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = problems.lc_number)
|
||||||
|
ORDER BY lc_number`,
|
||||||
|
)
|
||||||
|
.bind(topic, set)
|
||||||
|
.all<ProblemRow>();
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateReport {
|
||||||
|
created: boolean;
|
||||||
|
issue?: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createReviewIssue(env: Env, gh: GitHub, date: string): Promise<CreateReport> {
|
||||||
|
const db = env.DB;
|
||||||
|
const week = campaignWeek(date);
|
||||||
|
const iso = isoWeek(date);
|
||||||
|
const title = `Review — Week ${week}`;
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.prepare("SELECT issue FROM gates WHERE week = ? AND issue IS NOT NULL")
|
||||||
|
.bind(iso)
|
||||||
|
.first<{ issue: number }>();
|
||||||
|
if (existing) {
|
||||||
|
return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topics scheduled this week; review-only weeks (Sep 23 onward) sample
|
||||||
|
// across every topic that still has unsolved optional problems.
|
||||||
|
let topics = Object.entries(SCHEDULE)
|
||||||
|
.filter(([d]) => campaignWeek(d) === week)
|
||||||
|
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||||
|
.map(([, t]) => t);
|
||||||
|
if (topics.length === 0) {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT DISTINCT topic_issue AS t FROM problems
|
||||||
|
WHERE set_label = 'optional' AND stage = 'new'
|
||||||
|
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||||
|
ORDER BY topic_issue`,
|
||||||
|
)
|
||||||
|
.all<{ t: number }>();
|
||||||
|
topics = sample(results.map((r) => r.t), MAX_GATE_PROBLEMS, rng(`gate-topics-${iso}`)).sort(
|
||||||
|
(a, b) => a - b,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
|
||||||
|
|
||||||
|
const random = rng(`gate-${iso}`);
|
||||||
|
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
||||||
|
for (const topic of topics) {
|
||||||
|
const optional = await freshPool(db, topic, "optional");
|
||||||
|
if (optional.length > 0) {
|
||||||
|
picks.push({ p: sample(optional, 1, random)[0]!, fallback: false });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const core = await freshPool(db, topic, "core");
|
||||||
|
if (core.length > 0) picks.push({ p: sample(core, 1, random)[0]!, fallback: true });
|
||||||
|
// Both pools exhausted: the topic is fully solved; nothing to quiz.
|
||||||
|
}
|
||||||
|
if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` };
|
||||||
|
|
||||||
|
// Recap: everything attempted this week, plus streak — data, not tasks.
|
||||||
|
const monday = weekMonday(date);
|
||||||
|
const { results: attempts } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.lc_number, a.date, a.kind, a.result FROM attempts a
|
||||||
|
WHERE a.date >= ? AND a.date < ? AND a.source != 'import'
|
||||||
|
ORDER BY a.date, a.id`,
|
||||||
|
)
|
||||||
|
.bind(monday, date)
|
||||||
|
.all<{ lc_number: number; date: string; kind: string; result: string }>();
|
||||||
|
const reviewsDone = attempts.filter((a) => a.kind === "review" && a.result === "pass").length;
|
||||||
|
const currentStreak = await streak(db, date);
|
||||||
|
|
||||||
|
const gateLines = picks.map(
|
||||||
|
({ p, fallback }) =>
|
||||||
|
`- LC ${p.lc_number} — ${capitalized(p.difficulty)}${fallback ? " *(core fallback — optional pool exhausted)*" : ""}`,
|
||||||
|
);
|
||||||
|
const recapLines = attempts.length
|
||||||
|
? attempts.map(
|
||||||
|
(a) => `| LC ${a.lc_number} | ${a.kind} | ${a.result === "pass" ? "✅" : "❌"} | ${a.date} |`,
|
||||||
|
)
|
||||||
|
: ["| — | no attempts logged this week | | |"];
|
||||||
|
const body = [
|
||||||
|
"## Gate — blind set",
|
||||||
|
`**Target: ${Math.round(PASS_TARGET * 100)}% first-attempt pass rate. Timed. Narrate out loud.**`,
|
||||||
|
"No topics given. Log with `/done <n> pass|fail`, then close this issue.",
|
||||||
|
...gateLines,
|
||||||
|
"",
|
||||||
|
`## Week ${week} recap`,
|
||||||
|
`${attempts.length} attempts · ${reviewsDone} reviews passed · streak ${currentStreak}`,
|
||||||
|
"",
|
||||||
|
"| Problem | Kind | Result | Day |",
|
||||||
|
"| --- | --- | --- | --- |",
|
||||||
|
...recapLines,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// Ensure the `review` label exists (422 = already there).
|
||||||
|
try {
|
||||||
|
await gh.rest(`/repos/${gh.repo}/labels`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: REVIEW_LABEL,
|
||||||
|
color: "5319e7",
|
||||||
|
description: "Weekly blind gate + recap",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (!String(err).includes("422")) throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const milestone = await db
|
||||||
|
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
|
||||||
|
.bind(topics[0])
|
||||||
|
.first<{ milestone: number }>();
|
||||||
|
|
||||||
|
const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
labels: [REVIEW_LABEL],
|
||||||
|
milestone: milestone?.milestone,
|
||||||
|
}),
|
||||||
|
})) as { number: number };
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO gates (week, issue, problems) VALUES (?1, ?2, ?3)
|
||||||
|
ON CONFLICT(week) DO UPDATE SET issue = ?2, problems = ?3`,
|
||||||
|
)
|
||||||
|
.bind(iso, created.number, JSON.stringify(picks.map(({ p }) => p.lc_number)))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Last week's boosted topics had their remedial week — clear the flags.
|
||||||
|
await db.prepare("UPDATE topics SET boost = 0 WHERE boost = 1").run();
|
||||||
|
|
||||||
|
return { created: true, issue: created.number, reason: `created #${created.number}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreReport {
|
||||||
|
scored: boolean;
|
||||||
|
rate?: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grade a closed review issue: first-attempt pass rate over its gate set.
|
||||||
|
* Unlogged problems count as failures — skipping a gate problem is not a
|
||||||
|
* pass. Boosts topics that failed a gate problem or reached 2 drill misses;
|
||||||
|
* miss counters reset once consumed. The badge/charts read D1 live, so
|
||||||
|
* "refreshing the badge" is this row update.
|
||||||
|
*/
|
||||||
|
export async function scoreReview(
|
||||||
|
env: Env,
|
||||||
|
gh: GitHub,
|
||||||
|
issueNumber: number,
|
||||||
|
date: string,
|
||||||
|
): Promise<ScoreReport> {
|
||||||
|
const db = env.DB;
|
||||||
|
const gate = await db
|
||||||
|
.prepare("SELECT week, problems, pass_rate FROM gates WHERE issue = ?")
|
||||||
|
.bind(issueNumber)
|
||||||
|
.first<{ week: number; problems: string; pass_rate: number | null }>();
|
||||||
|
if (!gate) return { scored: false, reason: `issue #${issueNumber} has no gate record` };
|
||||||
|
if (gate.pass_rate !== null) return { scored: false, rate: gate.pass_rate, reason: "already scored" };
|
||||||
|
|
||||||
|
const lcs: number[] = JSON.parse(gate.problems);
|
||||||
|
const results: Record<number, Result | undefined> = {};
|
||||||
|
for (const lc of lcs) {
|
||||||
|
const row = await db
|
||||||
|
.prepare("SELECT result FROM attempts WHERE lc_number = ? AND kind = 'gate' ORDER BY id LIMIT 1")
|
||||||
|
.bind(lc)
|
||||||
|
.first<{ result: Result }>();
|
||||||
|
results[lc] = row?.result;
|
||||||
|
}
|
||||||
|
const passes = lcs.filter((lc) => results[lc] === "pass");
|
||||||
|
const unlogged = lcs.filter((lc) => results[lc] === undefined);
|
||||||
|
const rate = passes.length / lcs.length;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare("UPDATE gates SET pass_rate = ?, closed_on = ? WHERE issue = ?")
|
||||||
|
.bind(rate, date, issueNumber)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const boosted: number[] = [];
|
||||||
|
for (const lc of lcs) {
|
||||||
|
if (results[lc] !== "fail") continue;
|
||||||
|
const p = await db
|
||||||
|
.prepare("SELECT topic_issue FROM problems WHERE lc_number = ?")
|
||||||
|
.bind(lc)
|
||||||
|
.first<{ topic_issue: number }>();
|
||||||
|
if (p) boosted.push(p.topic_issue);
|
||||||
|
}
|
||||||
|
const { results: missed } = await db
|
||||||
|
.prepare("SELECT issue FROM topics WHERE misses >= 2")
|
||||||
|
.all<{ issue: number }>();
|
||||||
|
boosted.push(...missed.map((m) => m.issue));
|
||||||
|
const boostSet = [...new Set(boosted)];
|
||||||
|
if (boostSet.length) {
|
||||||
|
await db.batch(
|
||||||
|
boostSet.map((t) => db.prepare("UPDATE topics SET boost = 1, misses = 0 WHERE issue = ?").bind(t)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passed = rate >= PASS_TARGET;
|
||||||
|
const summary = [
|
||||||
|
`## Gate — Week ${gate.week}: **${Math.round(rate * 100)}%** first-attempt (target ${Math.round(PASS_TARGET * 100)}%) — ${passed ? "✅ pass" : "❌ fail"}`,
|
||||||
|
"",
|
||||||
|
...lcs.map((lc) => {
|
||||||
|
const r = results[lc];
|
||||||
|
return `- LC ${lc} — ${r === "pass" ? "✅ pass" : r === "fail" ? "❌ fail" : "⬜ not logged (counted as fail)"}`;
|
||||||
|
}),
|
||||||
|
"",
|
||||||
|
unlogged.length ? `${unlogged.length} problem(s) were never logged.` : "",
|
||||||
|
boostSet.length
|
||||||
|
? `Boosted topics for next week's drills: ${boostSet.map((t) => `#${t}`).join(", ")}.`
|
||||||
|
: "No topics boosted.",
|
||||||
|
"",
|
||||||
|
`Live charts: ${env.PUBLIC_URL}/chart/progress.svg · progress: ${env.DOCS_URL}/progress`,
|
||||||
|
]
|
||||||
|
.filter((l) => l !== "")
|
||||||
|
.join("\n");
|
||||||
|
await gh.comment(issueNumber, summary);
|
||||||
|
|
||||||
|
return { scored: true, rate, reason: `scored ${Math.round(rate * 100)}%` };
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* GitHub REST + GraphQL client on GH_PAT. Hand-rolled fetch, no SDK —
|
||||||
|
* matching the repo's dependency-free automation rule.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface GitHub {
|
||||||
|
repo: string;
|
||||||
|
rest(path: string, init?: RequestInit): Promise<unknown>;
|
||||||
|
graphql(query: string, variables?: Record<string, unknown>): Promise<unknown>;
|
||||||
|
list(path: string): AsyncGenerator<unknown, void, void>;
|
||||||
|
/** Comment first, then close — a failed close still leaves a visible note. */
|
||||||
|
closeIssue(issue: number, comment: string): Promise<void>;
|
||||||
|
comment(issue: number, body: string): Promise<void>;
|
||||||
|
react(commentId: number, content: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PER_PAGE = 100;
|
||||||
|
|
||||||
|
export function github(token: string, repo: string): GitHub {
|
||||||
|
async function rest(path: string, init: RequestInit = {}): Promise<unknown> {
|
||||||
|
const res = await fetch(`https://api.github.com${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
accept: "application/vnd.github+json",
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
"user-agent": "srs-api",
|
||||||
|
"x-github-api-version": "2022-11-28",
|
||||||
|
...(init.body ? { "content-type": "application/json" } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
return res.status === 204 ? null : res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function graphql(
|
||||||
|
query: string,
|
||||||
|
variables: Record<string, unknown> = {},
|
||||||
|
): Promise<unknown> {
|
||||||
|
const res = await fetch("https://api.github.com/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
"user-agent": "srs-api",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
|
||||||
|
const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
|
||||||
|
if (payload.errors?.length) throw new Error(payload.errors.map((e) => e.message).join("; "));
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* list(path: string): AsyncGenerator<unknown, void, void> {
|
||||||
|
const sep = path.includes("?") ? "&" : "?";
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const batch = await rest(`${path}${sep}per_page=${PER_PAGE}&page=${page}`);
|
||||||
|
if (!Array.isArray(batch)) throw new Error(`unexpected ${path} payload: not an array`);
|
||||||
|
yield* batch;
|
||||||
|
if (batch.length < PER_PAGE) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repo,
|
||||||
|
rest,
|
||||||
|
graphql,
|
||||||
|
list,
|
||||||
|
async closeIssue(issue, comment) {
|
||||||
|
await rest(`/repos/${repo}/issues/${issue}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ body: comment }),
|
||||||
|
});
|
||||||
|
await rest(`/repos/${repo}/issues/${issue}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async comment(issue, body) {
|
||||||
|
await rest(`/repos/${repo}/issues/${issue}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async react(commentId, content) {
|
||||||
|
await rest(`/repos/${repo}/issues/comments/${commentId}/reactions`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
/**
|
||||||
|
* SRS Worker entry: fetch routes + DST-proof cron dispatch.
|
||||||
|
*
|
||||||
|
* Direction of truth: D1 owns SRS state; GitHub issues own the catalog;
|
||||||
|
* the bundled schedule.json owns the calendar. Every handler writes D1
|
||||||
|
* first and runs GitHub/Project side effects afterwards via ctx.waitUntil —
|
||||||
|
* a mirror failure logs a warning and never loses a state write.
|
||||||
|
*
|
||||||
|
* Writes are owner-only (webhook author check, HMAC one-tap links, bearer
|
||||||
|
* admin routes); everything else is read-only public.
|
||||||
|
*/
|
||||||
|
import { reconcileCatalog } from "./catalog.ts";
|
||||||
|
import { gateBadge, heatmapChart, heatmapPng, ladderChart, progressChart } from "./charts.ts";
|
||||||
|
import { sendDigest } from "./digest.ts";
|
||||||
|
import { createReviewIssue, scoreReview } from "./gate.ts";
|
||||||
|
import { type GitHub, github } from "./github.ts";
|
||||||
|
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
|
||||||
|
import { type Mirror, projectMirror } from "./mirror.ts";
|
||||||
|
import {
|
||||||
|
CAMPAIGN_START,
|
||||||
|
type LogOutcome,
|
||||||
|
daysBetween,
|
||||||
|
etDate,
|
||||||
|
etHour,
|
||||||
|
getProblem,
|
||||||
|
logAttempt,
|
||||||
|
weekdayOf,
|
||||||
|
} from "./srs.ts";
|
||||||
|
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
|
||||||
|
* convention). Runs inside ctx.waitUntil; failures are warnings.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
await mirrorFields(projectMirror(gh, env.REPO.split("/")[0]!), outcome);
|
||||||
|
if (outcome.first && outcome.result === "pass") {
|
||||||
|
try {
|
||||||
|
await gh.closeIssue(
|
||||||
|
outcome.issue,
|
||||||
|
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +2. Logged by the SRS Worker.`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`close #${outcome.issue}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function outcomeLine(o: LogOutcome): string {
|
||||||
|
if (o.error) return o.error;
|
||||||
|
if (o.duplicate) return `LC ${o.lc}: already logged today — nothing changed`;
|
||||||
|
return (
|
||||||
|
`LC ${o.lc}: ${o.kind} ${o.result} → stage ${o.stage}` +
|
||||||
|
(o.next_review ? `, review ${o.next_review}` : " — retired 🎉")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── one-tap email links ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function page(title: string, body: string, status = 200): Response {
|
||||||
|
return new Response(
|
||||||
|
`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<body style="font-family:-apple-system,Segoe UI,sans-serif;max-width:420px;margin:15vh auto;padding:0 16px;text-align:center">
|
||||||
|
<h2>${title}</h2><p style="color:#57606a">${body}</p></body>`,
|
||||||
|
{ status, headers: { "content-type": "text/html; charset=utf-8" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTap(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const lc = Number(url.searchParams.get("p"));
|
||||||
|
const result = url.searchParams.get("r");
|
||||||
|
const date = url.searchParams.get("d") ?? "";
|
||||||
|
const sig = url.searchParams.get("sig") ?? "";
|
||||||
|
if (!Number.isFinite(lc) || (result !== "pass" && result !== "fail") || !date) {
|
||||||
|
return page("Bad link", "Missing or malformed parameters.", 400);
|
||||||
|
}
|
||||||
|
if (!(await verifyLink(env.LINK_KEY, lc, result, date, sig))) {
|
||||||
|
return page("Bad signature", "This link was not signed by the SRS.", 403);
|
||||||
|
}
|
||||||
|
const today = etDate(new Date());
|
||||||
|
if (daysBetween(date, today) > 3 || daysBetween(today, date) > 1) {
|
||||||
|
return page("Link expired", "Older than 3 days — log it with a /done comment instead.", 410);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await logAttempt(env.DB, { lc, date, result, source: "email" });
|
||||||
|
if (outcome.error) return page("Not logged", outcome.error, 422);
|
||||||
|
if (outcome.duplicate) {
|
||||||
|
return page("Already logged ✓", `LC ${lc} was already recorded today. Nothing changed.`);
|
||||||
|
}
|
||||||
|
ctx.waitUntil(mirrorOutcome(env, outcome, "email"));
|
||||||
|
return page(
|
||||||
|
result === "pass" ? "Logged ✅" : "Logged — back to +2",
|
||||||
|
outcomeLine(outcome),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GitHub webhook ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CommentEvent {
|
||||||
|
action: string;
|
||||||
|
issue: { number: number; title: string; labels: { name: string }[] };
|
||||||
|
comment: { id: number; body: string; user: { login: string } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWebhook(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
|
const raw = await request.arrayBuffer();
|
||||||
|
const ok = await verifyWebhook(env.WEBHOOK_SECRET, raw, request.headers.get("x-hub-signature-256"));
|
||||||
|
if (!ok) return new Response("bad signature", { status: 401 });
|
||||||
|
|
||||||
|
const event = request.headers.get("x-github-event");
|
||||||
|
const payload: unknown = JSON.parse(new TextDecoder().decode(raw));
|
||||||
|
const owner = env.REPO.split("/")[0]!;
|
||||||
|
const today = etDate(new Date());
|
||||||
|
|
||||||
|
if (event === "issues") {
|
||||||
|
if (!payload || typeof payload !== "object" || !("action" in payload) || !("issue" in payload)) {
|
||||||
|
return new Response("ignored");
|
||||||
|
}
|
||||||
|
const p = payload as { action: string; issue: { number: number; labels: { name: string }[] } };
|
||||||
|
if (p.action === "closed" && p.issue.labels.some((l) => l.name === "review")) {
|
||||||
|
const gh = github(env.GH_PAT, env.REPO);
|
||||||
|
const report = await scoreReview(env, gh, p.issue.number, today);
|
||||||
|
return Response.json(report);
|
||||||
|
}
|
||||||
|
return new Response("ignored");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event !== "issue_comment") return new Response("ignored");
|
||||||
|
const p = payload as CommentEvent;
|
||||||
|
if (p.action !== "created") return new Response("ignored");
|
||||||
|
if (p.comment.user.login !== owner) return new Response("ignored (owner only)");
|
||||||
|
|
||||||
|
const commands: { lc: number; result: "pass" | "fail" }[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const rawLine of p.comment.body.split("\n")) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line.startsWith("/done")) continue;
|
||||||
|
const m = line.match(/^\/done\s+(\d+)\s+(pass|fail)\s*$/);
|
||||||
|
if (m) commands.push({ lc: Number(m[1]), result: m[2] as "pass" | "fail" });
|
||||||
|
else errors.push(`cannot parse \`${line}\` — expected \`/done <number> <pass|fail>\``);
|
||||||
|
}
|
||||||
|
if (commands.length === 0 && errors.length === 0) return new Response("no commands");
|
||||||
|
|
||||||
|
// Comments on a review issue log its gate problems as kind='gate'.
|
||||||
|
const gateRow = await env.DB.prepare("SELECT problems FROM gates WHERE issue = ?")
|
||||||
|
.bind(p.issue.number)
|
||||||
|
.first<{ problems: string }>();
|
||||||
|
const gateLcs = new Set<number>(gateRow ? JSON.parse(gateRow.problems) : []);
|
||||||
|
|
||||||
|
const outcomes: LogOutcome[] = [];
|
||||||
|
for (const cmd of commands) {
|
||||||
|
const outcome = await logAttempt(env.DB, {
|
||||||
|
lc: cmd.lc,
|
||||||
|
date: today,
|
||||||
|
result: cmd.result,
|
||||||
|
source: "webhook",
|
||||||
|
gate: gateLcs.has(cmd.lc),
|
||||||
|
});
|
||||||
|
if (outcome.error) errors.push(outcome.error);
|
||||||
|
else outcomes.push(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gh = github(env.GH_PAT, env.REPO);
|
||||||
|
ctx.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
for (const outcome of outcomes) await mirrorOutcome(env, outcome, "webhook");
|
||||||
|
try {
|
||||||
|
if (errors.length === 0) {
|
||||||
|
await gh.react(p.comment.id, "+1");
|
||||||
|
} else {
|
||||||
|
await gh.comment(
|
||||||
|
p.issue.number,
|
||||||
|
`Could not log everything:\n\n${errors.map((e) => `- ${e}`).join("\n")}` +
|
||||||
|
(outcomes.length
|
||||||
|
? `\n\nApplied anyway:\n\n${outcomes.map((o) => `- ${outcomeLine(o)}`).join("\n")}`
|
||||||
|
: ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`webhook feedback: ${err}`);
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
return Response.json({ applied: outcomes.map(outcomeLine), errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── admin (bearer LINK_KEY, timing-safe) ─────────────────────────
|
||||||
|
|
||||||
|
async function adminAuthorized(request: Request, env: Env): Promise<boolean> {
|
||||||
|
const header = request.headers.get("authorization") ?? "";
|
||||||
|
if (!header.startsWith("Bearer ")) return false;
|
||||||
|
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);
|
||||||
|
const date = url.searchParams.get("date") ?? etDate(new Date());
|
||||||
|
const gh = () => github(env.GH_PAT, env.REPO);
|
||||||
|
|
||||||
|
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",
|
||||||
|
force: url.searchParams.get("force") === "1",
|
||||||
|
});
|
||||||
|
if (url.searchParams.get("dry") === "1") {
|
||||||
|
return new Response(report.digest.html, { headers: { "content-type": "text/html" } });
|
||||||
|
}
|
||||||
|
return Response.json({ sent: report.sent, reason: report.reason, subject: report.digest.subject });
|
||||||
|
}
|
||||||
|
if (path === "/admin/review") {
|
||||||
|
return Response.json(await createReviewIssue(env, gh(), date));
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── router ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SVG_HEADERS = {
|
||||||
|
"content-type": "image/svg+xml",
|
||||||
|
// GitHub Camo honors this; 5 minutes is the freshness floor for README charts.
|
||||||
|
"cache-control": "public, max-age=300",
|
||||||
|
};
|
||||||
|
|
||||||
|
// The digest embeds the raster heatmap; email clients refuse SVG entirely.
|
||||||
|
const PNG_HEADERS = {
|
||||||
|
"content-type": "image/png",
|
||||||
|
"cache-control": "public, max-age=300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const path = url.pathname;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (path === "/log" && request.method === "GET") return await handleTap(request, env, ctx);
|
||||||
|
if (path === "/webhook/github" && request.method === "POST") {
|
||||||
|
return await handleWebhook(request, env, ctx);
|
||||||
|
}
|
||||||
|
if (path.startsWith("/admin/") && request.method === "POST") {
|
||||||
|
return await handleAdmin(request, env, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/chart/progress.svg") {
|
||||||
|
return new Response(await progressChart(env.DB), { headers: SVG_HEADERS });
|
||||||
|
}
|
||||||
|
if (path === "/chart/ladder.svg") {
|
||||||
|
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
|
||||||
|
}
|
||||||
|
if (path === "/chart/heatmap.svg") {
|
||||||
|
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, 56), { headers: SVG_HEADERS });
|
||||||
|
}
|
||||||
|
if (path === "/chart/heatmap.png") {
|
||||||
|
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, 56), { headers: PNG_HEADERS });
|
||||||
|
}
|
||||||
|
if (path === "/badge/gate.svg") {
|
||||||
|
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/stats") {
|
||||||
|
// The docs serve from both the Pages origin and the custom domain;
|
||||||
|
// reflect the requesting origin only when it is on the allowlist.
|
||||||
|
const allowed = env.DOCS_ORIGIN.split(",").map((o) => o.trim());
|
||||||
|
const origin = request.headers.get("origin");
|
||||||
|
const cors = {
|
||||||
|
"access-control-allow-origin":
|
||||||
|
origin && allowed.includes(origin) ? origin : allowed[0]!,
|
||||||
|
"access-control-allow-methods": "GET",
|
||||||
|
vary: "Origin",
|
||||||
|
};
|
||||||
|
if (request.method === "OPTIONS") return new Response(null, { headers: cors });
|
||||||
|
const stats = await buildStats(env.DB, etDate(new Date()));
|
||||||
|
return Response.json(stats, { headers: cors });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("srs-api", { status: path === "/" ? 200 : 404 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`unhandled ${request.method} ${path}: ${err}`);
|
||||||
|
return new Response("internal error", { status: 500 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DST-proof cron dispatch: crons fire at both possible UTC hours; the
|
||||||
|
* computed ET hour decides. 8 AM daily → catalog reconcile + digest;
|
||||||
|
* midnight Saturday → review issue (so the 8 AM digest can link to it).
|
||||||
|
*/
|
||||||
|
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||||
|
const now = new Date(controller.scheduledTime);
|
||||||
|
const hour = etHour(now);
|
||||||
|
const date = etDate(now);
|
||||||
|
|
||||||
|
if (hour === 8) {
|
||||||
|
try {
|
||||||
|
const report = await reconcileCatalog(env.DB, github(env.GH_PAT, env.REPO));
|
||||||
|
console.log(`catalog: ${report.topics} topics, ${report.problems} problems`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`catalog reconcile failed (digest still goes out): ${err}`);
|
||||||
|
}
|
||||||
|
const report = await sendDigest(env, date);
|
||||||
|
console.log(`digest ${date}: ${report.reason}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hour === 0 && weekdayOf(date) === 6) {
|
||||||
|
const report = await createReviewIssue(env, github(env.GH_PAT, env.REPO), date);
|
||||||
|
console.log(`review issue ${date}: ${report.reason}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`cron at ET hour ${hour} on ${date}: no-op (DST guard)`);
|
||||||
|
void ctx;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* One-tap email links: `GET /log?p=704&r=pass&d=2026-08-31&sig=<hmac>`.
|
||||||
|
* The sig is HMAC-SHA256 over `p|r|d` with LINK_KEY (WebCrypto, hex).
|
||||||
|
* Verification is constant-time; links older than 3 days are rejected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function hmacKey(secret: string): Promise<CryptoKey> {
|
||||||
|
return crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
new TextEncoder().encode(secret),
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["sign", "verify"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signLink(
|
||||||
|
key: string,
|
||||||
|
p: number,
|
||||||
|
r: string,
|
||||||
|
d: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const mac = await crypto.subtle.sign(
|
||||||
|
"HMAC",
|
||||||
|
await hmacKey(key),
|
||||||
|
new TextEncoder().encode(`${p}|${r}|${d}`),
|
||||||
|
);
|
||||||
|
return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyLink(
|
||||||
|
key: string,
|
||||||
|
p: number,
|
||||||
|
r: string,
|
||||||
|
d: string,
|
||||||
|
sig: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(sig)) return false;
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
for (let i = 0; i < 32; i++) bytes[i] = Number.parseInt(sig.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
// crypto.subtle.verify is constant-time; never compare hex strings directly.
|
||||||
|
return crypto.subtle.verify(
|
||||||
|
"HMAC",
|
||||||
|
await hmacKey(key),
|
||||||
|
bytes,
|
||||||
|
new TextEncoder().encode(`${p}|${r}|${d}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time equality for webhook signatures and admin bearer keys. */
|
||||||
|
export async function timingSafeEqual(a: string, b: string): Promise<boolean> {
|
||||||
|
// HMAC both sides with a random key: unequal-length inputs and content
|
||||||
|
// differences are equally invisible to timing.
|
||||||
|
const key = await crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, false, [
|
||||||
|
"sign",
|
||||||
|
]);
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const [ma, mb] = await Promise.all([
|
||||||
|
crypto.subtle.sign("HMAC", key, enc.encode(a)),
|
||||||
|
crypto.subtle.sign("HMAC", key, enc.encode(b)),
|
||||||
|
]);
|
||||||
|
const va = new Uint8Array(ma);
|
||||||
|
const vb = new Uint8Array(mb);
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < va.length; i++) diff |= va[i]! ^ vb[i]!;
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GitHub webhook `X-Hub-Signature-256: sha256=<hex>` verification. */
|
||||||
|
export async function verifyWebhook(
|
||||||
|
secret: string,
|
||||||
|
body: ArrayBuffer,
|
||||||
|
header: string | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!header?.startsWith("sha256=")) return false;
|
||||||
|
const mac = await crypto.subtle.sign("HMAC", await hmacKey(secret), body);
|
||||||
|
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
return timingSafeEqual(header.slice(7), expected);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Best-effort mirror into the "Interview Prep" user Project — ported from
|
||||||
|
* scripts/srs-project.ts. D1 is the truth; a GraphQL failure here becomes a
|
||||||
|
* console warning and never blocks a state write. Only problem issues are
|
||||||
|
* ever passed in, so topic rows' Target Date is never written.
|
||||||
|
*
|
||||||
|
* Field/option IDs are resolved by NAME per invocation (a Worker isolate is
|
||||||
|
* short-lived; the two extra queries per mirror burst are irrelevant at this
|
||||||
|
* volume and survive field re-creation).
|
||||||
|
*/
|
||||||
|
import type { GitHub } from "./github.ts";
|
||||||
|
|
||||||
|
const PROJECT_TITLE = "Interview Prep";
|
||||||
|
|
||||||
|
interface SelectField {
|
||||||
|
id: string;
|
||||||
|
options: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectInfo {
|
||||||
|
id: string;
|
||||||
|
targetDate: string;
|
||||||
|
srsStage: SelectField;
|
||||||
|
firstAttempt: SelectField;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Mirror {
|
||||||
|
setTargetDate(issue: number, date: string | null): Promise<void>;
|
||||||
|
setStage(issue: number, stage: string): Promise<void>;
|
||||||
|
setFirstAttempt(issue: number, result: string): Promise<void>;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectMirror(gh: GitHub, owner: string): Mirror {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
let info: ProjectInfo | undefined;
|
||||||
|
const itemIds = new Map<number, string | undefined>();
|
||||||
|
|
||||||
|
async function resolve(): Promise<ProjectInfo> {
|
||||||
|
if (info) return info;
|
||||||
|
const data = (await gh.graphql(
|
||||||
|
`query($owner: String!, $title: String!) {
|
||||||
|
user(login: $owner) {
|
||||||
|
projectsV2(first: 10, query: $title) {
|
||||||
|
nodes {
|
||||||
|
id title
|
||||||
|
fields(first: 30) {
|
||||||
|
nodes {
|
||||||
|
... on ProjectV2FieldCommon { id name dataType }
|
||||||
|
... on ProjectV2SingleSelectField { id name options { id name } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ owner, title: PROJECT_TITLE },
|
||||||
|
)) as {
|
||||||
|
user: {
|
||||||
|
projectsV2: {
|
||||||
|
nodes: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
fields: {
|
||||||
|
nodes: { id: string; name: string; options?: { id: string; name: string }[] }[];
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
|
||||||
|
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
|
||||||
|
const select = (name: string): SelectField => {
|
||||||
|
const f = node.fields.nodes.find((n) => n.name === name);
|
||||||
|
if (!f?.options) throw new Error(`single-select field "${name}" missing`);
|
||||||
|
return { id: f.id, options: Object.fromEntries(f.options.map((o) => [o.name, o.id])) };
|
||||||
|
};
|
||||||
|
const date = node.fields.nodes.find((n) => n.name === "Target Date");
|
||||||
|
if (!date) throw new Error(`date field "Target Date" missing`);
|
||||||
|
info = {
|
||||||
|
id: node.id,
|
||||||
|
targetDate: date.id,
|
||||||
|
srsStage: select("SRS Stage"),
|
||||||
|
firstAttempt: select("First Attempt"),
|
||||||
|
};
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function itemId(issue: number): Promise<string> {
|
||||||
|
if (itemIds.has(issue)) {
|
||||||
|
const cached = itemIds.get(issue);
|
||||||
|
if (!cached) throw new Error(`issue #${issue} is not in the project`);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const project = await resolve();
|
||||||
|
const [repoOwner, repoName] = gh.repo.split("/");
|
||||||
|
const data = (await gh.graphql(
|
||||||
|
`query($owner: String!, $name: String!, $issue: Int!) {
|
||||||
|
repository(owner: $owner, name: $name) {
|
||||||
|
issue(number: $issue) {
|
||||||
|
projectItems(first: 10, includeArchived: true) {
|
||||||
|
nodes { id project { id } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ owner: repoOwner, name: repoName, issue },
|
||||||
|
)) as {
|
||||||
|
repository: { issue: { projectItems: { nodes: { id: string; project: { id: string } }[] } } };
|
||||||
|
};
|
||||||
|
const item = data.repository.issue.projectItems.nodes.find((n) => n.project.id === project.id);
|
||||||
|
itemIds.set(issue, item?.id);
|
||||||
|
if (!item) throw new Error(`issue #${issue} is not in the project`);
|
||||||
|
return item.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setField(issue: number, fieldId: string, value: object): Promise<void> {
|
||||||
|
const project = await resolve();
|
||||||
|
await gh.graphql(
|
||||||
|
`mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
|
||||||
|
updateProjectV2ItemFieldValue(
|
||||||
|
input: { projectId: $project, itemId: $item, fieldId: $field, value: $value }
|
||||||
|
) { projectV2Item { id } }
|
||||||
|
}`,
|
||||||
|
{ project: project.id, item: await itemId(issue), field: fieldId, value },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attempt(what: string, op: () => Promise<void>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await op();
|
||||||
|
} catch (err) {
|
||||||
|
const message = `mirror ${what}: ${err instanceof Error ? err.message : String(err)}`;
|
||||||
|
warnings.push(message);
|
||||||
|
console.warn(message); // observability picks this up; never rethrow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
warnings,
|
||||||
|
setTargetDate: (issue, date) =>
|
||||||
|
attempt(`Target Date #${issue}`, async () => {
|
||||||
|
const project = await resolve();
|
||||||
|
if (date === null) {
|
||||||
|
await gh.graphql(
|
||||||
|
`mutation($project: ID!, $item: ID!, $field: ID!) {
|
||||||
|
clearProjectV2ItemFieldValue(
|
||||||
|
input: { projectId: $project, itemId: $item, fieldId: $field }
|
||||||
|
) { projectV2Item { id } }
|
||||||
|
}`,
|
||||||
|
{ project: project.id, item: await itemId(issue), field: project.targetDate },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await setField(issue, project.targetDate, { date });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
setStage: (issue, stage) =>
|
||||||
|
attempt(`SRS Stage #${issue}`, async () => {
|
||||||
|
const project = await resolve();
|
||||||
|
const option = project.srsStage.options[stage];
|
||||||
|
if (!option) throw new Error(`no option "${stage}"`);
|
||||||
|
await setField(issue, project.srsStage.id, { singleSelectOptionId: option });
|
||||||
|
}),
|
||||||
|
setFirstAttempt: (issue, result) =>
|
||||||
|
attempt(`First Attempt #${issue}`, async () => {
|
||||||
|
const project = await resolve();
|
||||||
|
const option = project.firstAttempt.options[result];
|
||||||
|
if (!option) throw new Error(`no option "${result}"`);
|
||||||
|
await setField(issue, project.firstAttempt.id, { singleSelectOptionId: option });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* Hand-rolled PNG encoder for the SRS Worker.
|
||||||
|
*
|
||||||
|
* Every major email client — Gmail, Outlook, Apple Mail — refuses to render
|
||||||
|
* SVG, inline or remote, so the daily digest cannot reuse /chart/heatmap.svg;
|
||||||
|
* it needs raster bytes. The Worker ships with no runtime dependencies by
|
||||||
|
* design (SVG, HMAC and GraphQL are all hand-rolled here), so this file
|
||||||
|
* encodes PNG itself instead of pulling in a codec.
|
||||||
|
*
|
||||||
|
* The format reduces to four things the runtime already provides:
|
||||||
|
* - an 8-byte signature plus chunks (IHDR, IDAT, IEND), each one
|
||||||
|
* length + type + payload + CRC32, every integer big-endian;
|
||||||
|
* - one filter byte per scanline, always 0 here: these images are flat
|
||||||
|
* rectangles, so a predictor would buy nothing;
|
||||||
|
* - zlib-wrapped deflate for IDAT, which is exactly what
|
||||||
|
* `new CompressionStream("deflate")` emits ("deflate-raw" would not);
|
||||||
|
* - CRC32, a 256-entry table built lazily on the first chunk.
|
||||||
|
*
|
||||||
|
* Colour type 2 (truecolour, 8-bit, no alpha): these charts are opaque cards,
|
||||||
|
* so an alpha channel would cost a third more bytes for nothing. That does mean
|
||||||
|
* transparency is unavailable — a caller wanting rounded corners paints the
|
||||||
|
* colour that sits behind the image first, then the rounded card on top.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── crc32 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// One table for every chunk of every image; built on first use.
|
||||||
|
let crcTable: Uint32Array | undefined;
|
||||||
|
|
||||||
|
function crc32(bytes: Uint8Array): number {
|
||||||
|
let table = crcTable;
|
||||||
|
if (!table) {
|
||||||
|
table = new Uint32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
table[n] = c;
|
||||||
|
}
|
||||||
|
crcTable = table;
|
||||||
|
}
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (const byte of bytes) crc = table[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── chunks ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
|
||||||
|
/** length, 4-char type, payload, then CRC32 over the type and payload. */
|
||||||
|
function chunk(type: string, data: Uint8Array): Uint8Array {
|
||||||
|
const out = new Uint8Array(data.length + 12);
|
||||||
|
const view = new DataView(out.buffer);
|
||||||
|
view.setUint32(0, data.length);
|
||||||
|
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
|
||||||
|
out.set(data, 8);
|
||||||
|
view.setUint32(data.length + 8, crc32(out.subarray(4, data.length + 8)));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5x7 bitmap font ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const GLYPH_W = 5;
|
||||||
|
const GLYPH_H = 7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seven row bitmasks per glyph, five low bits each, MSB (0b10000) leftmost.
|
||||||
|
* Uppercase only: chart labels are short and mechanical, and a lowercase set
|
||||||
|
* would double the table for no gain. Unlisted characters render blank.
|
||||||
|
*/
|
||||||
|
const GLYPHS: Record<string, number[]> = {
|
||||||
|
A: [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
|
||||||
|
B: [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110],
|
||||||
|
C: [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110],
|
||||||
|
D: [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110],
|
||||||
|
E: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
|
||||||
|
F: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000],
|
||||||
|
G: [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110],
|
||||||
|
H: [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
|
||||||
|
I: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111],
|
||||||
|
J: [0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100],
|
||||||
|
K: [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001],
|
||||||
|
L: [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
|
||||||
|
M: [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001],
|
||||||
|
N: [0b10001, 0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001],
|
||||||
|
O: [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
|
||||||
|
P: [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
|
||||||
|
Q: [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10011, 0b01101],
|
||||||
|
R: [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001],
|
||||||
|
S: [0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110],
|
||||||
|
T: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100],
|
||||||
|
U: [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
|
||||||
|
V: [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100],
|
||||||
|
W: [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001],
|
||||||
|
X: [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001],
|
||||||
|
Y: [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100],
|
||||||
|
Z: [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111],
|
||||||
|
"0": [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
|
||||||
|
"1": [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
|
||||||
|
"2": [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
|
||||||
|
"3": [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
|
||||||
|
"4": [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
|
||||||
|
"5": [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
|
||||||
|
"6": [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
|
||||||
|
"7": [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
|
||||||
|
"8": [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
|
||||||
|
"9": [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
|
||||||
|
" ": [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000],
|
||||||
|
"-": [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000],
|
||||||
|
"/": [0b00001, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b10000],
|
||||||
|
":": [0b00000, 0b00100, 0b00100, 0b00000, 0b00100, 0b00100, 0b00000],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device pixels `content` occupies at `scale`, inter-glyph gaps included.
|
||||||
|
* Exported because callers need it to size a canvas before constructing one.
|
||||||
|
*/
|
||||||
|
export function textWidth(content: string, scale = 1): number {
|
||||||
|
return content.length === 0 ? 0 : (content.length * (GLYPH_W + 1) - 1) * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── canvas ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** "#rrggbb" to its three channel bytes; called once per draw, not per pixel. */
|
||||||
|
function rgb(color: string): [number, number, number] {
|
||||||
|
const packed = Number.parseInt(color.slice(1), 16);
|
||||||
|
return [(packed >> 16) & 0xff, (packed >> 8) & 0xff, packed & 0xff];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fixed-size RGB canvas: filled rectangles plus 5x7 bitmap text. */
|
||||||
|
export class Canvas {
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
/** Row-major RGB triples, unpadded; encode() interleaves the filter bytes. */
|
||||||
|
private readonly pixels: Uint8Array;
|
||||||
|
|
||||||
|
constructor(width: number, height: number, background: string) {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.pixels = new Uint8Array(width * height * 3);
|
||||||
|
const [r, g, b] = rgb(background);
|
||||||
|
for (let i = 0; i < this.pixels.length; i += 3) {
|
||||||
|
this.pixels[i] = r;
|
||||||
|
this.pixels[i + 1] = g;
|
||||||
|
this.pixels[i + 2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filled rectangle, clipped to the canvas. `radius` rounds all four corners
|
||||||
|
* by dropping the pixels whose centre falls outside the corner circle —
|
||||||
|
* enough for the small cell roundings and the card these charts draw.
|
||||||
|
*/
|
||||||
|
rect(x: number, y: number, w: number, h: number, color: string, radius = 0): void {
|
||||||
|
const [r, g, b] = rgb(color);
|
||||||
|
// Device pixels only: a fractional origin would index the buffer between
|
||||||
|
// bytes and silently drop the whole rectangle.
|
||||||
|
const left = Math.round(x);
|
||||||
|
const top = Math.round(y);
|
||||||
|
const rw = Math.round(w);
|
||||||
|
const rh = Math.round(h);
|
||||||
|
const rad = Math.min(radius, rw / 2, rh / 2);
|
||||||
|
|
||||||
|
const y1 = Math.min(this.height, top + rh);
|
||||||
|
const x1 = Math.min(this.width, left + rw);
|
||||||
|
for (let py = Math.max(0, top); py < y1; py++) {
|
||||||
|
// How far this row lies past the nearer corner centre; 0 in between.
|
||||||
|
const cy = py + 0.5;
|
||||||
|
const dy = cy < top + rad ? top + rad - cy : cy > top + rh - rad ? cy - (top + rh - rad) : 0;
|
||||||
|
const rowBase = py * this.width * 3;
|
||||||
|
for (let px = Math.max(0, left); px < x1; px++) {
|
||||||
|
const cx = px + 0.5;
|
||||||
|
const dx = cx < left + rad ? left + rad - cx : cx > left + rw - rad ? cx - (left + rw - rad) : 0;
|
||||||
|
if (dx * dx + dy * dy > rad * rad) continue;
|
||||||
|
const at = rowBase + px * 3;
|
||||||
|
this.pixels[at] = r;
|
||||||
|
this.pixels[at + 1] = g;
|
||||||
|
this.pixels[at + 2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bitmap text from a top-left origin (not a baseline), `scale` device pixels
|
||||||
|
* per glyph pixel, 1 glyph pixel of advance between characters. Input is
|
||||||
|
* uppercased; unknown characters advance without drawing.
|
||||||
|
*/
|
||||||
|
text(x: number, y: number, content: string, color: string, scale = 1): void {
|
||||||
|
const [r, g, b] = rgb(color);
|
||||||
|
const left = Math.round(x);
|
||||||
|
const top = Math.round(y);
|
||||||
|
const upper = content.toUpperCase();
|
||||||
|
|
||||||
|
for (let i = 0; i < upper.length; i++) {
|
||||||
|
const glyph = GLYPHS[upper[i]!];
|
||||||
|
if (!glyph) continue;
|
||||||
|
const originX = left + i * (GLYPH_W + 1) * scale;
|
||||||
|
for (let gy = 0; gy < GLYPH_H; gy++) {
|
||||||
|
const bits = glyph[gy]!;
|
||||||
|
if (bits === 0) continue;
|
||||||
|
for (let gx = 0; gx < GLYPH_W; gx++) {
|
||||||
|
if ((bits & (1 << (GLYPH_W - 1 - gx))) === 0) continue;
|
||||||
|
// Every set glyph pixel is a scale x scale block of device pixels.
|
||||||
|
const blockX = originX + gx * scale;
|
||||||
|
const blockY = top + gy * scale;
|
||||||
|
const y1 = Math.min(this.height, blockY + scale);
|
||||||
|
const x1 = Math.min(this.width, blockX + scale);
|
||||||
|
for (let py = Math.max(0, blockY); py < y1; py++) {
|
||||||
|
const rowBase = py * this.width * 3;
|
||||||
|
for (let px = Math.max(0, blockX); px < x1; px++) {
|
||||||
|
const at = rowBase + px * 3;
|
||||||
|
this.pixels[at] = r;
|
||||||
|
this.pixels[at + 1] = g;
|
||||||
|
this.pixels[at + 2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PNG bytes: 8-bit truecolour, one IDAT, zlib via CompressionStream. */
|
||||||
|
async encode(): Promise<Uint8Array> {
|
||||||
|
const stride = this.width * 3;
|
||||||
|
const raw = new Uint8Array((stride + 1) * this.height);
|
||||||
|
for (let y = 0; y < this.height; y++) {
|
||||||
|
// Leading 0 of each scanline slot is the "no filter" byte.
|
||||||
|
raw.set(this.pixels.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
||||||
|
}
|
||||||
|
const deflated = new Response(raw).body!.pipeThrough(new CompressionStream("deflate"));
|
||||||
|
const idat = new Uint8Array(await new Response(deflated).arrayBuffer());
|
||||||
|
|
||||||
|
const ihdr = new Uint8Array(13);
|
||||||
|
const header = new DataView(ihdr.buffer);
|
||||||
|
header.setUint32(0, this.width);
|
||||||
|
header.setUint32(4, this.height);
|
||||||
|
ihdr[8] = 8; // bit depth
|
||||||
|
ihdr[9] = 2; // colour type 2: truecolour RGB
|
||||||
|
// Bytes 10-12 stay 0: deflate, adaptive filtering, no interlace.
|
||||||
|
|
||||||
|
const chunks = [chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0))];
|
||||||
|
let total = SIGNATURE.length;
|
||||||
|
for (const c of chunks) total += c.length;
|
||||||
|
|
||||||
|
const png = new Uint8Array(total);
|
||||||
|
png.set(SIGNATURE);
|
||||||
|
let at = SIGNATURE.length;
|
||||||
|
for (const c of chunks) {
|
||||||
|
png.set(c, at);
|
||||||
|
at += c.length;
|
||||||
|
}
|
||||||
|
return png;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* DST-guard and date-math checks with fixed instants — the acceptance proof
|
||||||
|
* that the double crons fire exactly once at 8 AM ET (digest) and midnight
|
||||||
|
* Saturday ET (review issue) on BOTH UTC offsets.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { addDays, campaignDay, campaignWeek, etDate, etHour, isoWeek, rng, sample, weekdayOf } from "./srs.ts";
|
||||||
|
|
||||||
|
describe("DST-proof cron guard", () => {
|
||||||
|
test("digest fires only at ET hour 8 — EDT (UTC-4)", () => {
|
||||||
|
// Cron pair 12:00 / 13:00 UTC during EDT (2026-08-29 is EDT).
|
||||||
|
expect(etHour(new Date("2026-08-29T12:00:00Z"))).toBe(8); // fires
|
||||||
|
expect(etHour(new Date("2026-08-29T13:00:00Z"))).toBe(9); // no-op
|
||||||
|
});
|
||||||
|
test("digest fires only at ET hour 8 — EST (UTC-5)", () => {
|
||||||
|
// DST ends 2026-11-01; 2026-11-05 is EST.
|
||||||
|
expect(etHour(new Date("2026-11-05T12:00:00Z"))).toBe(7); // no-op
|
||||||
|
expect(etHour(new Date("2026-11-05T13:00:00Z"))).toBe(8); // fires
|
||||||
|
});
|
||||||
|
test("saturday review fires only at ET midnight — both offsets", () => {
|
||||||
|
// EDT Saturday: 4 UTC = 0 ET fires, 5 UTC = 1 ET no-op.
|
||||||
|
expect(etHour(new Date("2026-08-29T04:00:00Z"))).toBe(0);
|
||||||
|
expect(weekdayOf(etDate(new Date("2026-08-29T04:00:00Z")))).toBe(6);
|
||||||
|
expect(etHour(new Date("2026-08-29T05:00:00Z"))).toBe(1);
|
||||||
|
// EST Saturday (2026-11-07): 5 UTC = 0 ET fires, 4 UTC = 11 PM FRIDAY ET.
|
||||||
|
expect(etHour(new Date("2026-11-07T05:00:00Z"))).toBe(0);
|
||||||
|
expect(weekdayOf(etDate(new Date("2026-11-07T05:00:00Z")))).toBe(6);
|
||||||
|
expect(etHour(new Date("2026-11-07T04:00:00Z"))).toBe(23);
|
||||||
|
expect(etDate(new Date("2026-11-07T04:00:00Z"))).toBe("2026-11-06"); // still Friday ET
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("date math (noon-UTC anchored)", () => {
|
||||||
|
test("addDays crosses the DST-end boundary without drift", () => {
|
||||||
|
expect(addDays("2026-10-31", 2)).toBe("2026-11-02");
|
||||||
|
expect(addDays("2026-08-24", 2)).toBe("2026-08-26");
|
||||||
|
});
|
||||||
|
test("campaign math", () => {
|
||||||
|
expect(campaignDay("2026-08-17")).toBe(1);
|
||||||
|
expect(campaignDay("2026-08-24")).toBe(8);
|
||||||
|
expect(campaignWeek("2026-08-24")).toBe(2);
|
||||||
|
expect(campaignWeek("2026-08-30")).toBe(2);
|
||||||
|
expect(campaignWeek("2026-08-31")).toBe(3);
|
||||||
|
});
|
||||||
|
test("iso week is stable across a week", () => {
|
||||||
|
expect(isoWeek("2026-08-24")).toBe(isoWeek("2026-08-29"));
|
||||||
|
expect(isoWeek("2026-08-30")).toBe(isoWeek("2026-08-24")); // Sun ends the ISO week
|
||||||
|
expect(isoWeek("2026-08-31")).toBe(isoWeek("2026-08-24") + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deterministic sampling", () => {
|
||||||
|
test("same seed, same picks", () => {
|
||||||
|
const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||||
|
expect(sample(pool, 3, rng("drill-2026-08-31"))).toEqual(sample(pool, 3, rng("drill-2026-08-31")));
|
||||||
|
expect(sample(pool, 3, rng("drill-2026-09-01"))).not.toEqual(sample(pool, 3, rng("drill-2026-08-31")));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* SRS domain: ET dates, the interval ladder, deterministic sampling, and the
|
||||||
|
* one write path for attempts — ported from scripts/srs.ts, re-homed on D1.
|
||||||
|
*
|
||||||
|
* D1 is the single source of truth. The stage names the review a problem must
|
||||||
|
* pass NEXT (`+2` = due 2 days after last clean solve). Passing advances
|
||||||
|
* new → +2 → +5 → +10 → retired; any failure resets to +2. A problem's
|
||||||
|
* FIRST-ever log enters the ladder at +2 regardless of result: a pass earns
|
||||||
|
* a +2 review, a fail must be re-solved just as soon.
|
||||||
|
*
|
||||||
|
* All dates are America/New_York calendar strings (YYYY-MM-DD); arithmetic is
|
||||||
|
* anchored at noon UTC so DST edges cannot shift a date. Never raw Date math.
|
||||||
|
*/
|
||||||
|
import scheduleJson from "../data/schedule.json";
|
||||||
|
|
||||||
|
// ── dates (America/New_York) ─────────────────────────────────────
|
||||||
|
|
||||||
|
export const CAMPAIGN_START = "2026-08-17";
|
||||||
|
export const CAMPAIGN_DAYS = 56;
|
||||||
|
|
||||||
|
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: "America/New_York",
|
||||||
|
dateStyle: "short",
|
||||||
|
});
|
||||||
|
const ET_HOUR = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: "America/New_York",
|
||||||
|
hour: "numeric",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** ET calendar date of an instant. */
|
||||||
|
export function etDate(now: Date): string {
|
||||||
|
return ET_DATE.format(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ET hour 0–23 of an instant — the DST-proof cron guard. */
|
||||||
|
export function etHour(now: Date): number {
|
||||||
|
return Number(ET_HOUR.format(now));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Noon-UTC anchor: date-only arithmetic immune to DST edges. */
|
||||||
|
function atNoon(date: string): Date {
|
||||||
|
return new Date(`${date}T12:00:00Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(date: string, days: number): string {
|
||||||
|
return new Date(atNoon(date).getTime() + days * 86_400_000).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysBetween(from: string, to: string): number {
|
||||||
|
return Math.round((atNoon(to).getTime() - atNoon(from).getTime()) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0 = Sunday … 6 = Saturday. */
|
||||||
|
export function weekdayOf(date: string): number {
|
||||||
|
return atNoon(date).getUTCDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function campaignDay(date: string): number {
|
||||||
|
return daysBetween(CAMPAIGN_START, date) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1-based campaign week (Mon–Sun), aligned to CAMPAIGN_START. */
|
||||||
|
export function campaignWeek(date: string): number {
|
||||||
|
return Math.floor(daysBetween(CAMPAIGN_START, date) / 7) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monday of the date's campaign week. */
|
||||||
|
export function weekMonday(date: string): string {
|
||||||
|
return addDays(CAMPAIGN_START, (campaignWeek(date) - 1) * 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isoWeek(date: string): number {
|
||||||
|
const d = atNoon(date);
|
||||||
|
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||||
|
const jan1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||||
|
return Math.ceil(((d.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prettyDate(date: string): string {
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: "UTC",
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(atNoon(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the schedule (bundled; humans edit apps/api/data/schedule.json) ───
|
||||||
|
|
||||||
|
export const SCHEDULE: Record<string, number> = scheduleJson;
|
||||||
|
|
||||||
|
/** Week in which a topic was (or will be) taught. */
|
||||||
|
export function topicWeek(topic: number): number | undefined {
|
||||||
|
for (const [date, t] of Object.entries(SCHEDULE)) {
|
||||||
|
if (t === topic) return campaignWeek(date);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── deterministic sampling ───────────────────────────────────────
|
||||||
|
|
||||||
|
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
|
||||||
|
export function rng(seed: string): () => number {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
h ^= seed.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 0x01000193);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
h = Math.imul(h ^ (h >>> 15), h | 1);
|
||||||
|
h ^= h + Math.imul(h ^ (h >>> 7), h | 61);
|
||||||
|
return ((h ^ (h >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Up to n elements, Fisher–Yates order driven by the seeded PRNG. */
|
||||||
|
export function sample<T>(pool: T[], n: number, random: () => number): T[] {
|
||||||
|
const copy = [...pool];
|
||||||
|
for (let i = copy.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(random() * (i + 1));
|
||||||
|
[copy[i], copy[j]] = [copy[j]!, copy[i]!];
|
||||||
|
}
|
||||||
|
return copy.slice(0, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── rows ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type Stage = "new" | "+2" | "+5" | "+10" | "retired";
|
||||||
|
export type Result = "pass" | "fail";
|
||||||
|
export type Kind = "first" | "review" | "drill" | "gate";
|
||||||
|
|
||||||
|
export interface ProblemRow {
|
||||||
|
lc_number: number;
|
||||||
|
issue: number;
|
||||||
|
topic_issue: number;
|
||||||
|
title: string;
|
||||||
|
difficulty: string;
|
||||||
|
set_label: string;
|
||||||
|
stage: Stage;
|
||||||
|
next_review: string | null;
|
||||||
|
defer_until: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INTERVAL: Record<string, number> = { "+2": 2, "+5": 5, "+10": 10 };
|
||||||
|
const NEXT_STAGE: Record<string, Stage> = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" };
|
||||||
|
|
||||||
|
// ── queries ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow | null> {
|
||||||
|
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
||||||
|
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM problems
|
||||||
|
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||||
|
AND (defer_until IS NULL OR defer_until <= ?1)
|
||||||
|
ORDER BY next_review, lc_number`,
|
||||||
|
)
|
||||||
|
.bind(date)
|
||||||
|
.all<ProblemRow>();
|
||||||
|
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
|
||||||
|
* for Saturday's gate) AND showing learning evidence: at least one of the
|
||||||
|
* topic's core problems has entered the ladder. A skipped learning day never
|
||||||
|
* feeds drills just because its calendar week lapsed. Never repeated,
|
||||||
|
* seeded by date. Boosted topics contribute up to 2 extra.
|
||||||
|
*/
|
||||||
|
export async function pickDrills(
|
||||||
|
db: D1Database,
|
||||||
|
date: string,
|
||||||
|
budget: number,
|
||||||
|
): Promise<ProblemRow[]> {
|
||||||
|
if (budget <= 0) return [];
|
||||||
|
const week = campaignWeek(date);
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT p.*, t.boost AS boost FROM problems p
|
||||||
|
JOIN topics t ON t.issue = p.topic_issue
|
||||||
|
WHERE p.set_label = 'optional' AND p.stage = 'new'
|
||||||
|
AND p.lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = p.lc_number)
|
||||||
|
AND EXISTS (SELECT 1 FROM problems c
|
||||||
|
WHERE c.topic_issue = p.topic_issue
|
||||||
|
AND c.set_label = 'core' AND c.stage != 'new')
|
||||||
|
ORDER BY p.lc_number`,
|
||||||
|
)
|
||||||
|
.all<ProblemRow & { boost: number }>();
|
||||||
|
const pool = results.filter((p) => {
|
||||||
|
const w = topicWeek(p.topic_issue);
|
||||||
|
return w !== undefined && w < week;
|
||||||
|
});
|
||||||
|
const boosted = pool.filter((p) => p.boost === 1);
|
||||||
|
const regular = pool.filter((p) => p.boost !== 1);
|
||||||
|
const boostPicks = sample(boosted, Math.min(2, budget), rng(`boost-${date}`));
|
||||||
|
const regularPicks = sample(
|
||||||
|
regular,
|
||||||
|
Math.min(2, Math.max(0, budget - boostPicks.length)),
|
||||||
|
rng(`drill-${date}`),
|
||||||
|
);
|
||||||
|
return [...boostPicks, ...regularPicks].slice(0, budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consecutive days with ≥1 attempt, ending today or yesterday. */
|
||||||
|
export async function streak(db: D1Database, today: string): Promise<number> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare("SELECT DISTINCT date FROM attempts ORDER BY date DESC LIMIT 90")
|
||||||
|
.all<{ date: string }>();
|
||||||
|
const days = new Set(results.map((r) => r.date));
|
||||||
|
let cursor = days.has(today) ? today : addDays(today, -1);
|
||||||
|
let n = 0;
|
||||||
|
while (days.has(cursor)) {
|
||||||
|
n++;
|
||||||
|
cursor = addDays(cursor, -1);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the one write path ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LogOutcome {
|
||||||
|
lc: number;
|
||||||
|
title: string;
|
||||||
|
kind: Kind;
|
||||||
|
result: Result;
|
||||||
|
stage: Stage;
|
||||||
|
next_review: string | null;
|
||||||
|
/** First-ever attempt — caller closes the sub-issue on pass. */
|
||||||
|
first: boolean;
|
||||||
|
issue: number;
|
||||||
|
/** Email one-tap replay: nothing changed. */
|
||||||
|
duplicate: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an attempt and move the ladder. Ladder semantics live here and only
|
||||||
|
* 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. 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" | "commit";
|
||||||
|
gate?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<LogOutcome> {
|
||||||
|
const p = await getProblem(db, opts.lc);
|
||||||
|
const nothing: LogOutcome = {
|
||||||
|
lc: opts.lc,
|
||||||
|
title: "",
|
||||||
|
kind: "review",
|
||||||
|
result: opts.result,
|
||||||
|
stage: "new",
|
||||||
|
next_review: null,
|
||||||
|
first: false,
|
||||||
|
issue: 0,
|
||||||
|
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` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempted = await db
|
||||||
|
.prepare("SELECT 1 AS x FROM attempts WHERE lc_number = ? LIMIT 1")
|
||||||
|
.bind(opts.lc)
|
||||||
|
.first();
|
||||||
|
const first = !attempted && p.stage === "new";
|
||||||
|
const kind: Kind = opts.gate ? "gate" : first ? (p.set_label === "optional" ? "drill" : "first") : "review";
|
||||||
|
|
||||||
|
const inserted = await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO attempts (lc_number, date, kind, result, source) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
)
|
||||||
|
.bind(opts.lc, opts.date, kind, opts.result, opts.source)
|
||||||
|
.run();
|
||||||
|
if (opts.source === "email" && inserted.meta.changes === 0) {
|
||||||
|
return { ...nothing, title: p.title, kind, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ladder move. First-ever logs enter at +2 for pass AND fail.
|
||||||
|
let stage: Stage;
|
||||||
|
if (first) {
|
||||||
|
stage = "+2";
|
||||||
|
} else if (opts.result === "pass") {
|
||||||
|
stage = NEXT_STAGE[p.stage] ?? "retired";
|
||||||
|
} else {
|
||||||
|
stage = "+2";
|
||||||
|
}
|
||||||
|
const next = stage === "retired" ? null : addDays(opts.date, INTERVAL[stage]!);
|
||||||
|
|
||||||
|
const writes = [
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE problems SET stage = ?, next_review = ?, defer_until = NULL WHERE lc_number = ?",
|
||||||
|
).bind(stage, next, opts.lc),
|
||||||
|
];
|
||||||
|
if (first && kind === "drill") {
|
||||||
|
writes.push(
|
||||||
|
db.prepare("INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (?)").bind(opts.lc),
|
||||||
|
);
|
||||||
|
if (opts.result === "fail") {
|
||||||
|
writes.push(
|
||||||
|
db.prepare("UPDATE topics SET misses = misses + 1 WHERE issue = ?").bind(p.topic_issue),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db.batch(writes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lc: opts.lc,
|
||||||
|
title: p.title,
|
||||||
|
kind,
|
||||||
|
result: opts.result,
|
||||||
|
stage,
|
||||||
|
next_review: next,
|
||||||
|
first,
|
||||||
|
issue: p.issue,
|
||||||
|
duplicate: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/stats — the one JSON document behind the docs progress page.
|
||||||
|
* Read-only aggregation over D1; shape is the page's contract, change both
|
||||||
|
* together. A problem counts as "done" once it entered the ladder
|
||||||
|
* (stage != 'new').
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
CAMPAIGN_DAYS,
|
||||||
|
CAMPAIGN_START,
|
||||||
|
addDays,
|
||||||
|
campaignDay,
|
||||||
|
campaignWeek,
|
||||||
|
isoWeek,
|
||||||
|
streak,
|
||||||
|
} from "./srs.ts";
|
||||||
|
|
||||||
|
const PHASE_NAMES: Record<number, string> = {
|
||||||
|
1: "I — Linear",
|
||||||
|
2: "II — Nodal & Grid",
|
||||||
|
3: "III — Hierarchical",
|
||||||
|
4: "IV — Relational",
|
||||||
|
5: "V — Decision Space",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function buildStats(db: D1Database, today: string): Promise<object> {
|
||||||
|
const { results: phaseRows } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT t.milestone AS milestone, p.set_label AS set_label,
|
||||||
|
COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done
|
||||||
|
FROM problems p JOIN topics t ON t.issue = p.topic_issue
|
||||||
|
WHERE t.milestone IS NOT NULL
|
||||||
|
GROUP BY t.milestone, p.set_label
|
||||||
|
ORDER BY t.milestone`,
|
||||||
|
)
|
||||||
|
.all<{ milestone: number; set_label: string; total: number; done: number }>();
|
||||||
|
const phases = new Map<
|
||||||
|
number,
|
||||||
|
{ milestone: number; name: string } & Record<string, number | string>
|
||||||
|
>();
|
||||||
|
for (const row of phaseRows) {
|
||||||
|
const phase =
|
||||||
|
phases.get(row.milestone) ??
|
||||||
|
({
|
||||||
|
milestone: row.milestone,
|
||||||
|
name: PHASE_NAMES[row.milestone] ?? `Phase ${row.milestone}`,
|
||||||
|
core_done: 0,
|
||||||
|
core_total: 0,
|
||||||
|
optional_done: 0,
|
||||||
|
optional_total: 0,
|
||||||
|
deferred_done: 0,
|
||||||
|
deferred_total: 0,
|
||||||
|
} as { milestone: number; name: string } & Record<string, number | string>);
|
||||||
|
phase[`${row.set_label}_done`] = row.done;
|
||||||
|
phase[`${row.set_label}_total`] = row.total;
|
||||||
|
phases.set(row.milestone, phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results: ladderRows } = await db
|
||||||
|
.prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage")
|
||||||
|
.all<{ stage: string; n: number }>();
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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 }[] = [];
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
const date = addDays(today, i);
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) AS n FROM problems
|
||||||
|
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||||
|
AND (defer_until IS NULL OR defer_until <= ?1)`,
|
||||||
|
)
|
||||||
|
.bind(date)
|
||||||
|
.first<{ n: number }>();
|
||||||
|
queue.push({ date, due: row?.n ?? 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const recent: { date: string; attempts: number; passes: number }[] = [];
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const date = addDays(today, -i);
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) AS attempts,
|
||||||
|
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes
|
||||||
|
FROM attempts WHERE date = ?`,
|
||||||
|
)
|
||||||
|
.bind(date)
|
||||||
|
.first<{ attempts: number; passes: number | null }>();
|
||||||
|
recent.push({ date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
generated: new Date().toISOString(),
|
||||||
|
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
|
||||||
|
phases: [...phases.values()],
|
||||||
|
ladder,
|
||||||
|
gates,
|
||||||
|
streak: await streak(db, today),
|
||||||
|
queue,
|
||||||
|
recent,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["esnext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["./worker-configuration.d.ts", "bun"]
|
||||||
|
},
|
||||||
|
"include": ["src", "scripts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// SRS Worker — daily digest email, one-tap logging, weekly review issues,
|
||||||
|
// live SVG charts, and the /api/stats feed for the docs progress page.
|
||||||
|
//
|
||||||
|
// D1 is the source of truth for SRS state; GitHub issues own the catalog;
|
||||||
|
// apps/api/data/schedule.json (bundled) owns the schedule. JSONC over TOML per
|
||||||
|
// Workers best practices (newer features are JSON-only).
|
||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"name": "srs-api",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"compatibility_date": "2026-08-24",
|
||||||
|
"observability": { "enabled": true },
|
||||||
|
|
||||||
|
"d1_databases": [
|
||||||
|
{ "binding": "DB", "database_name": "srs", "database_id": "2ee2c34a-c2e2-4ebb-934e-c9e7dc8aa4f1" }
|
||||||
|
],
|
||||||
|
|
||||||
|
// 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"] }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Double-cron per event makes each DST-proof: the code fires only when the
|
||||||
|
// computed ET hour matches (8 AM daily digest, midnight-Saturday review).
|
||||||
|
// 8 AM ET = 12:00 UTC in EDT, 13:00 UTC in EST; midnight ET = 4/5 UTC.
|
||||||
|
"triggers": {
|
||||||
|
"crons": ["0 12 * * *", "0 13 * * *", "0 4 * * 6", "0 5 * * 6"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"vars": {
|
||||||
|
"REPO": "prdlk/leetcode",
|
||||||
|
"FROM_EMAIL": "srs@prdlk.com",
|
||||||
|
"TO_EMAIL": "prnk28@gmail.com",
|
||||||
|
"DOCS_URL": "https://prdlk.github.io/leetcode",
|
||||||
|
"DOCS_ORIGIN": "https://prdlk.github.io,https://lc.prad.nu",
|
||||||
|
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secrets (wrangler secret put): GH_PAT, WEBHOOK_SECRET, LINK_KEY.
|
||||||
|
}
|
||||||
Executable
+277
@@ -0,0 +1,277 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* closed. Only open issues are touched, so re-runs are no-ops and backfilling
|
||||||
|
* needs no special casing. Removing a solution never reopens an issue.
|
||||||
|
*
|
||||||
|
* `bun run pick` scaffolds a statement header plus an empty function body, so
|
||||||
|
* file existence alone means nothing — a stub must not close its issue. See
|
||||||
|
* isImplemented().
|
||||||
|
*
|
||||||
|
* 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";
|
||||||
|
|
||||||
|
import { github } from "./github.ts";
|
||||||
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
|
|
||||||
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
|
const WORK = join(ROOT, "work");
|
||||||
|
|
||||||
|
const DRY =
|
||||||
|
process.argv.includes("--dry-run") ||
|
||||||
|
process.env.DRY_RUN === "1" ||
|
||||||
|
process.env.DRY_RUN === "true";
|
||||||
|
|
||||||
|
// ── is the file a real solution or just a scaffolded stub? ────────
|
||||||
|
|
||||||
|
/** Placeholder bodies that leetcode-cli / a human leaves behind. */
|
||||||
|
const PLACEHOLDERS: Record<string, true> = { pass: true, "...": true, TODO: true };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a work/ file contains an implementation.
|
||||||
|
*
|
||||||
|
* The header comment is dropped the same way sync.ts splitSource() does it
|
||||||
|
* (duplicated rather than imported, because sync.ts runs its whole pipeline on
|
||||||
|
* import). Comments must go before any brace analysis: the scaffold's JSDoc
|
||||||
|
* carries `@param {number[]}`, whose braces would otherwise read as a body.
|
||||||
|
*
|
||||||
|
* A brace-language file counts as implemented when at least one *innermost*
|
||||||
|
* brace pair holds real content. That distinguishes a bare stub
|
||||||
|
* (`function(nums) {}`) and a class-shaped design stub (every method body
|
||||||
|
* empty) from any genuine solution, whose innermost block always has code.
|
||||||
|
*/
|
||||||
|
function isImplemented(src: string, lang: "js" | "py"): boolean {
|
||||||
|
if (lang === "py") {
|
||||||
|
const open = src.indexOf('"""');
|
||||||
|
const close = src.indexOf('"""', open + 3);
|
||||||
|
const code = open === -1 || close === -1 ? src : src.slice(close + 3);
|
||||||
|
for (const raw of code.split("\n")) {
|
||||||
|
const line = raw.replace(/#.*$/, "").trim();
|
||||||
|
if (!line || PLACEHOLDERS[line]) continue;
|
||||||
|
if (/^(?:@|def\s|class\s)/.test(line)) continue;
|
||||||
|
return true; // a statement inside some def body
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerEnd = src.indexOf("*/");
|
||||||
|
const body = headerEnd === -1 ? src : src.slice(headerEnd + 2);
|
||||||
|
const code = body.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/.*$/gm, " ");
|
||||||
|
|
||||||
|
let innermost = -1;
|
||||||
|
for (let i = 0; i < code.length; i++) {
|
||||||
|
if (code[i] === "{") {
|
||||||
|
innermost = i;
|
||||||
|
} else if (code[i] === "}" && innermost !== -1) {
|
||||||
|
const inner = code.slice(innermost + 1, i).replace(/[\s;]/g, "");
|
||||||
|
if (inner && !PLACEHOLDERS[inner]) return true;
|
||||||
|
innermost = -1; // measured; the enclosing pair is not innermost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── repo + auth ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const gh = await github();
|
||||||
|
|
||||||
|
// ── work/ inventory ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface WorkEntry {
|
||||||
|
files: string[];
|
||||||
|
implemented: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LC number -> solution files (a problem may have both js and py). */
|
||||||
|
const work = new Map<number, WorkEntry>();
|
||||||
|
for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
||||||
|
const m = basename(rel).match(/^(\d+)\.(.+)\.(?:js|py)$/);
|
||||||
|
if (!m) {
|
||||||
|
console.warn(`skip (unrecognized name): work/${rel}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const num = Number(m[1]);
|
||||||
|
const src = await Bun.file(join(WORK, rel)).text();
|
||||||
|
const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js");
|
||||||
|
const entry = work.get(num);
|
||||||
|
if (entry) {
|
||||||
|
entry.files.push(`work/${rel}`);
|
||||||
|
entry.implemented ||= implemented;
|
||||||
|
} else {
|
||||||
|
work.set(num, { files: [`work/${rel}`], implemented });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── open problem issues ──────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ProblemIssue {
|
||||||
|
number: number;
|
||||||
|
set: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrow one element of the /issues payload to the fields this script needs.
|
||||||
|
* Returns undefined for pull requests and for anything whose title is not a
|
||||||
|
* `LC <num> ...` problem, which is how non-curriculum rows get skipped.
|
||||||
|
*/
|
||||||
|
function readProblemIssue(
|
||||||
|
value: unknown,
|
||||||
|
): { lc: number; issue: ProblemIssue } | undefined {
|
||||||
|
if (!value || typeof value !== "object") return;
|
||||||
|
if ("pull_request" in value) return; // the /issues route also lists PRs
|
||||||
|
if (!("number" in value) || typeof value.number !== "number") return;
|
||||||
|
if (!("title" in value) || typeof value.title !== "string") return;
|
||||||
|
const lc = value.title.match(/^LC (\d+) /);
|
||||||
|
if (!lc) return;
|
||||||
|
|
||||||
|
let set = "—";
|
||||||
|
if ("labels" in value && Array.isArray(value.labels)) {
|
||||||
|
for (const label of value.labels) {
|
||||||
|
if (
|
||||||
|
label &&
|
||||||
|
typeof label === "object" &&
|
||||||
|
"name" in label &&
|
||||||
|
typeof label.name === "string" &&
|
||||||
|
label.name.startsWith("set:")
|
||||||
|
) {
|
||||||
|
set = label.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { lc: Number(lc[1]), issue: { number: value.number, set } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LC number -> open issue carrying the `problem` label. */
|
||||||
|
const open = new Map<number, ProblemIssue>();
|
||||||
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=open`)) {
|
||||||
|
const parsed = readProblemIssue(raw);
|
||||||
|
if (parsed) open.set(parsed.lc, parsed.issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── reconcile ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const sha = process.env.GITHUB_SHA;
|
||||||
|
const report: string[][] = [];
|
||||||
|
let closed = 0;
|
||||||
|
|
||||||
|
for (const num of [...work.keys()].sort((a, b) => a - b)) {
|
||||||
|
const entry = work.get(num)!;
|
||||||
|
const issue = open.get(num);
|
||||||
|
|
||||||
|
// Warm-ups and out-of-curriculum practice have no issue. Not an error.
|
||||||
|
if (!issue) {
|
||||||
|
report.push([`${num}`, "—", "—", "no open issue"]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.implemented) {
|
||||||
|
report.push([`${num}`, `#${issue.number}`, issue.set, "stub — skipped"]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (DRY) {
|
||||||
|
report.push([`${num}`, `#${issue.number}`, issue.set, "would close"]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = entry.files
|
||||||
|
.map((f) =>
|
||||||
|
sha ? `[\`${f}\`](https://github.com/${gh.repo}/blob/${sha}/${encodeURI(f)})` : `\`${f}\``,
|
||||||
|
)
|
||||||
|
.join(", ");
|
||||||
|
await gh.closeIssue(
|
||||||
|
issue.number,
|
||||||
|
`Solved — solution committed at ${links}.\n\n` +
|
||||||
|
"Closed automatically by `close-solved`. Fill in the close-out block above if you have not already.",
|
||||||
|
);
|
||||||
|
report.push([`${num}`, `#${issue.number}`, issue.set, "closed"]);
|
||||||
|
closed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── push the solved set into D1 ───────────────────────────────────
|
||||||
|
|
||||||
|
// Closing an issue is invisible to the Worker: its catalog reconcile reads
|
||||||
|
// GitHub for titles, labels and milestones and never looks at issue state,
|
||||||
|
// and D1's SRS columns only ever move through logAttempt(). So a solution
|
||||||
|
// pushed here — rather than tapped in the digest email — has to be reported,
|
||||||
|
// or the charts, the digest's "already solved" ticks, and the drill/gate
|
||||||
|
// pools all keep treating it as untouched.
|
||||||
|
//
|
||||||
|
// The whole implemented set goes over, not just this run's closes: the
|
||||||
|
// Worker drops anything already on the ladder, so the call is a total
|
||||||
|
// recompute and backfills whatever earlier runs missed.
|
||||||
|
const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
||||||
|
const solved = [...work.entries()]
|
||||||
|
.filter(([, entry]) => entry.implemented)
|
||||||
|
.map(([lc]) => lc)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
let syncNote: string;
|
||||||
|
let syncFailed = false;
|
||||||
|
if (!process.env.SRS_ADMIN_KEY) {
|
||||||
|
syncNote = "SRS_ADMIN_KEY unset — D1 not touched";
|
||||||
|
} else if (solved.length === 0) {
|
||||||
|
syncNote = "nothing implemented — D1 not touched";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SRS_API}/admin/solved${DRY ? "?dry=1" : ""}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${process.env.SRS_ADMIN_KEY}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ lc: solved }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
||||||
|
// Narrow the report payload instead of casting it; a shape change should
|
||||||
|
// read as "logged nothing", not throw inside the reconciler.
|
||||||
|
const payload: unknown = await res.json();
|
||||||
|
const fields = payload && typeof payload === "object" ? payload : {};
|
||||||
|
const strings = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : []);
|
||||||
|
const logged = strings("logged" in fields ? fields.logged : null);
|
||||||
|
const skipped = strings("skipped" in fields ? fields.skipped : null);
|
||||||
|
syncNote =
|
||||||
|
`${DRY ? "would log" : "logged"} ${logged.length} of ${solved.length} ` +
|
||||||
|
`(${skipped.length} already on the ladder or off-curriculum)`;
|
||||||
|
for (const line of logged) console.log(` d1: ${line}`);
|
||||||
|
} catch (err) {
|
||||||
|
// The closes above already landed; surface the failure instead of
|
||||||
|
// letting D1 drift silently until the next push.
|
||||||
|
syncFailed = true;
|
||||||
|
syncNote = `D1 push FAILED: ${err instanceof Error ? err.message : String(err)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── report ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const rows = [["lc", "issue", "set", "status"], ...report];
|
||||||
|
printTable(rows);
|
||||||
|
|
||||||
|
const actionable = report.filter((r) => r[3] !== "no open issue");
|
||||||
|
console.log(
|
||||||
|
`\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}`}` +
|
||||||
|
`\nd1: ${syncNote}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeStepSummary(
|
||||||
|
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
|
||||||
|
`${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") +
|
||||||
|
`\nD1: ${syncNote}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Non-zero only for the D1 push: the closes are already reported above.
|
||||||
|
if (syncFailed) process.exitCode = 1;
|
||||||
Executable
+150
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Close the GitHub issue for every topic whose required (core) problems are done.
|
||||||
|
*
|
||||||
|
* Each `topic`-labelled issue owns its problems as GitHub sub-issues, and each
|
||||||
|
* problem carries exactly one `set:` label — `set:core` is required, while
|
||||||
|
* `set:optional` and `set:deferred` are extra credit. A topic is finished when
|
||||||
|
* every one of its core sub-issues is closed; optional/deferred state is
|
||||||
|
* ignored, matching the day rule in each topic body ("Core problems first").
|
||||||
|
*
|
||||||
|
* Like close-solved.ts this reconciles state instead of reacting to an event
|
||||||
|
* payload: re-runs are no-ops, backfilling needs no special casing, and only
|
||||||
|
* open topics are touched, so reopening a problem never reopens its topic.
|
||||||
|
*
|
||||||
|
* bun apps/cli/close-topics.ts # close finished topics
|
||||||
|
* bun apps/cli/close-topics.ts --dry-run # report only, touch nothing
|
||||||
|
*
|
||||||
|
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
||||||
|
*/
|
||||||
|
import { github } from "./github.ts";
|
||||||
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
|
|
||||||
|
const DRY =
|
||||||
|
process.argv.includes("--dry-run") ||
|
||||||
|
process.env.DRY_RUN === "1" ||
|
||||||
|
process.env.DRY_RUN === "true";
|
||||||
|
|
||||||
|
const gh = await github();
|
||||||
|
|
||||||
|
// ── open topic issues ────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Topic {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrow one element of the /issues payload to the fields this script needs.
|
||||||
|
* The `topic` label filter cannot exclude pull requests, so drop those here.
|
||||||
|
*/
|
||||||
|
function readTopic(value: unknown): Topic | undefined {
|
||||||
|
if (!value || typeof value !== "object") return;
|
||||||
|
if ("pull_request" in value) return; // the /issues route also lists PRs
|
||||||
|
if (!("number" in value) || typeof value.number !== "number") return;
|
||||||
|
if (!("title" in value) || typeof value.title !== "string") return;
|
||||||
|
return { number: value.number, title: value.title };
|
||||||
|
}
|
||||||
|
|
||||||
|
const topics: Topic[] = [];
|
||||||
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=open`)) {
|
||||||
|
const topic = readTopic(raw);
|
||||||
|
if (topic) topics.push(topic);
|
||||||
|
}
|
||||||
|
topics.sort((a, b) => a.number - b.number);
|
||||||
|
|
||||||
|
// ── core sub-issue state per topic ───────────────────────────────
|
||||||
|
|
||||||
|
interface Core {
|
||||||
|
/** Core sub-issues, closed and open alike, lowest number first. */
|
||||||
|
all: number[];
|
||||||
|
/** The core sub-issues still open — non-empty means the topic stays open. */
|
||||||
|
pending: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a topic's core sub-issue state. The sub_issues payload carries the full
|
||||||
|
* issue objects (state + labels), so no per-problem follow-up request is needed.
|
||||||
|
*/
|
||||||
|
async function readCore(topic: number): Promise<Core> {
|
||||||
|
const all: number[] = [];
|
||||||
|
const pending: number[] = [];
|
||||||
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
|
||||||
|
if (!raw || typeof raw !== "object") continue;
|
||||||
|
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||||
|
if (!("state" in raw) || typeof raw.state !== "string") continue;
|
||||||
|
if (!("labels" in raw) || !Array.isArray(raw.labels)) continue;
|
||||||
|
|
||||||
|
const core = raw.labels.some(
|
||||||
|
(label) =>
|
||||||
|
label &&
|
||||||
|
typeof label === "object" &&
|
||||||
|
"name" in label &&
|
||||||
|
label.name === "set:core",
|
||||||
|
);
|
||||||
|
if (!core) continue;
|
||||||
|
|
||||||
|
all.push(raw.number);
|
||||||
|
if (raw.state === "open") pending.push(raw.number);
|
||||||
|
}
|
||||||
|
all.sort((a, b) => a - b);
|
||||||
|
pending.sort((a, b) => a - b);
|
||||||
|
return { all, pending };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── reconcile ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const report: string[][] = [];
|
||||||
|
let closed = 0;
|
||||||
|
|
||||||
|
for (const topic of topics) {
|
||||||
|
const core = await readCore(topic.number);
|
||||||
|
const progress = `${core.all.length - core.pending.length}/${core.all.length}`;
|
||||||
|
|
||||||
|
// A topic with no core sub-issues has nothing to complete: never close it,
|
||||||
|
// since that would be indistinguishable from a mis-labelled problem set.
|
||||||
|
if (core.all.length === 0) {
|
||||||
|
report.push([`#${topic.number}`, topic.title, progress, "no core set"]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (core.pending.length > 0) {
|
||||||
|
report.push([
|
||||||
|
`#${topic.number}`,
|
||||||
|
topic.title,
|
||||||
|
progress,
|
||||||
|
`open: ${core.pending.map((n) => `#${n}`).join(" ")}`,
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (DRY) {
|
||||||
|
report.push([`#${topic.number}`, topic.title, progress, "would close"]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await gh.closeIssue(
|
||||||
|
topic.number,
|
||||||
|
`Core set complete — all ${core.all.length} core problems closed ` +
|
||||||
|
`(${core.all.map((n) => `#${n}`).join(", ")}).\n\n` +
|
||||||
|
"Closed automatically by `close-topics`. Optional and deferred problems " +
|
||||||
|
"stay open as extra credit.",
|
||||||
|
);
|
||||||
|
report.push([`#${topic.number}`, topic.title, progress, "closed"]);
|
||||||
|
closed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── report ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const rows = [["topic", "title", "core", "status"], ...report];
|
||||||
|
printTable(rows);
|
||||||
|
|
||||||
|
const finished = report.filter((r) => r[3] === "closed" || r[3] === "would close");
|
||||||
|
console.log(
|
||||||
|
`\n${topics.length} open topics · ` +
|
||||||
|
`${DRY ? `would close ${finished.length}` : `closed ${closed}`}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeStepSummary(
|
||||||
|
`### close-topics${DRY ? " (dry run)" : ""}\n\n` +
|
||||||
|
`${topics.length} open topic issues, ${finished.length} with a complete core set.\n\n` +
|
||||||
|
(report.length ? `${markdownTable(rows)}\n` : "No open topics.\n"),
|
||||||
|
);
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* Local LeetCode problem cache backed by SQLite at
|
||||||
|
* ~/.local/share/leetcode/data.db (XDG_DATA_HOME honored).
|
||||||
|
*
|
||||||
|
* One `problems` table, two freshness rules:
|
||||||
|
* - the full problem index (id / title / slug / difficulty / paid flag) is
|
||||||
|
* refreshed from leetcode.com at most once per 24h (`meta.index_fetched_at`);
|
||||||
|
* - descriptions (HTML from GraphQL `question.content`) are fetched once per
|
||||||
|
* slug and kept forever. Only the campaign problems linked from README.md
|
||||||
|
* are ever fetched, and paid-only problems are skipped because GraphQL
|
||||||
|
* returns null content for them.
|
||||||
|
*
|
||||||
|
* A `solutions` table tracks the lifecycle of each work/ file by LC number:
|
||||||
|
* `created_at` (backfilled from the file's birthtime on every picker run),
|
||||||
|
* `last_tested` and `last_submitted` (stamped on exit 0 of leetcode-cli).
|
||||||
|
* The test picker sorts by last_tested, the submit picker by created_at, and
|
||||||
|
* submit hides anything with a last_submitted.
|
||||||
|
*
|
||||||
|
* Library: side-effect-free on import — nothing is opened or fetched until
|
||||||
|
* problemDb() is called. Index upserts never touch the description column, so
|
||||||
|
* a daily refresh cannot evict cached statements.
|
||||||
|
*/
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
export interface Problem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
difficulty: "Easy" | "Medium" | "Hard";
|
||||||
|
paidOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lifecycle of one work/ solution, keyed by LC number. ISO timestamps. */
|
||||||
|
export interface SolutionActivity {
|
||||||
|
createdAt: string;
|
||||||
|
lastTested: string | null;
|
||||||
|
lastSubmitted: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
|
||||||
|
const INDEX_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export const DB_PATH = join(
|
||||||
|
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
|
||||||
|
"leetcode",
|
||||||
|
"data.db",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── README parsing ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Campaign problem slugs linked from README.md, in order, deduplicated. */
|
||||||
|
export function readmeSlugs(markdown: string): string[] {
|
||||||
|
const slugs = new Set<string>();
|
||||||
|
for (const m of markdown.matchAll(/https:\/\/leetcode\.com\/problems\/([\w-]+)\//g)) {
|
||||||
|
slugs.add(m[1]!);
|
||||||
|
}
|
||||||
|
return [...slugs];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── remote fetches ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchIndex(): Promise<Problem[]> {
|
||||||
|
const res = await fetch("https://leetcode.com/api/problems/all/", {
|
||||||
|
headers: { "user-agent": "Mozilla/5.0" },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`GET /api/problems/all -> ${res.status} ${res.statusText}`);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
stat_status_pairs: Array<{
|
||||||
|
stat: {
|
||||||
|
frontend_question_id: number;
|
||||||
|
question__title: string;
|
||||||
|
question__title_slug: string;
|
||||||
|
};
|
||||||
|
difficulty: { level: 1 | 2 | 3 };
|
||||||
|
paid_only: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
return data.stat_status_pairs
|
||||||
|
.map((p) => ({
|
||||||
|
id: p.stat.frontend_question_id,
|
||||||
|
title: p.stat.question__title,
|
||||||
|
slug: p.stat.question__title_slug,
|
||||||
|
difficulty: DIFFICULTY[p.difficulty.level] as Problem["difficulty"],
|
||||||
|
paidOnly: p.paid_only,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.id - b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTML problem statement, or null when LeetCode withholds it (paid-only). */
|
||||||
|
export async function fetchDescription(slug: string): Promise<string | null> {
|
||||||
|
const res = await fetch("https://leetcode.com/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"user-agent": "Mozilla/5.0",
|
||||||
|
referer: `https://leetcode.com/problems/${slug}/`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: "query questionContent($titleSlug: String!) { question(titleSlug: $titleSlug) { content } }",
|
||||||
|
variables: { titleSlug: slug },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`POST /graphql (${slug}) -> ${res.status} ${res.statusText}`);
|
||||||
|
const data = (await res.json()) as { data?: { question?: { content?: string | null } | null } };
|
||||||
|
return data.data?.question?.content ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the cache ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function problemDb(path = DB_PATH) {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
const db = new Database(path);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS problems (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
difficulty TEXT NOT NULL,
|
||||||
|
paid_only INTEGER NOT NULL,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS solutions (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_tested TEXT,
|
||||||
|
last_submitted TEXT
|
||||||
|
);
|
||||||
|
DROP TABLE IF EXISTS submissions;
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const getMeta = db.query<{ value: string }, [string]>("SELECT value FROM meta WHERE key = ?1");
|
||||||
|
const setMeta = db.query<never, [string, string]>(
|
||||||
|
"INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
);
|
||||||
|
const upsert = db.query<never, [number, string, string, string, number]>(
|
||||||
|
`INSERT INTO problems (id, title, slug, difficulty, paid_only)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
title = excluded.title, slug = excluded.slug,
|
||||||
|
difficulty = excluded.difficulty, paid_only = excluded.paid_only`,
|
||||||
|
);
|
||||||
|
const descState = db.query<{ paid_only: number; described: number }, [string]>(
|
||||||
|
"SELECT paid_only, description IS NOT NULL AS described FROM problems WHERE slug = ?1",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
|
||||||
|
/** True when the index has never been fetched or is older than 24h. */
|
||||||
|
indexStale(): boolean {
|
||||||
|
const row = getMeta.get("index_fetched_at");
|
||||||
|
return !row || Date.now() - Number(row.value) > INDEX_TTL_MS;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Pull the full index from leetcode.com and upsert it. Returns the count. */
|
||||||
|
async refreshIndex(): Promise<number> {
|
||||||
|
const problems = await fetchIndex();
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const p of problems) upsert.run(p.id, p.title, p.slug, p.difficulty, p.paidOnly ? 1 : 0);
|
||||||
|
setMeta.run("index_fetched_at", String(Date.now()));
|
||||||
|
})();
|
||||||
|
return problems.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Every cached problem, ordered by LC number. */
|
||||||
|
all(): Problem[] {
|
||||||
|
const rows = db
|
||||||
|
.query<{ id: number; title: string; slug: string; difficulty: string; paid_only: number }, []>(
|
||||||
|
"SELECT id, title, slug, difficulty, paid_only FROM problems ORDER BY id",
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
slug: r.slug,
|
||||||
|
difficulty: r.difficulty as Problem["difficulty"],
|
||||||
|
paidOnly: r.paid_only === 1,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Free (non-premium) slugs among `slugs` with no cached description yet. */
|
||||||
|
missingDescriptions(slugs: string[]): string[] {
|
||||||
|
return slugs.filter((slug) => {
|
||||||
|
const row = descState.get(slug);
|
||||||
|
return row !== null && !row.paid_only && !row.described;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
saveDescription(slug: string, html: string): void {
|
||||||
|
db.query("UPDATE problems SET description = ?2 WHERE slug = ?1").run(slug, html);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Per-solution lifecycle rows keyed by LC number. */
|
||||||
|
solutionActivity(): Map<number, SolutionActivity> {
|
||||||
|
const rows = db
|
||||||
|
.query<{ id: number; created_at: string; last_tested: string | null; last_submitted: string | null }, []>(
|
||||||
|
"SELECT id, created_at, last_tested, last_submitted FROM solutions",
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
return new Map(
|
||||||
|
rows.map((r) => [r.id, { createdAt: r.created_at, lastTested: r.last_tested, lastSubmitted: r.last_submitted }]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Register a solution's creation time; existing rows are left alone. */
|
||||||
|
ensureSolution(id: number, createdAt: string): void {
|
||||||
|
db.query("INSERT INTO solutions (id, created_at) VALUES (?1, ?2) ON CONFLICT(id) DO NOTHING").run(id, createdAt);
|
||||||
|
},
|
||||||
|
|
||||||
|
markTested(id: number): void {
|
||||||
|
db.query(
|
||||||
|
`INSERT INTO solutions (id, created_at, last_tested) VALUES (?1, ?2, ?2)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET last_tested = excluded.last_tested`,
|
||||||
|
).run(id, new Date().toISOString());
|
||||||
|
},
|
||||||
|
|
||||||
|
markSubmitted(id: number): void {
|
||||||
|
db.query(
|
||||||
|
`INSERT INTO solutions (id, created_at, last_submitted) VALUES (?1, ?2, ?2)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET last_submitted = excluded.last_submitted`,
|
||||||
|
).run(id, new Date().toISOString());
|
||||||
|
},
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
db.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* GitHub REST plumbing shared by the issue reconcilers (close-solved,
|
||||||
|
* close-topics).
|
||||||
|
*
|
||||||
|
* Importing this module is side-effect free — nothing resolves credentials or
|
||||||
|
* touches the network until github() is awaited — so a script can import it
|
||||||
|
* without inheriting another script's pipeline.
|
||||||
|
*
|
||||||
|
* Repo: GITHUB_REPOSITORY, else the `origin` remote.
|
||||||
|
* Auth: GH_TOKEN / GITHUB_TOKEN, else `gh auth token`.
|
||||||
|
*/
|
||||||
|
import { $ } from "bun";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
|
|
||||||
|
/** Page size used for every list endpoint; also the "more pages" threshold. */
|
||||||
|
const PER_PAGE = 100;
|
||||||
|
|
||||||
|
export interface GitHub {
|
||||||
|
/** `owner/name`. */
|
||||||
|
repo: string;
|
||||||
|
/** One authenticated request against api.github.com; throws on non-2xx. */
|
||||||
|
api(path: string, init?: RequestInit): Promise<unknown>;
|
||||||
|
/** Every page of a list endpoint, flattened into one stream of elements. */
|
||||||
|
list(path: string): AsyncGenerator<unknown, void, void>;
|
||||||
|
/** Comment on an issue, then close it as completed. */
|
||||||
|
closeIssue(issue: number, comment: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRepo(): Promise<string> {
|
||||||
|
if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY;
|
||||||
|
const url = (await $`git -C ${ROOT} remote get-url origin`.text()).trim();
|
||||||
|
const m = url.match(/github\.com[:/](.+?)(?:\.git)?$/);
|
||||||
|
if (!m) throw new Error(`cannot derive owner/repo from remote: ${url}`);
|
||||||
|
return m[1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveToken(): Promise<string> {
|
||||||
|
const env = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
||||||
|
if (env) return env;
|
||||||
|
const token = (await $`gh auth token`.text()).trim();
|
||||||
|
if (!token) throw new Error("no credentials: set GH_TOKEN or run `gh auth login`");
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function github(): Promise<GitHub> {
|
||||||
|
const repo = await resolveRepo();
|
||||||
|
const token = await resolveToken();
|
||||||
|
|
||||||
|
async function api(path: string, init: RequestInit = {}): Promise<unknown> {
|
||||||
|
const res = await fetch(`https://api.github.com${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
accept: "application/vnd.github+json",
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
"x-github-api-version": "2022-11-28",
|
||||||
|
...(init.body ? { "content-type": "application/json" } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* list(path: string): AsyncGenerator<unknown, void, void> {
|
||||||
|
const sep = path.includes("?") ? "&" : "?";
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const batch = await api(`${path}${sep}per_page=${PER_PAGE}&page=${page}`);
|
||||||
|
if (!Array.isArray(batch)) throw new Error(`unexpected ${path} payload: not an array`);
|
||||||
|
yield* batch;
|
||||||
|
if (batch.length < PER_PAGE) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comment before closing: if the PATCH fails, the issue still carries a
|
||||||
|
* visible note of what the automation decided, instead of failing silently.
|
||||||
|
*/
|
||||||
|
async function closeIssue(issue: number, comment: string): Promise<void> {
|
||||||
|
await api(`/repos/${repo}/issues/${issue}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ body: comment }),
|
||||||
|
});
|
||||||
|
await api(`/repos/${repo}/issues/${issue}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { repo, api, list, closeIssue };
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "cli",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@clack/prompts": "^1.7.0",
|
||||||
|
"@night-slayer18/leetcode-cli": "^3.5.0",
|
||||||
|
"@types/bun": "^1.3.14"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+99
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Fuzzy-pick a LeetCode problem and scaffold it via leetcode-cli.
|
||||||
|
*
|
||||||
|
* 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 { basename, join } from "node:path";
|
||||||
|
import { fetchDescription, problemDb, readmeSlugs, type Problem } from "./db";
|
||||||
|
|
||||||
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
|
const WORK_DIR = join(ROOT, "work");
|
||||||
|
|
||||||
|
const db = problemDb();
|
||||||
|
|
||||||
|
// ── problem index (24h TTL) ──────────────────────────────────────
|
||||||
|
|
||||||
|
if (db.indexStale()) {
|
||||||
|
const s = spinner();
|
||||||
|
s.start("Fetching problem index from leetcode.com");
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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",
|
||||||
|
placeholder: "Type to search by number or title...",
|
||||||
|
maxItems: 12,
|
||||||
|
options: problems.map((p) => ({
|
||||||
|
value: p,
|
||||||
|
label: `${p.id}. ${p.title}`,
|
||||||
|
hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty,
|
||||||
|
})),
|
||||||
|
filter: (search, option) => {
|
||||||
|
const haystack = option.label!.toLowerCase();
|
||||||
|
return search
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.every((token) => haystack.includes(token));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isCancel(picked)) {
|
||||||
|
cancel("Nothing picked.");
|
||||||
|
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"), "pick", picked.slug],
|
||||||
|
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
|
||||||
|
);
|
||||||
|
process.exit(await proc.exited);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Console + GitHub Actions reporting shared by the issue reconcilers.
|
||||||
|
*
|
||||||
|
* Both reconcilers end the same way: an aligned table on stdout, and the same
|
||||||
|
* table as Markdown in the step summary when running under Actions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Print rows[0] as a header, a rule, then the body — every column padded. */
|
||||||
|
export function printTable(rows: string[][]): void {
|
||||||
|
const widths = rows[0]!.map((_, i) => Math.max(...rows.map((r) => r[i]!.length)));
|
||||||
|
const render = (r: string[]) =>
|
||||||
|
r
|
||||||
|
.map((cell, i) => cell.padEnd(widths[i]!))
|
||||||
|
.join(" ")
|
||||||
|
.trimEnd();
|
||||||
|
|
||||||
|
console.log(render(rows[0]!));
|
||||||
|
console.log(widths.map((n) => "─".repeat(n)).join(" "));
|
||||||
|
for (const row of rows.slice(1)) console.log(render(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same rows as a GitHub-flavoured Markdown table; rows[0] is the header. */
|
||||||
|
export function markdownTable(rows: string[][]): string {
|
||||||
|
const [header, ...body] = rows;
|
||||||
|
return [
|
||||||
|
`| ${header!.join(" | ")} |`,
|
||||||
|
`| ${header!.map(() => "---").join(" | ")} |`,
|
||||||
|
...body.map((r) => `| ${r.join(" | ")} |`),
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append to the Actions step summary; a no-op outside Actions. */
|
||||||
|
export async function writeStepSummary(markdown: string): Promise<void> {
|
||||||
|
const path = process.env.GITHUB_STEP_SUMMARY;
|
||||||
|
if (!path) return;
|
||||||
|
await Bun.write(path, markdown);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Shared work/ solution picker for the test and submit entries: fuzzy-select
|
||||||
|
* a solution file, then hand it to leetcode-cli.
|
||||||
|
*
|
||||||
|
* Every run backfills the db's solutions table with created_at (from the
|
||||||
|
* file's birthtime, mtime when the filesystem has none) so ordering works for
|
||||||
|
* files that predate the db. Ordering is per command — test lists the most
|
||||||
|
* recently tested first (never-tested after, by name); submit lists the most
|
||||||
|
* recently created first. Nothing is ever hidden. Exit 0 of leetcode-cli
|
||||||
|
* stamps last_tested / last_submitted.
|
||||||
|
*
|
||||||
|
* Library: side-effect-free on import — nothing scans the filesystem until
|
||||||
|
* runOnSolution() is called.
|
||||||
|
*/
|
||||||
|
import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
|
||||||
|
import { statSync } from "node:fs";
|
||||||
|
import { basename, join, relative } from "node:path";
|
||||||
|
import { problemDb } from "./db";
|
||||||
|
|
||||||
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
|
const WORK_DIR = join(ROOT, "work");
|
||||||
|
|
||||||
|
/** Leading LC number of a work/ file, or undefined for unnumbered names. */
|
||||||
|
function lcNumber(rel: string): number | undefined {
|
||||||
|
const m = basename(rel).match(/^(\d+)\./);
|
||||||
|
return m ? Number(m[1]) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
|
||||||
|
export async function runOnSolution(command: "test" | "submit", message: string): Promise<never> {
|
||||||
|
const db = problemDb();
|
||||||
|
|
||||||
|
const scanned = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })];
|
||||||
|
|
||||||
|
// register creation times so files older than the db still sort correctly
|
||||||
|
for (const f of scanned) {
|
||||||
|
const num = lcNumber(f);
|
||||||
|
if (num === undefined) continue;
|
||||||
|
const stat = statSync(join(WORK_DIR, f));
|
||||||
|
db.ensureSolution(num, new Date(stat.birthtimeMs || stat.mtimeMs).toISOString());
|
||||||
|
}
|
||||||
|
const activity = db.solutionActivity();
|
||||||
|
|
||||||
|
// test → most recently tested first; submit → most recently created first
|
||||||
|
const sortKey = (f: string) => {
|
||||||
|
const num = lcNumber(f);
|
||||||
|
const row = num === undefined ? undefined : activity.get(num);
|
||||||
|
return (command === "test" ? row?.lastTested : row?.createdAt) ?? undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const files = scanned
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ka = sortKey(a);
|
||||||
|
const kb = sortKey(b);
|
||||||
|
if (ka !== kb) {
|
||||||
|
if (ka === undefined) return 1;
|
||||||
|
if (kb === undefined) return -1;
|
||||||
|
return kb.localeCompare(ka); // ISO timestamps: lexicographic = chronological
|
||||||
|
}
|
||||||
|
return a.localeCompare(b, undefined, { numeric: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const picked = await autocomplete<string>({
|
||||||
|
message,
|
||||||
|
placeholder: "Type to search...",
|
||||||
|
maxItems: 12,
|
||||||
|
options: files.map((f) => {
|
||||||
|
// work layout: Difficulty/Category/<id>.<slug>.<ext>
|
||||||
|
const [difficulty, category, name] = f.split("/");
|
||||||
|
return {
|
||||||
|
value: f,
|
||||||
|
label: name ?? f,
|
||||||
|
hint: category ? `${difficulty} · ${category}` : difficulty,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
filter: (search, option) => {
|
||||||
|
const haystack = option.value.toLowerCase();
|
||||||
|
return search
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.every((token) => haystack.includes(token));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isCancel(picked)) {
|
||||||
|
cancel("Nothing selected.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
// bun installs workspace-dep bins into this package's node_modules, not the root's
|
||||||
|
join(import.meta.dir, "node_modules", ".bin", "leetcode"),
|
||||||
|
command,
|
||||||
|
join(WORK_DIR, picked),
|
||||||
|
],
|
||||||
|
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
|
||||||
|
);
|
||||||
|
const code = await proc.exited;
|
||||||
|
const num = lcNumber(picked);
|
||||||
|
if (code === 0 && num !== undefined) {
|
||||||
|
if (command === "test") db.markTested(num);
|
||||||
|
else db.markSubmitted(num);
|
||||||
|
}
|
||||||
|
db.close();
|
||||||
|
process.exit(code);
|
||||||
|
}
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Fuzzy-select a solution file from work/ and submit it to LeetCode's judge.
|
||||||
|
*/
|
||||||
|
import { runOnSolution } from "./solutions";
|
||||||
|
|
||||||
|
await runOnSolution("submit", "Submit which solution?");
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Sync work/ leetcode solutions into docs/solutions/ pages.
|
* Sync work/ leetcode solutions into apps/docs/content/ pages.
|
||||||
*
|
*
|
||||||
* - New problems get a full page (frontmatter, badge, warning, examples,
|
* - New problems get a full page (frontmatter, badge, warning, examples,
|
||||||
* constraints, solution) in the gold-standard format.
|
* constraints, solution) in the gold-standard format.
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
import { mkdir, rename } from "node:fs/promises";
|
import { mkdir, rename } from "node:fs/promises";
|
||||||
import { basename, join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..");
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
const WORK = join(ROOT, "work");
|
const WORK = join(ROOT, "work");
|
||||||
const DOCS = join(ROOT, "docs", "solutions");
|
const DOCS = join(ROOT, "apps", "docs", "content");
|
||||||
|
|
||||||
type Lang = "js" | "py";
|
type Lang = "js" | "py";
|
||||||
|
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Fuzzy-select a solution file from work/ and run leetcode-cli tests on it.
|
||||||
|
*/
|
||||||
|
import { runOnSolution } from "./solutions";
|
||||||
|
|
||||||
|
await runOnSolution("test", "Test which solution?");
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.blume-verify/
|
||||||
@@ -7,11 +7,14 @@ export default defineConfig({
|
|||||||
site: "https://prdlk.github.io",
|
site: "https://prdlk.github.io",
|
||||||
base: "/leetcode",
|
base: "/leetcode",
|
||||||
},
|
},
|
||||||
|
content: {
|
||||||
|
root: "content",
|
||||||
|
},
|
||||||
navigation: {
|
navigation: {
|
||||||
tabs: [
|
tabs: [
|
||||||
{
|
{
|
||||||
label: "Solutions",
|
label: "Reference",
|
||||||
path: "/solutions",
|
path: "/reference",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
title: '1. Two Sum'
|
||||||
|
description: You are given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target
|
||||||
|
sidebar:
|
||||||
|
label: 'Two Sum'
|
||||||
|
badge: 'Easy'
|
||||||
|
---
|
||||||
|
|
||||||
|
<Badge variant="accent">Hash Table</Badge>
|
||||||
|
|
||||||
|
::::warning
|
||||||
|
Can you come up with an algorithm that is less than O(n^2) time complexity?
|
||||||
|
::::
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `nums = [2,7,11,15], target = 9`
|
||||||
|
- Output: `[0,1]`
|
||||||
|
- Explanation: Because `nums[0] + nums[1]` == `9`, we return [0, 1].
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `nums = [3,2,4], target = 6`
|
||||||
|
- Output: `[1,2]`
|
||||||
|
|
||||||
|
### Example 3:
|
||||||
|
- Input: `nums = [3,3], target = 6`
|
||||||
|
- Output: `[0,1]`
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `2 <= nums.length <= 10^4`
|
||||||
|
- `-10^9 <= nums[i] <= 10^9`
|
||||||
|
- `-10^9 <= target <= 10^9`
|
||||||
|
- Only one valid answer exists.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def twoSum(self, nums: List[int], target: int) -> List[int]:
|
||||||
|
seen = {}
|
||||||
|
for i, num in enumerate(nums):
|
||||||
|
complement = target - num
|
||||||
|
if complement in seen:
|
||||||
|
return [seen[complement], i]
|
||||||
|
seen[num] = i
|
||||||
|
return []
|
||||||
|
```
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
title: '11. Container With Most Water'
|
||||||
|
description: You are given an integer array height of length n. There are n vertical lines drawn such that the two endpoints of the i^th line are (i, 0) and (i, height[i])
|
||||||
|
sidebar:
|
||||||
|
label: 'Container With Most Water'
|
||||||
|
badge: 'Medium'
|
||||||
|
---
|
||||||
|
|
||||||
|
<Badge variant="accent">Two Pointers</Badge>
|
||||||
|
|
||||||
|
::::warning
|
||||||
|
Notice that you may not slant the container.
|
||||||
|
::::
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `height = [1,8,6,2,5,4,8,3,7]`
|
||||||
|
- Output: `49`
|
||||||
|
- Explanation: The above vertical lines are represented by array `[1,8,6,2,5,4,8,3,7]`. In this case, the max area of water (blue section) the container can contain is `49`.
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `height = [1,1]`
|
||||||
|
- Output: `1`
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `n == height.length`
|
||||||
|
- `2 <= n <= 10^5`
|
||||||
|
- `0 <= height[i] <= 10^4`
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def maxArea(self, height: List[int]) -> int: # noqa: F821
|
||||||
|
res = 0
|
||||||
|
left = 0
|
||||||
|
right = len(height) - 1
|
||||||
|
|
||||||
|
while left < right:
|
||||||
|
area = (right - left) * min(height[left], height[right])
|
||||||
|
res = max(res, area)
|
||||||
|
if height[left] < height[right]:
|
||||||
|
left += 1
|
||||||
|
else:
|
||||||
|
right -= 1
|
||||||
|
|
||||||
|
return res
|
||||||
|
```
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
title: '15. 3Sum'
|
||||||
|
description: Given an integer array nums, return all the triplets [nums[i], nums[j], nums[k]] such that i != j, i != k, and j != k, and nums[i] + nums[j] + nums[k] == 0
|
||||||
|
sidebar:
|
||||||
|
label: '3Sum'
|
||||||
|
badge: 'Medium'
|
||||||
|
---
|
||||||
|
|
||||||
|
<Badge variant="accent">Two Pointers</Badge>
|
||||||
|
|
||||||
|
::::warning
|
||||||
|
Notice that the solution set must not contain duplicate triplets.
|
||||||
|
::::
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `nums = [-1,0,1,2,-1,-4]`
|
||||||
|
- Output: `[[-1,-1,2],[-1,0,1]]`
|
||||||
|
- Explanation: `nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0`. `nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0`. `nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0`. The distinct triplets are `[-1,0,1]` and `[-1,-1,2]`. Notice that the order of the output and the order of the triplets does not matter.
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `nums = [0,1,1]`
|
||||||
|
- Output: `[]`
|
||||||
|
- Explanation: The only possible triplet does not sum up to `0`.
|
||||||
|
|
||||||
|
### Example 3:
|
||||||
|
- Input: `nums = [0,0,0]`
|
||||||
|
- Output: `[[0,0,0]]`
|
||||||
|
- Explanation: The only possible triplet sums up to `0`.
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `3 <= nums.length <= 3000`
|
||||||
|
- `-10^5 <= nums[i] <= 10^5`
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def threeSum(self, nums: list[int]) -> list[list[int]]:
|
||||||
|
nums.sort()
|
||||||
|
result = []
|
||||||
|
n = len(nums)
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
# skip all zero
|
||||||
|
if i > 0 and nums[i] == nums[i - 1]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# two pointers
|
||||||
|
left = i + 1
|
||||||
|
right = n - 1
|
||||||
|
target = -nums[i]
|
||||||
|
|
||||||
|
while left < right:
|
||||||
|
current = nums[left] + nums[right]
|
||||||
|
|
||||||
|
if current == target:
|
||||||
|
result.append([nums[i], nums[left], nums[right]])
|
||||||
|
|
||||||
|
# skip duplicates
|
||||||
|
while left < right and nums[left] == nums[left + 1]:
|
||||||
|
left += 1
|
||||||
|
while left < right and nums[right] == nums[right - 1]:
|
||||||
|
right -= 1
|
||||||
|
|
||||||
|
# shift pointers
|
||||||
|
left += 1
|
||||||
|
right -= 1
|
||||||
|
|
||||||
|
# since sorted, if current < target, then move left
|
||||||
|
elif current < target:
|
||||||
|
left += 1
|
||||||
|
# since sorted, if current > target, then move right
|
||||||
|
else:
|
||||||
|
right -= 1
|
||||||
|
|
||||||
|
return result
|
||||||
|
```
|
||||||
@@ -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]
|
||||||
|
```
|
||||||
File renamed without changes.
+14
-22
@@ -37,27 +37,19 @@ Your solution must use only constant extra space.
|
|||||||
|
|
||||||
## Solution
|
## Solution
|
||||||
|
|
||||||
```js
|
```py
|
||||||
/**
|
class Solution:
|
||||||
* @param {number[]} numbers
|
def twoSum(self, numbers: List[int], target: int) -> List[int]:
|
||||||
* @param {number} target
|
i = 0
|
||||||
* @return {number[]}
|
j = len(numbers) - 1
|
||||||
*/
|
|
||||||
var twoSum = function(numbers, target) {
|
|
||||||
let i = 0, j = numbers.length - 1;
|
|
||||||
|
|
||||||
while (i < j) {
|
while i < j:
|
||||||
const curr = numbers[i] + numbers[j];
|
c = numbers[i] + numbers[j]
|
||||||
if (curr === target){
|
if c == target:
|
||||||
return [i + 1, j + 1];
|
return [i + 1, j + 1]
|
||||||
}else{
|
elif c < target:
|
||||||
if (curr < target){
|
i+=1
|
||||||
i++;
|
else:
|
||||||
}else{
|
j-=1
|
||||||
j--;
|
return []
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,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
|
||||||
|
```
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
|
||||||
|
```
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
title: '3. Longest Substring Without Repeating Characters'
|
||||||
|
description: Given a string s, find the length of the longest substring without duplicate characters
|
||||||
|
sidebar:
|
||||||
|
label: 'Longest Substring Without Repeating Characters'
|
||||||
|
badge: 'Medium'
|
||||||
|
---
|
||||||
|
|
||||||
|
<Badge variant="accent">Sliding Window</Badge>
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `s = "abcabcbb"`
|
||||||
|
- Output: `3`
|
||||||
|
- Explanation: The answer is "abc", with the length of `3`. Note that "bca" and "cab" are also correct answers.
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `s = "bbbbb"`
|
||||||
|
- Output: `1`
|
||||||
|
- Explanation: The answer is "b", with the length of `1`.
|
||||||
|
|
||||||
|
### Example 3:
|
||||||
|
- Input: `s = "pwwkew"`
|
||||||
|
- Output: `3`
|
||||||
|
- Explanation: The answer is "wke", with the length of `3`. Notice that the answer must be a substring, "pwke" is a subsequence and not a substring.
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `0 <= s.length <= 10^5`
|
||||||
|
- `s` consists of English letters, digits, symbols and spaces.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def lengthOfLongestSubstring(self, s: str) -> int:
|
||||||
|
left = 0
|
||||||
|
ans = 0
|
||||||
|
window = set()
|
||||||
|
for right, c in enumerate(s):
|
||||||
|
while c in window:
|
||||||
|
window.remove(s[left])
|
||||||
|
left += 1
|
||||||
|
window.add(c)
|
||||||
|
ans = max(ans, right - left + 1)
|
||||||
|
|
||||||
|
return ans
|
||||||
|
```
|
||||||
File renamed without changes.
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
title: '424. Longest Repeating Character Replacement'
|
||||||
|
description: You are given a string s and an integer k. You can choose any character of the string and change it to any other uppercase English character. You can perform this operation at most k times
|
||||||
|
sidebar:
|
||||||
|
label: 'Longest Repeating Character Replacement'
|
||||||
|
badge: 'Medium'
|
||||||
|
---
|
||||||
|
|
||||||
|
<Badge variant="accent">Sliding Window</Badge>
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `s = "ABAB", k = 2`
|
||||||
|
- Output: `4`
|
||||||
|
- Explanation: Replace the two 'A's with two 'B's or vice versa.
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `s = "AABABBA", k = 1`
|
||||||
|
- Output: `4`
|
||||||
|
- Explanation: Replace the one 'A' in the middle with 'B' and form "AABBBBA". The substring "BBBB" has the longest repeating letters, which is `4`. There may exists other ways to achieve this answer too.
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `1 <= s.length <= 10^5`
|
||||||
|
- `s` consists of only uppercase English letters.
|
||||||
|
- `0 <= k <= s.length`
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def characterReplacement(self, s: str, k: int) -> int:
|
||||||
|
count = {}
|
||||||
|
res = 0
|
||||||
|
|
||||||
|
l = 0
|
||||||
|
maxF = 0
|
||||||
|
for r, c in enumerate(s):
|
||||||
|
count[c] = 1 + count.get(c, 0)
|
||||||
|
maxF = max(maxF, count[c])
|
||||||
|
|
||||||
|
while (r - l + 1) - maxF > k:
|
||||||
|
count[s[l]] -= 1
|
||||||
|
l += 1
|
||||||
|
|
||||||
|
res = max(res, r - l + 1)
|
||||||
|
return res
|
||||||
|
```
|
||||||
File renamed without changes.
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
title: '567. Permutation in String'
|
||||||
|
description: Given two strings s1 and s2, return true if s2 contains a permutation of s1, or false otherwise
|
||||||
|
sidebar:
|
||||||
|
label: 'Permutation in String'
|
||||||
|
badge: 'Medium'
|
||||||
|
---
|
||||||
|
|
||||||
|
<Badge variant="accent">Sliding Window</Badge>
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `s1 = "ab", s2 = "eidbaooo"`
|
||||||
|
- Output: `true`
|
||||||
|
- Explanation: `s2` contains one permutation of `s1` ("ba").
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `s1 = "ab", s2 = "eidboaoo"`
|
||||||
|
- Output: `false`
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `1 <= s1.length, s2.length <= 10^4`
|
||||||
|
- `s1` and `s2` consist of lowercase English letters.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
|
||||||
|
class Solution:
|
||||||
|
def checkInclusion(self, s1: str, s2: str) -> bool:
|
||||||
|
k = len(s1)
|
||||||
|
if k > len(s2):
|
||||||
|
return False
|
||||||
|
|
||||||
|
need = Counter(s1)
|
||||||
|
window = Counter()
|
||||||
|
|
||||||
|
for right, c in enumerate(s2):
|
||||||
|
window[c] += 1
|
||||||
|
if right >= k:
|
||||||
|
left_char = s2[right - k]
|
||||||
|
window[left_char] -= 1
|
||||||
|
if window[left_char] == 0:
|
||||||
|
del window[left_char]
|
||||||
|
if window == need:
|
||||||
|
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
|
||||||
|
```
|
||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
title: '392. Is Subsequence'
|
||||||
|
description: Given two strings s and t, return true if s is a subsequence of t, or false otherwise
|
||||||
|
sidebar:
|
||||||
|
label: 'Is Subsequence'
|
||||||
|
badge: 'Easy'
|
||||||
|
---
|
||||||
|
|
||||||
|
<Badge variant="accent">Two Pointers</Badge>
|
||||||
|
|
||||||
|
::::warning
|
||||||
|
Suppose there are lots of incoming s, say s1, s2, ..., sk where k >= 10^9, and you want to check one by one to see if t has its subsequence. In this scenario, how would you change your code?
|
||||||
|
::::
|
||||||
|
|
||||||
|
### Example 1:
|
||||||
|
- Input: `s = "abc", t = "ahbgdc"`
|
||||||
|
- Output: `true`
|
||||||
|
|
||||||
|
### Example 2:
|
||||||
|
- Input: `s = "axc", t = "ahbgdc"`
|
||||||
|
- Output: `false`
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
|
||||||
|
- `0 <= s.length <= 100`
|
||||||
|
- `0 <= t.length <= 10^4`
|
||||||
|
- `s` and `t` consist only of lowercase English letters.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Solution:
|
||||||
|
def isSubsequence(self, s: str, t: str) -> bool:
|
||||||
|
if len(s) > len(t):
|
||||||
|
return False
|
||||||
|
|
||||||
|
i, j = 0, 0
|
||||||
|
while i < len(s) and j < len(t):
|
||||||
|
if s[i] == t[j]:
|
||||||
|
i += 1
|
||||||
|
j += 1
|
||||||
|
|
||||||
|
return i == len(s)
|
||||||
|
```
|
||||||
File renamed without changes.
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
title: Progress
|
||||||
|
description: Live SRS campaign dashboard — phases, ladder, review queue, gates, and recent attempts, fetched from the SRS Worker.
|
||||||
|
---
|
||||||
|
|
||||||
|
Live view of the 56-day campaign. Everything below is fetched client-side
|
||||||
|
from the SRS Worker, so it is always current — no rebuild required.
|
||||||
|
|
||||||
|
<ProgressDashboard />
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
title: Sliding Window
|
||||||
|
description: 'Sliding window is a specialized two-pointer family where the pointers define a contiguous interval and you maintain information about that entire interval as it moves.'
|
||||||
|
---
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
This is the family where invariants matter the most
|
||||||
|
:::
|
||||||
|
|
||||||
|
## What Makes Sliding Window Different?
|
||||||
|
|
||||||
|
Many problems have two moving boundaries. That alone does not make them sliding window problems.
|
||||||
|
|
||||||
|
A problem belongs to this family when:
|
||||||
|
|
||||||
|
- the answer depends on the subarray or substring between left and right
|
||||||
|
- you can update the window state incrementally as elements enter or leave
|
||||||
|
- you maintain a condition that tells you whether the window is valid
|
||||||
|
|
||||||
|
## What is an Invariant?
|
||||||
|
|
||||||
|
|
||||||
|
An invariant is a statement that stays true before and after every iteration.
|
||||||
|
|
||||||
|
For sliding window, the invariant often looks like one of these:
|
||||||
|
|
||||||
|
- "the current window is valid"
|
||||||
|
- "the current window contains no duplicates"
|
||||||
|
- "the current window sum is at most the target"
|
||||||
|
- "the frequency map matches the characters currently inside the window"
|
||||||
|
|
||||||
|
The invariant gives the loop its structure:
|
||||||
|
|
||||||
|
- expand the window by moving right
|
||||||
|
- update the window state
|
||||||
|
- if the invariant breaks, shrink from the left until it holds again
|
||||||
|
- update the answer only when you know the invariant is satisfied
|
||||||
|
|
||||||
|
|
||||||
|
## Why Invariants Matter
|
||||||
|
|
||||||
|
Without an invariant, shrinking the window becomes arbitrary. You no longer know:
|
||||||
|
|
||||||
|
- when the window is valid
|
||||||
|
- when to update the answer
|
||||||
|
- why the left pointer should stop moving
|
||||||
|
- The invariant is the reason the algorithm is correct.
|
||||||
|
|
||||||
|
## The Main Subtypes
|
||||||
|
|
||||||
|
### 1. Fixed-size windows
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
The window size never changes. The main task is updating the window state efficiently when one element enters and one leaves.
|
||||||
|
:::
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 2. Flexible-size windows with a validity rule
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
Here the window expands until invalid, then shrinks until valid again.
|
||||||
|
:::
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
### 3. Frequency-tracking windows
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
The difficulty comes from keeping the bookkeeping correct as characters enter and leave.
|
||||||
|
:::
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## How Sliding Window Differs From Other Two-Pointer Families
|
||||||
|
|
||||||
|
Compare it with Container With Most Water:
|
||||||
|
|
||||||
|
- In sliding window, the interval itself has meaning.
|
||||||
|
- In container, the interval is just the remaining search space.
|
||||||
|
|
||||||
|
Compare it with Remove Duplicates:
|
||||||
|
|
||||||
|
- In sliding window, both pointers describe a live window.
|
||||||
|
- In compaction, the left part of the array is already the answer prefix.
|
||||||
|
|
||||||
|
## A Practical Template
|
||||||
|
|
||||||
|
A common variable-size sliding window has this shape:
|
||||||
|
|
||||||
|
1. Add the new rightmost element.
|
||||||
|
2. Update the window state.
|
||||||
|
3. While the invariant is broken, remove elements from the left.
|
||||||
|
4. Once the invariant is restored, update the answer.
|
||||||
|
|
||||||
|
The exact data structure changes from problem to problem, but the logic stays the same.
|
||||||
|
|
||||||
|
As you work through this section, keep asking:
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
What invariant does my window maintain, and at what moment is it safe to update the answer?
|
||||||
|
:::
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
/**
|
||||||
|
* Progress dashboard island — the expanded, always-current version of the
|
||||||
|
* daily email footer. Rendered on /progress; fetches campaign state from the
|
||||||
|
* SRS Worker at page load, entirely client-side, so `blume build` never
|
||||||
|
* depends on the API being up. API_BASE is a placeholder stamped in after the
|
||||||
|
* Worker deploys — do not inline it anywhere else.
|
||||||
|
*
|
||||||
|
* Invariants: the component never throws on malformed API data (a shape guard
|
||||||
|
* downgrades to the error state), and the first frame is always the loading
|
||||||
|
* state so server and client render identically.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState, type CSSProperties } from "react";
|
||||||
|
|
||||||
|
export const client = "load";
|
||||||
|
|
||||||
|
const API_BASE = "https://srs-api.prdlk.workers.dev";
|
||||||
|
|
||||||
|
// ── /api/stats response shape ────────────────────────────────────
|
||||||
|
|
||||||
|
const STAGES = ["new", "+2", "+5", "+10", "retired"] as const;
|
||||||
|
|
||||||
|
type Stage = (typeof STAGES)[number];
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
generated: string;
|
||||||
|
campaign: { day: number; week: number; start: string; days: number };
|
||||||
|
phases: {
|
||||||
|
milestone: number;
|
||||||
|
name: string;
|
||||||
|
core_done: number;
|
||||||
|
core_total: number;
|
||||||
|
optional_done: number;
|
||||||
|
optional_total: number;
|
||||||
|
deferred_done: number;
|
||||||
|
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;
|
||||||
|
pass_rate: number | null;
|
||||||
|
closed_on: string | null;
|
||||||
|
}[];
|
||||||
|
streak: number;
|
||||||
|
queue: { date: string; due: number }[];
|
||||||
|
recent: { date: string; attempts: number; passes: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStats(value: unknown): value is Stats {
|
||||||
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
const v = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof v.campaign === "object" &&
|
||||||
|
v.campaign !== null &&
|
||||||
|
typeof v.ladder === "object" &&
|
||||||
|
v.ladder !== null &&
|
||||||
|
Array.isArray(v.phases) &&
|
||||||
|
Array.isArray(v.gates) &&
|
||||||
|
Array.isArray(v.queue) &&
|
||||||
|
Array.isArray(v.recent) &&
|
||||||
|
typeof v.streak === "number"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── formatting ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Legacy gates stored integer percent; the Worker may emit a 0–1 fraction. */
|
||||||
|
function percent(rate: number): string {
|
||||||
|
return `${Math.round(rate <= 1 ? rate * 100 : rate)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2026-08-24" → "Mon 24" without timezone drift. */
|
||||||
|
function dayLabel(iso: string): string {
|
||||||
|
const d = new Date(`${iso}T00:00:00`);
|
||||||
|
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
|
||||||
|
return `${weekday} ${iso.slice(8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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)",
|
||||||
|
borderRadius: "var(--blume-radius)",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
};
|
||||||
|
|
||||||
|
const muted: CSSProperties = {
|
||||||
|
color: "var(--blume-muted-foreground)",
|
||||||
|
fontSize: "0.85em",
|
||||||
|
};
|
||||||
|
|
||||||
|
const heading: CSSProperties = {
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
};
|
||||||
|
|
||||||
|
function Bar({ done, total }: { done: number; total: number }) {
|
||||||
|
const ratio = total > 0 ? Math.min(done / total, 1) : 0;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "10rem",
|
||||||
|
maxWidth: "40vw",
|
||||||
|
height: "0.5rem",
|
||||||
|
borderRadius: "var(--blume-radius)",
|
||||||
|
background: "var(--blume-muted)",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: `${ratio * 100}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: "var(--blume-accent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sections ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Header({ stats }: { stats: Stats }) {
|
||||||
|
const { campaign, streak } = stats;
|
||||||
|
return (
|
||||||
|
<div style={{ ...card, display: "flex", gap: "1.5rem", flexWrap: "wrap" }}>
|
||||||
|
<span>
|
||||||
|
<strong>
|
||||||
|
Day {campaign.day}/{campaign.days}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
<span>Week {campaign.week}</span>
|
||||||
|
<span>
|
||||||
|
Streak: <strong>{streak}</strong> day{streak === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
<span style={muted}>started {campaign.start}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ladder({ stats }: { stats: Stats }) {
|
||||||
|
return (
|
||||||
|
<div style={card}>
|
||||||
|
<div style={heading}>SRS ladder</div>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
|
{STAGES.map((stage) => (
|
||||||
|
<span
|
||||||
|
key={stage}
|
||||||
|
style={{
|
||||||
|
background: "var(--blume-muted)",
|
||||||
|
borderRadius: "var(--blume-radius)",
|
||||||
|
padding: "0.35rem 0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={muted}>{stage}</span>{" "}
|
||||||
|
<strong>{stats.ladder[stage] ?? 0}</strong>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Queue({ stats }: { stats: Stats }) {
|
||||||
|
const max = Math.max(1, ...stats.queue.map((q) => q.due));
|
||||||
|
return (
|
||||||
|
<div style={card}>
|
||||||
|
<div style={heading}>Review queue — next 14 days</div>
|
||||||
|
<table style={{ borderCollapse: "collapse" }}>
|
||||||
|
<tbody>
|
||||||
|
{stats.queue.map((q) => (
|
||||||
|
<tr key={q.date}>
|
||||||
|
<td style={{ ...muted, padding: "0.1rem 1rem 0.1rem 0" }}>
|
||||||
|
{dayLabel(q.date)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "0.1rem 0.75rem 0.1rem 0" }}>
|
||||||
|
<Bar done={q.due} total={max} />
|
||||||
|
</td>
|
||||||
|
<td>{q.due}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Gates({ stats }: { stats: Stats }) {
|
||||||
|
return (
|
||||||
|
<div style={card}>
|
||||||
|
<div style={heading}>Gate log</div>
|
||||||
|
{stats.gates.length === 0 ? (
|
||||||
|
<span style={muted}>No gates yet — the first weekly review opens Saturday night.</span>
|
||||||
|
) : (
|
||||||
|
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.4rem", flexWrap: "wrap" }}>
|
||||||
|
{stats.recent.map((r) => (
|
||||||
|
<span
|
||||||
|
key={r.date}
|
||||||
|
title={`${r.date}: ${r.passes}/${r.attempts} passed`}
|
||||||
|
style={{ textAlign: "center" }}
|
||||||
|
>
|
||||||
|
<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.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── dashboard ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Load =
|
||||||
|
| { phase: "loading" }
|
||||||
|
| { phase: "error" }
|
||||||
|
| { phase: "ready"; stats: Stats };
|
||||||
|
|
||||||
|
export default function ProgressDashboard() {
|
||||||
|
const [load, setLoad] = useState<Load>({ phase: "loading" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetch(`${API_BASE}/api/stats`, { signal: controller.signal })
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((body: unknown) => {
|
||||||
|
setLoad(isStats(body) ? { phase: "ready", stats: body } : { phase: "error" });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!controller.signal.aborted) setLoad({ phase: "error" });
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (load.phase === "loading") {
|
||||||
|
return <p style={muted}>Loading progress…</p>;
|
||||||
|
}
|
||||||
|
if (load.phase === "error") {
|
||||||
|
return (
|
||||||
|
<p style={muted}>
|
||||||
|
API unreachable — stats are served live from the SRS Worker and it did
|
||||||
|
not respond.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stats } = load;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header stats={stats} />
|
||||||
|
<Phases stats={stats} />
|
||||||
|
<Ladder stats={stats} />
|
||||||
|
<Queue stats={stats} />
|
||||||
|
<Gates stats={stats} />
|
||||||
|
<Recent stats={stats} />
|
||||||
|
<p style={muted}>Generated {stats.generated}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "docs",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "blume dev",
|
||||||
|
"build": "blume build",
|
||||||
|
"doctor": "blume doctor"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"blume": "^1.5.1",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,66 @@
|
|||||||
|
<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Background (REQUIRED) -->
|
||||||
|
<rect width="800" height="450" fill="#f8f9fa" rx="10"/>
|
||||||
|
|
||||||
|
<!-- Title and Subtitle -->
|
||||||
|
<text x="400" y="45" font-family="Arial, sans-serif" font-size="26" font-weight="bold" fill="#2c3e50" text-anchor="middle">Sliding Window: Fixed Size</text>
|
||||||
|
<text x="400" y="75" font-family="Arial, sans-serif" font-size="16" fill="#7f8c8d" text-anchor="middle">Window of size K=3 moving through the array.</text>
|
||||||
|
|
||||||
|
<!-- Step 1: Initial window -->
|
||||||
|
<g transform="translate(150, 130)">
|
||||||
|
<text x="0" y="-20" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">1. Current window (Sum = 10)</text>
|
||||||
|
|
||||||
|
<!-- Array -->
|
||||||
|
<g transform="translate(0, 20)">
|
||||||
|
<rect x="0" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||||
|
<text x="30" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">2</text>
|
||||||
|
|
||||||
|
<rect x="70" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||||
|
<text x="100" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">3</text>
|
||||||
|
|
||||||
|
<rect x="140" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||||
|
<text x="170" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">5</text>
|
||||||
|
|
||||||
|
<rect x="210" y="0" width="60" height="50" fill="#ffffff" stroke="#dee2e6" stroke-width="2" rx="3"/>
|
||||||
|
<text x="240" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">1</text>
|
||||||
|
|
||||||
|
<rect x="280" y="0" width="60" height="50" fill="#ffffff" stroke="#dee2e6" stroke-width="2" rx="3"/>
|
||||||
|
<text x="310" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">8</text>
|
||||||
|
|
||||||
|
<!-- Bracket -->
|
||||||
|
<path d="M 0,60 L 0,70 L 200,70 L 200,60" fill="none" stroke="#10b981" stroke-width="2"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Transition -->
|
||||||
|
<g transform="translate(400, 260)">
|
||||||
|
<path d="M 0,0 L 0,30" stroke="#dee2e6" stroke-width="2" fill="none"/>
|
||||||
|
<text x="10" y="20" font-family="Arial, sans-serif" font-size="14" font-style="italic" fill="#7f8c8d">Slide window: -2, +1</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Step 2: Slid window -->
|
||||||
|
<g transform="translate(150, 340)">
|
||||||
|
<text x="0" y="-20" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">2. Next window (Sum = 9)</text>
|
||||||
|
|
||||||
|
<!-- Array -->
|
||||||
|
<g transform="translate(0, 20)">
|
||||||
|
<rect x="0" y="0" width="60" height="50" fill="#e5e7eb" stroke="#94a3b8" stroke-width="2" rx="3"/>
|
||||||
|
<text x="30" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#94a3b8">2</text>
|
||||||
|
|
||||||
|
<rect x="70" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||||
|
<text x="100" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">3</text>
|
||||||
|
|
||||||
|
<rect x="140" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||||
|
<text x="170" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">5</text>
|
||||||
|
|
||||||
|
<rect x="210" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||||
|
<text x="240" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#1b5e20">1</text>
|
||||||
|
|
||||||
|
<rect x="280" y="0" width="60" height="50" fill="#ffffff" stroke="#dee2e6" stroke-width="2" rx="3"/>
|
||||||
|
<text x="310" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">8</text>
|
||||||
|
|
||||||
|
<!-- Bracket -->
|
||||||
|
<path d="M 70,60 L 70,70 L 270,70 L 270,60" fill="none" stroke="#10b981" stroke-width="2"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,66 @@
|
|||||||
|
<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Background (REQUIRED) -->
|
||||||
|
<rect width="800" height="450" fill="#f8f9fa" rx="10"/>
|
||||||
|
|
||||||
|
<!-- Title and Subtitle -->
|
||||||
|
<text x="400" y="45" font-family="Arial, sans-serif" font-size="26" font-weight="bold" fill="#2c3e50" text-anchor="middle">Sliding Window: Flexible Size</text>
|
||||||
|
<text x="400" y="75" font-family="Arial, sans-serif" font-size="16" fill="#7f8c8d" text-anchor="middle">Window expands until invalid, then shrinks until valid again.</text>
|
||||||
|
|
||||||
|
<!-- Step 1: Invalid state -->
|
||||||
|
<g transform="translate(150, 130)">
|
||||||
|
<text x="0" y="-20" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">1. Expansion breaks invariant</text>
|
||||||
|
|
||||||
|
<!-- Array -->
|
||||||
|
<g transform="translate(0, 20)">
|
||||||
|
<rect x="0" y="0" width="60" height="50" fill="#fee2e2" stroke="#ef4444" stroke-width="3" rx="3"/>
|
||||||
|
<text x="30" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">a</text>
|
||||||
|
<polygon points="30,60 25,70 35,70" fill="#27ae60"/>
|
||||||
|
<text x="30" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#27ae60" text-anchor="middle">L</text>
|
||||||
|
|
||||||
|
<rect x="70" y="0" width="60" height="50" fill="#fee2e2" stroke="#ef4444" stroke-width="3" rx="3"/>
|
||||||
|
<text x="100" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">b</text>
|
||||||
|
|
||||||
|
<rect x="140" y="0" width="60" height="50" fill="#fee2e2" stroke="#ef4444" stroke-width="3" rx="3"/>
|
||||||
|
<text x="170" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">c</text>
|
||||||
|
|
||||||
|
<rect x="210" y="0" width="60" height="50" fill="#fee2e2" stroke="#ef4444" stroke-width="4" rx="3"/>
|
||||||
|
<text x="240" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">a</text>
|
||||||
|
<polygon points="240,60 235,70 245,70" fill="#3498db"/>
|
||||||
|
<text x="240" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#3498db" text-anchor="middle">R</text>
|
||||||
|
|
||||||
|
<text x="300" y="32" font-family="Arial, sans-serif" font-size="14" fill="#ef4444" font-weight="bold">Duplicate 'a'!</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Transition -->
|
||||||
|
<g transform="translate(400, 260)">
|
||||||
|
<path d="M 0,0 L 0,30" stroke="#dee2e6" stroke-width="2" fill="none"/>
|
||||||
|
<text x="10" y="20" font-family="Arial, sans-serif" font-size="14" font-style="italic" fill="#7f8c8d">Shrink from left</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Step 2: Valid again -->
|
||||||
|
<g transform="translate(150, 340)">
|
||||||
|
<text x="0" y="-20" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">2. Invariant restored</text>
|
||||||
|
|
||||||
|
<!-- Array -->
|
||||||
|
<g transform="translate(0, 20)">
|
||||||
|
<rect x="0" y="0" width="60" height="50" fill="#e5e7eb" stroke="#94a3b8" stroke-width="2" rx="3"/>
|
||||||
|
<text x="30" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#94a3b8">a</text>
|
||||||
|
|
||||||
|
<rect x="70" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||||
|
<text x="100" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">b</text>
|
||||||
|
<polygon points="100,60 95,70 105,70" fill="#27ae60"/>
|
||||||
|
<text x="100" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#27ae60" text-anchor="middle">L</text>
|
||||||
|
|
||||||
|
<rect x="140" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||||
|
<text x="170" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">c</text>
|
||||||
|
|
||||||
|
<rect x="210" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||||
|
<text x="240" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">a</text>
|
||||||
|
<polygon points="240,60 235,70 245,70" fill="#3498db"/>
|
||||||
|
<text x="240" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#3498db" text-anchor="middle">R</text>
|
||||||
|
|
||||||
|
<text x="300" y="32" font-family="Arial, sans-serif" font-size="14" fill="#10b981" font-weight="bold">Valid again!</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,54 @@
|
|||||||
|
<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Background (REQUIRED) -->
|
||||||
|
<rect width="800" height="450" fill="#f8f9fa" rx="10"/>
|
||||||
|
|
||||||
|
<!-- Title and Subtitle -->
|
||||||
|
<text x="400" y="45" font-family="Arial, sans-serif" font-size="26" font-weight="bold" fill="#2c3e50" text-anchor="middle">Sliding Window: Frequency Tracking</text>
|
||||||
|
<text x="400" y="75" font-family="Arial, sans-serif" font-size="16" fill="#7f8c8d" text-anchor="middle">Maintaining a character frequency map for the current window.</text>
|
||||||
|
|
||||||
|
<!-- Left: Window -->
|
||||||
|
<g transform="translate(100, 150)">
|
||||||
|
<text x="0" y="-30" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">Current Window</text>
|
||||||
|
|
||||||
|
<!-- Array -->
|
||||||
|
<g transform="translate(0, 20)">
|
||||||
|
<rect x="0" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||||
|
<text x="30" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">a</text>
|
||||||
|
<polygon points="30,60 25,70 35,70" fill="#27ae60"/>
|
||||||
|
<text x="30" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#27ae60" text-anchor="middle">L</text>
|
||||||
|
|
||||||
|
<rect x="70" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||||
|
<text x="100" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">b</text>
|
||||||
|
|
||||||
|
<rect x="140" y="0" width="60" height="50" fill="#d1fae5" stroke="#10b981" stroke-width="3" rx="3"/>
|
||||||
|
<text x="170" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">a</text>
|
||||||
|
<polygon points="170,60 165,70 175,70" fill="#3498db"/>
|
||||||
|
<text x="170" y="85" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#3498db" text-anchor="middle">R</text>
|
||||||
|
|
||||||
|
<rect x="210" y="0" width="60" height="50" fill="#ffffff" stroke="#dee2e6" stroke-width="2" rx="3"/>
|
||||||
|
<text x="240" y="32" font-family="monospace" font-size="22" text-anchor="middle" fill="#2c3e50">c</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Right: Frequency Map -->
|
||||||
|
<g transform="translate(500, 150)">
|
||||||
|
<text x="0" y="-30" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#2c3e50">Frequency Map</text>
|
||||||
|
|
||||||
|
<!-- Map Container -->
|
||||||
|
<rect x="0" y="0" width="150" height="120" fill="#ffffff" stroke="#dee2e6" stroke-width="2" rx="8"/>
|
||||||
|
|
||||||
|
<!-- Entries -->
|
||||||
|
<g transform="translate(25, 35)">
|
||||||
|
<text x="0" y="0" font-family="monospace" font-size="18" fill="#2c3e50">'a' : 2</text>
|
||||||
|
<text x="0" y="30" font-family="monospace" font-size="18" fill="#2c3e50">'b' : 1</text>
|
||||||
|
<text x="0" y="60" font-family="monospace" font-size="18" fill="#7f8c8d">'c' : 0</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Invariant label -->
|
||||||
|
<rect x="-20" y="140" width="190" height="40" fill="#e3f2fd" stroke="#3498db" stroke-width="1" rx="5"/>
|
||||||
|
<text x="75" y="165" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="#1565c0" text-anchor="middle">INVARIANT: SYNCED STATE</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Connector -->
|
||||||
|
<path d="M 380,195 L 480,195" stroke="#dee2e6" stroke-width="2" stroke-dasharray="5,5" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -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)
|
||||||
@@ -4,19 +4,36 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "docs",
|
"name": "docs",
|
||||||
|
},
|
||||||
|
"apps/api": {
|
||||||
|
"name": "srs-api",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@revealjs/react": "^0.2.1",
|
"@react-email/components": "^1.0.12",
|
||||||
"blume": "^1.5.1",
|
"@react-email/render": "^2.1.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"reveal.js": "^6.0.1",
|
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"wrangler": "^4.125.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"apps/cli": {
|
||||||
|
"name": "cli",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clack/prompts": "^1.7.0",
|
"@clack/prompts": "^1.7.0",
|
||||||
"@night-slayer18/leetcode-cli": "^3.5.0",
|
"@night-slayer18/leetcode-cli": "^3.5.0",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"apps/docs": {
|
||||||
|
"name": "docs",
|
||||||
|
"dependencies": {
|
||||||
|
"blume": "^1.5.1",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.53", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4ZgwJjv/lGA7ywlD/3oYg6XMkIy1VvVKiSRYB83PyLDApwMGXSF+oT7D5lPYJFhNE5Vr3W2H+SwCFuTsbfgn9w=="],
|
"@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.53", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4ZgwJjv/lGA7ywlD/3oYg6XMkIy1VvVKiSRYB83PyLDApwMGXSF+oT7D5lPYJFhNE5Vr3W2H+SwCFuTsbfgn9w=="],
|
||||||
@@ -147,8 +164,24 @@
|
|||||||
|
|
||||||
"@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="],
|
"@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="],
|
||||||
|
|
||||||
|
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
|
||||||
|
|
||||||
|
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
|
||||||
|
|
||||||
|
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260820.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-5F2/t7SVnugG3rscSe9da1LoHst+GiuVGPaE9BP6j5AonlFpiYi0eoJrl3wof9zDBIIgJZ87NjRj9TKjbbYgHg=="],
|
||||||
|
|
||||||
|
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260820.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jFnG7715+r9FXRZPpuWWe5Ayd9v/IJKDODARg56ffFJWWtte6bFNi5VY3GazBM04hEmxny6OjXQBh3j2E/wNZw=="],
|
||||||
|
|
||||||
|
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260820.1", "", { "os": "linux", "cpu": "x64" }, "sha512-TbTYaCBht0OaOWmnVpg43hXVYtIti/Kg6lvO819H2DwbxPpK1BH0z2C+Y7ySrVJLlxZQeic5eN7/noqbP80hJg=="],
|
||||||
|
|
||||||
|
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260820.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-FQmri1UF7hBnnpeyC5SZJCAnsSRs3+Ykn9wlO5zxmE2qIgKfbJZ+toJ6qrQyugWlxE65juT33HU6aYLtutLJHw=="],
|
||||||
|
|
||||||
|
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260820.1", "", { "os": "win32", "cpu": "x64" }, "sha512-BPvCuMxIQfA47wtYsGbBG6Bcar57Qs7yHqU2jWkT1+sRnL/741/ZbQGP9kVRMQ5fwBMuqNFmQRzAu8gSm7ri/w=="],
|
||||||
|
|
||||||
"@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="],
|
"@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="],
|
||||||
|
|
||||||
|
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||||
|
|
||||||
"@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="],
|
"@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="],
|
||||||
|
|
||||||
"@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="],
|
"@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="],
|
||||||
@@ -169,57 +202,57 @@
|
|||||||
|
|
||||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||||
|
|
||||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||||
|
|
||||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||||
|
|
||||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||||
|
|
||||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||||
|
|
||||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||||
|
|
||||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||||
|
|
||||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||||
|
|
||||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||||
|
|
||||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||||
|
|
||||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||||
|
|
||||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||||
|
|
||||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||||
|
|
||||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||||
|
|
||||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||||
|
|
||||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||||
|
|
||||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||||
|
|
||||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||||
|
|
||||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||||
|
|
||||||
"@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="],
|
"@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="],
|
||||||
|
|
||||||
@@ -325,7 +358,7 @@
|
|||||||
|
|
||||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||||
|
|
||||||
"@jsep-plugin/assignment": ["@jsep-plugin/assignment@1.3.0", "", { "peerDependencies": { "jsep": "^0.4.0||^1.0.0" } }, "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ=="],
|
"@jsep-plugin/assignment": ["@jsep-plugin/assignment@1.3.0", "", { "peerDependencies": { "jsep": "^0.4.0||^1.0.0" } }, "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ=="],
|
||||||
|
|
||||||
@@ -377,7 +410,53 @@
|
|||||||
|
|
||||||
"@pierre/theming": ["@pierre/theming@1.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.1.0 || ^2.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA=="],
|
"@pierre/theming": ["@pierre/theming@1.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.1.0 || ^2.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA=="],
|
||||||
|
|
||||||
"@revealjs/react": ["@revealjs/react@0.2.1", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18", "reveal.js": ">=5" } }, "sha512-bEivSQ1W0WpiSUPd4KNLkFGSkdywuno2jjzBHQCO+SN3ChFWP6Y3Z2OLRih3Qeel5k1bwKsSsmr5AMUMA5ZdyQ=="],
|
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
|
||||||
|
|
||||||
|
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
|
||||||
|
|
||||||
|
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
||||||
|
|
||||||
|
"@react-email/body": ["@react-email/body@0.3.0", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug=="],
|
||||||
|
|
||||||
|
"@react-email/button": ["@react-email/button@0.2.1", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A=="],
|
||||||
|
|
||||||
|
"@react-email/code-block": ["@react-email/code-block@0.2.1", "", { "dependencies": { "prismjs": "^1.30.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw=="],
|
||||||
|
|
||||||
|
"@react-email/code-inline": ["@react-email/code-inline@0.0.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA=="],
|
||||||
|
|
||||||
|
"@react-email/column": ["@react-email/column@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg=="],
|
||||||
|
|
||||||
|
"@react-email/components": ["@react-email/components@1.0.12", "", { "dependencies": { "@react-email/body": "0.3.0", "@react-email/button": "0.2.1", "@react-email/code-block": "0.2.1", "@react-email/code-inline": "0.0.6", "@react-email/column": "0.0.14", "@react-email/container": "0.0.16", "@react-email/font": "0.0.10", "@react-email/head": "0.0.13", "@react-email/heading": "0.0.16", "@react-email/hr": "0.0.12", "@react-email/html": "0.0.12", "@react-email/img": "0.0.12", "@react-email/link": "0.0.13", "@react-email/markdown": "0.0.18", "@react-email/preview": "0.0.14", "@react-email/render": "2.0.6", "@react-email/row": "0.0.13", "@react-email/section": "0.0.17", "@react-email/tailwind": "2.0.7", "@react-email/text": "0.1.6" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-tH18JhPDWgE+3jnYkzyB6ZrZdfNnEsFe4PwmuXmlOw4NGIysP8wPY5aXZg++pTG9qUabXg1nzX/FGHGkObH8xQ=="],
|
||||||
|
|
||||||
|
"@react-email/container": ["@react-email/container@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ=="],
|
||||||
|
|
||||||
|
"@react-email/font": ["@react-email/font@0.0.10", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA=="],
|
||||||
|
|
||||||
|
"@react-email/head": ["@react-email/head@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog=="],
|
||||||
|
|
||||||
|
"@react-email/heading": ["@react-email/heading@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw=="],
|
||||||
|
|
||||||
|
"@react-email/hr": ["@react-email/hr@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA=="],
|
||||||
|
|
||||||
|
"@react-email/html": ["@react-email/html@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw=="],
|
||||||
|
|
||||||
|
"@react-email/img": ["@react-email/img@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ=="],
|
||||||
|
|
||||||
|
"@react-email/link": ["@react-email/link@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw=="],
|
||||||
|
|
||||||
|
"@react-email/markdown": ["@react-email/markdown@0.0.18", "", { "dependencies": { "marked": "^15.0.12" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg=="],
|
||||||
|
|
||||||
|
"@react-email/preview": ["@react-email/preview@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw=="],
|
||||||
|
|
||||||
|
"@react-email/render": ["@react-email/render@2.1.0", "", { "dependencies": { "entities": "^4.5.0", "html-to-text": "^9.0.5", "html5parser": "^3.0.0", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-F+zE3O6d6sW6Aj2UjvZAA17R7tJKM7kcq2mgV6k4HCT8jeLLFaVP2txMtH1lgqYFRMZ0Gxsd37q2PRyiXLXXxA=="],
|
||||||
|
|
||||||
|
"@react-email/row": ["@react-email/row@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw=="],
|
||||||
|
|
||||||
|
"@react-email/section": ["@react-email/section@0.0.17", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w=="],
|
||||||
|
|
||||||
|
"@react-email/tailwind": ["@react-email/tailwind@2.0.7", "", { "dependencies": { "tailwindcss": "^4.1.18" }, "peerDependencies": { "@react-email/body": ">=0", "@react-email/button": ">=0", "@react-email/code-block": ">=0", "@react-email/code-inline": ">=0", "@react-email/container": ">=0", "@react-email/heading": ">=0", "@react-email/hr": ">=0", "@react-email/img": ">=0", "@react-email/link": ">=0", "@react-email/preview": ">=0", "@react-email/text": ">=0", "react": "^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@react-email/body", "@react-email/button", "@react-email/code-block", "@react-email/code-inline", "@react-email/container", "@react-email/heading", "@react-email/hr", "@react-email/img", "@react-email/link", "@react-email/preview"] }, "sha512-kGw80weVFXikcnCXbigTGXGWQ0MRCSYNCudcdkWxebkWYd0FG6/NPoN3V1p/u68/4+NxZwYPVi2fhnp0x23HdA=="],
|
||||||
|
|
||||||
|
"@react-email/text": ["@react-email/text@0.1.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw=="],
|
||||||
|
|
||||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA=="],
|
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA=="],
|
||||||
|
|
||||||
@@ -435,6 +514,8 @@
|
|||||||
|
|
||||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||||
|
|
||||||
|
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
|
||||||
|
|
||||||
"@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="],
|
"@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="],
|
||||||
|
|
||||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="],
|
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="],
|
||||||
@@ -457,6 +538,8 @@
|
|||||||
|
|
||||||
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||||
|
|
||||||
|
"@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="],
|
||||||
|
|
||||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@stoplight/better-ajv-errors": ["@stoplight/better-ajv-errors@1.0.3", "", { "dependencies": { "jsonpointer": "^5.0.0", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-0p9uXkuB22qGdNfy3VeEhxkU5uwvp/KrBTAbrLBURv6ilxIVwanKwjMc41lQfIVgPGcOkmLbTolfFrSsueu7zA=="],
|
"@stoplight/better-ajv-errors": ["@stoplight/better-ajv-errors@1.0.3", "", { "dependencies": { "jsonpointer": "^5.0.0", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-0p9uXkuB22qGdNfy3VeEhxkU5uwvp/KrBTAbrLBURv6ilxIVwanKwjMc41lQfIVgPGcOkmLbTolfFrSsueu7zA=="],
|
||||||
@@ -789,6 +872,8 @@
|
|||||||
|
|
||||||
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||||
|
|
||||||
|
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
||||||
|
|
||||||
"blume": ["blume@1.5.1", "", { "dependencies": { "@astrojs/check": "^0.9.0", "@astrojs/markdown-satteri": "^0.3.2", "@astrojs/mdx": "^7.0.0", "@astrojs/node": "^11.0.0", "@astrojs/react": "^6.0.0", "@astrojs/vercel": "^11.0.3", "@asyncapi/converter": "^2.0.2", "@clack/prompts": "^1.7.0", "@iconify-json/lucide": "^1.2.115", "@iconify/types": "^2.0.0", "@iconify/utils": "^3.1.3", "@modelcontextprotocol/sdk": "^1.29.0", "@orama/orama": "^3.1.18", "@pierre/diffs": "^1.2.11", "@scalar/astro": "^0.4.5", "@scalar/openapi-parser": "^0.28.8", "@scalar/openapi-types": "^0.9.1", "@shikijs/transformers": "^4.2.0", "@shikijs/twoslash": "^4.2.0", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4", "@types/mdast": "^4.0.4", "@vercel/analytics": "^2.0.1", "ai": "^7.0.42", "astro": "^7.1.0", "babel-plugin-react-compiler": "^1.0.0", "chokidar": "^5.0.0", "citty": "^0.1.6", "consola": "^3.4.0", "cross-spawn": "^7.0.6", "dompurify": "^3.4.13", "dotenv": "^17.4.2", "epub-gen-memory": "^1.1.2", "fast-xml-parser": "^5.10.1", "get-tsconfig": "^4.14.1", "github-slugger": "^2.0.0", "gray-matter": "^4.0.3", "html-escaper": "^3.0.3", "image-size": "^2.0.2", "jiti": "^2.4.0", "js-yaml": "^4.3.1", "katex": "^0.18.1", "markdown-table": "^3.0.4", "marked": "^18.0.5", "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", "mdast-util-to-string": "^4.0.0", "medium-zoom": "^1.1.0", "mermaid": "^11.16.1", "micromark-extension-gfm": "^3.0.0", "nanotar": "^0.3.0", "node-html-parser": "^9.0.0", "openapi-sampler": "^1.7.4", "p-limit": "^7.3.1", "p-map": "^7.0.6", "p-retry": "^8.0.0", "package-manager-detector": "^1.8.0", "pagefind": "^1.3.0", "pathe": "^2.0.0", "perfect-debounce": "^2.1.0", "picomatch": "^4.0.5", "react": "^19.0.0", "react-dom": "^19.0.0", "robots-parser": "^3.0.1", "satteri": "^0.9.5", "semver": "^7.8.5", "sharp": "^0.35.3", "shiki": "^4.2.0", "simple-icons": "^13.0.0", "string-width": "^8.1.0", "tailwindcss": "^4.3.3", "takumi-js": "^2.2.1", "tinyglobby": "^0.2.10", "twoslash": "^0.3.9", "typescript": "^6.0.3", "ufo": "^1.6.4", "undici": "^8.9.0", "write-file-atomic": "^8.0.0", "zod": "^4.3.6" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^3.0.0", "@astrojs/cloudflare": "^14.0.0", "@astrojs/netlify": "^8.0.0", "@astrojs/svelte": "^9.0.0", "@astrojs/vue": "^7.0.0", "@mixedbread/sdk": "^0.76.0", "@notionhq/client": "^2.2.15", "@openrouter/ai-sdk-provider": "^3.0.0", "@oramacloud/client": "^2.1.0", "@sanity/client": "^6.21.0 || ^7.0.0", "algoliasearch": "^5.55.0", "flexsearch": "^0.8.0", "typesense": "^3.0.0" }, "optionalPeers": ["@ai-sdk/openai-compatible", "@astrojs/cloudflare", "@astrojs/netlify", "@astrojs/svelte", "@astrojs/vue", "@mixedbread/sdk", "@notionhq/client", "@openrouter/ai-sdk-provider", "@oramacloud/client", "@sanity/client", "algoliasearch", "flexsearch", "typesense"], "bin": { "blume": "bin/blume.mjs" } }, "sha512-RnzoY+OHvchUJkXjF7s45SUW5rhuL81URWATEZoEl3psWeajsUu6JRyqDuy8bpYZjoth7P6gpS4IijZspv8Yrw=="],
|
"blume": ["blume@1.5.1", "", { "dependencies": { "@astrojs/check": "^0.9.0", "@astrojs/markdown-satteri": "^0.3.2", "@astrojs/mdx": "^7.0.0", "@astrojs/node": "^11.0.0", "@astrojs/react": "^6.0.0", "@astrojs/vercel": "^11.0.3", "@asyncapi/converter": "^2.0.2", "@clack/prompts": "^1.7.0", "@iconify-json/lucide": "^1.2.115", "@iconify/types": "^2.0.0", "@iconify/utils": "^3.1.3", "@modelcontextprotocol/sdk": "^1.29.0", "@orama/orama": "^3.1.18", "@pierre/diffs": "^1.2.11", "@scalar/astro": "^0.4.5", "@scalar/openapi-parser": "^0.28.8", "@scalar/openapi-types": "^0.9.1", "@shikijs/transformers": "^4.2.0", "@shikijs/twoslash": "^4.2.0", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4", "@types/mdast": "^4.0.4", "@vercel/analytics": "^2.0.1", "ai": "^7.0.42", "astro": "^7.1.0", "babel-plugin-react-compiler": "^1.0.0", "chokidar": "^5.0.0", "citty": "^0.1.6", "consola": "^3.4.0", "cross-spawn": "^7.0.6", "dompurify": "^3.4.13", "dotenv": "^17.4.2", "epub-gen-memory": "^1.1.2", "fast-xml-parser": "^5.10.1", "get-tsconfig": "^4.14.1", "github-slugger": "^2.0.0", "gray-matter": "^4.0.3", "html-escaper": "^3.0.3", "image-size": "^2.0.2", "jiti": "^2.4.0", "js-yaml": "^4.3.1", "katex": "^0.18.1", "markdown-table": "^3.0.4", "marked": "^18.0.5", "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", "mdast-util-to-string": "^4.0.0", "medium-zoom": "^1.1.0", "mermaid": "^11.16.1", "micromark-extension-gfm": "^3.0.0", "nanotar": "^0.3.0", "node-html-parser": "^9.0.0", "openapi-sampler": "^1.7.4", "p-limit": "^7.3.1", "p-map": "^7.0.6", "p-retry": "^8.0.0", "package-manager-detector": "^1.8.0", "pagefind": "^1.3.0", "pathe": "^2.0.0", "perfect-debounce": "^2.1.0", "picomatch": "^4.0.5", "react": "^19.0.0", "react-dom": "^19.0.0", "robots-parser": "^3.0.1", "satteri": "^0.9.5", "semver": "^7.8.5", "sharp": "^0.35.3", "shiki": "^4.2.0", "simple-icons": "^13.0.0", "string-width": "^8.1.0", "tailwindcss": "^4.3.3", "takumi-js": "^2.2.1", "tinyglobby": "^0.2.10", "twoslash": "^0.3.9", "typescript": "^6.0.3", "ufo": "^1.6.4", "undici": "^8.9.0", "write-file-atomic": "^8.0.0", "zod": "^4.3.6" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^3.0.0", "@astrojs/cloudflare": "^14.0.0", "@astrojs/netlify": "^8.0.0", "@astrojs/svelte": "^9.0.0", "@astrojs/vue": "^7.0.0", "@mixedbread/sdk": "^0.76.0", "@notionhq/client": "^2.2.15", "@openrouter/ai-sdk-provider": "^3.0.0", "@oramacloud/client": "^2.1.0", "@sanity/client": "^6.21.0 || ^7.0.0", "algoliasearch": "^5.55.0", "flexsearch": "^0.8.0", "typesense": "^3.0.0" }, "optionalPeers": ["@ai-sdk/openai-compatible", "@astrojs/cloudflare", "@astrojs/netlify", "@astrojs/svelte", "@astrojs/vue", "@mixedbread/sdk", "@notionhq/client", "@openrouter/ai-sdk-provider", "@oramacloud/client", "@sanity/client", "algoliasearch", "flexsearch", "typesense"], "bin": { "blume": "bin/blume.mjs" } }, "sha512-RnzoY+OHvchUJkXjF7s45SUW5rhuL81URWATEZoEl3psWeajsUu6JRyqDuy8bpYZjoth7P6gpS4IijZspv8Yrw=="],
|
||||||
|
|
||||||
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
|
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
|
||||||
@@ -847,6 +932,8 @@
|
|||||||
|
|
||||||
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||||
|
|
||||||
|
"cli": ["cli@workspace:apps/cli"],
|
||||||
|
|
||||||
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||||
|
|
||||||
"cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="],
|
"cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="],
|
||||||
@@ -1003,6 +1090,8 @@
|
|||||||
|
|
||||||
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||||
|
|
||||||
|
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||||
|
|
||||||
"default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="],
|
"default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="],
|
||||||
|
|
||||||
"default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="],
|
"default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="],
|
||||||
@@ -1035,6 +1124,8 @@
|
|||||||
|
|
||||||
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||||
|
|
||||||
|
"docs": ["docs@workspace:apps/docs"],
|
||||||
|
|
||||||
"dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="],
|
"dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="],
|
||||||
|
|
||||||
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||||
@@ -1071,7 +1162,7 @@
|
|||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
|
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
|
||||||
|
|
||||||
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
|
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||||
|
|
||||||
@@ -1079,6 +1170,8 @@
|
|||||||
|
|
||||||
"epub-gen-memory": ["epub-gen-memory@1.1.2", "", { "dependencies": { "abort-controller": "^3.0.0", "css-select": "^4.1.3", "diacritics": "^1.3.0", "dom-serializer": "^1.3.2", "domhandler": "^4.2.2", "domutils": "^2.8.0", "ejs": "^3.1.6", "htmlparser2": "^7.1.2", "jszip": "^3.7.1", "mime": "^2.5.2", "node-fetch": "^2.0.0", "ow": "^0.28.1", "slugify": "^1.6.5" } }, "sha512-vwGM6MVNqKIskFzPZqhi4ZOs0ZTUXco9oDuHFX1vB2Il9pTAkaHWFBFgHrrl832dYmBPb/raGVUZXFvZYueRyw=="],
|
"epub-gen-memory": ["epub-gen-memory@1.1.2", "", { "dependencies": { "abort-controller": "^3.0.0", "css-select": "^4.1.3", "diacritics": "^1.3.0", "dom-serializer": "^1.3.2", "domhandler": "^4.2.2", "domutils": "^2.8.0", "ejs": "^3.1.6", "htmlparser2": "^7.1.2", "jszip": "^3.7.1", "mime": "^2.5.2", "node-fetch": "^2.0.0", "ow": "^0.28.1", "slugify": "^1.6.5" } }, "sha512-vwGM6MVNqKIskFzPZqhi4ZOs0ZTUXco9oDuHFX1vB2Il9pTAkaHWFBFgHrrl832dYmBPb/raGVUZXFvZYueRyw=="],
|
||||||
|
|
||||||
|
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
||||||
|
|
||||||
"es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="],
|
"es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="],
|
||||||
|
|
||||||
"es-abstract-get": ["es-abstract-get@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "es-object-atoms": "^1.1.2", "is-callable": "^1.2.7", "object-inspect": "^1.13.4" } }, "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg=="],
|
"es-abstract-get": ["es-abstract-get@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "es-object-atoms": "^1.1.2", "is-callable": "^1.2.7", "object-inspect": "^1.13.4" } }, "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg=="],
|
||||||
@@ -1103,7 +1196,7 @@
|
|||||||
|
|
||||||
"esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="],
|
"esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
@@ -1287,8 +1380,12 @@
|
|||||||
|
|
||||||
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
|
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
|
||||||
|
|
||||||
|
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
|
||||||
|
|
||||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||||
|
|
||||||
|
"html5parser": ["html5parser@3.0.0", "", {}, "sha512-iNpSopa+4YHX50UOk825tBy7MghmXHo/ZpLskBYN0kAr1xhH8GlIMk5bLRXcZlfP3AnLUcSuFMu8C4MdOUxA8A=="],
|
||||||
|
|
||||||
"htmlparser2": ["htmlparser2@7.2.0", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.2", "domutils": "^2.8.0", "entities": "^3.0.1" } }, "sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog=="],
|
"htmlparser2": ["htmlparser2@7.2.0", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.2", "domutils": "^2.8.0", "entities": "^3.0.1" } }, "sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog=="],
|
||||||
|
|
||||||
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
||||||
@@ -1463,6 +1560,8 @@
|
|||||||
|
|
||||||
"layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="],
|
"layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="],
|
||||||
|
|
||||||
|
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
|
||||||
|
|
||||||
"leven": ["leven@4.1.0", "", {}, "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew=="],
|
"leven": ["leven@4.1.0", "", {}, "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew=="],
|
||||||
|
|
||||||
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||||
@@ -1653,6 +1752,8 @@
|
|||||||
|
|
||||||
"mimic-response": ["mimic-response@4.0.0", "", {}, "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg=="],
|
"mimic-response": ["mimic-response@4.0.0", "", {}, "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg=="],
|
||||||
|
|
||||||
|
"miniflare": ["miniflare@5.20260820.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260820.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-Bv1j2kcKKNwXLWuCx+j0xGt7z318mqQkJmEN6ellM9sCESbPBDTM9ofZMbKqx47jSnoGA3CiaUkAmzGVXUa/wQ=="],
|
||||||
|
|
||||||
"minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
|
"minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
|
||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
@@ -1777,6 +1878,8 @@
|
|||||||
|
|
||||||
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="],
|
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="],
|
||||||
|
|
||||||
|
"parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
|
||||||
|
|
||||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||||
|
|
||||||
"path": ["path@0.12.7", "", { "dependencies": { "process": "^0.11.1", "util": "^0.10.3" } }, "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q=="],
|
"path": ["path@0.12.7", "", { "dependencies": { "process": "^0.11.1", "util": "^0.10.3" } }, "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q=="],
|
||||||
@@ -1791,12 +1894,14 @@
|
|||||||
|
|
||||||
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
|
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
|
||||||
|
|
||||||
"path-to-regexp": ["path-to-regexp@6.1.0", "", {}, "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw=="],
|
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||||
|
|
||||||
"path-to-regexp-updated": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
"path-to-regexp-updated": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||||
|
|
||||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|
||||||
|
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
|
||||||
|
|
||||||
"perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="],
|
"perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="],
|
||||||
|
|
||||||
"piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="],
|
"piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="],
|
||||||
@@ -1923,8 +2028,6 @@
|
|||||||
|
|
||||||
"retext-stringify": ["retext-stringify@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unified": "^11.0.0" } }, "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA=="],
|
"retext-stringify": ["retext-stringify@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unified": "^11.0.0" } }, "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA=="],
|
||||||
|
|
||||||
"reveal.js": ["reveal.js@6.0.1", "", {}, "sha512-9eacArNIgqO2HGWOK+93gJNn+gvdGDVbSq+i2u3Ja9kjiHps0XNLpgYTZTYjKRH91uXy3clGimeGiw4umHG/tg=="],
|
|
||||||
|
|
||||||
"robots-parser": ["robots-parser@3.0.1", "", {}, "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ=="],
|
"robots-parser": ["robots-parser@3.0.1", "", {}, "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ=="],
|
||||||
|
|
||||||
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
|
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
|
||||||
@@ -1963,6 +2066,8 @@
|
|||||||
|
|
||||||
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
|
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
|
||||||
|
|
||||||
|
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
|
||||||
|
|
||||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||||
|
|
||||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||||
@@ -2021,6 +2126,8 @@
|
|||||||
|
|
||||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||||
|
|
||||||
|
"srs-api": ["srs-api@workspace:apps/api"],
|
||||||
|
|
||||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||||
|
|
||||||
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
|
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
|
||||||
@@ -2143,6 +2250,8 @@
|
|||||||
|
|
||||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||||
|
|
||||||
|
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
|
||||||
|
|
||||||
"unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="],
|
"unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="],
|
||||||
|
|
||||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||||
@@ -2253,12 +2362,18 @@
|
|||||||
|
|
||||||
"which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="],
|
"which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="],
|
||||||
|
|
||||||
|
"workerd": ["workerd@1.20260820.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260820.1", "@cloudflare/workerd-darwin-arm64": "1.20260820.1", "@cloudflare/workerd-linux-64": "1.20260820.1", "@cloudflare/workerd-linux-arm64": "1.20260820.1", "@cloudflare/workerd-windows-64": "1.20260820.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-/wk4rFNHH6IVMXFe6aPEZsT5YWpWtfKTUMt7+rFkyeGbXcGCy4yxODmJatbqYj0jmqHETdz0+m2mT+vNjXnF7w=="],
|
||||||
|
|
||||||
|
"wrangler": ["wrangler@4.125.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260820.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260820.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260820.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-yFpvggu+xk1Hdm/Uxwaqa19bb7GArME4CrCS3Vov68a2TZq2MPO+wLocKbbnIC9K0oLowcdau7/ycxbbNHKCEg=="],
|
||||||
|
|
||||||
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||||
|
|
||||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
"write-file-atomic": ["write-file-atomic@8.0.0", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-dYwyZredl67GyLLIHJnRM3h2PcOmN5SkcgC7eM5DPDEOEl6dLFqVrMg3F1Ea32usj4VSVZtd2H4MtKTNOf6nPg=="],
|
"write-file-atomic": ["write-file-atomic@8.0.0", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-dYwyZredl67GyLLIHJnRM3h2PcOmN5SkcgC7eM5DPDEOEl6dLFqVrMg3F1Ea32usj4VSVZtd2H4MtKTNOf6nPg=="],
|
||||||
|
|
||||||
|
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||||
|
|
||||||
"wsl-utils": ["wsl-utils@1.0.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="],
|
"wsl-utils": ["wsl-utils@1.0.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="],
|
||||||
|
|
||||||
"xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="],
|
"xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="],
|
||||||
@@ -2285,6 +2400,10 @@
|
|||||||
|
|
||||||
"yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="],
|
"yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="],
|
||||||
|
|
||||||
|
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||||
|
|
||||||
|
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||||
|
|
||||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||||
|
|
||||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||||
@@ -2301,6 +2420,8 @@
|
|||||||
|
|
||||||
"@astrojs/vercel/@vercel/analytics": ["@vercel/analytics@1.6.1", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg=="],
|
"@astrojs/vercel/@vercel/analytics": ["@vercel/analytics@1.6.1", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||||
|
|
||||||
"@astrojs/yaml2ts/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
|
"@astrojs/yaml2ts/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
|
||||||
|
|
||||||
"@asyncapi/converter/js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="],
|
"@asyncapi/converter/js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="],
|
||||||
@@ -2311,18 +2432,30 @@
|
|||||||
|
|
||||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
"@pierre/diffs/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
"@pierre/diffs/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||||
|
|
||||||
|
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||||
|
|
||||||
|
"@react-email/components/@react-email/render": ["@react-email/render@2.0.6", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog=="],
|
||||||
|
|
||||||
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||||
|
|
||||||
"@scalar/types/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
|
"@scalar/types/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
|
||||||
|
|
||||||
"@scalar/types/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
|
"@scalar/types/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
|
||||||
|
|
||||||
|
"@selderee/plugin-htmlparser2/domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||||
|
|
||||||
"@stoplight/better-ajv-errors/leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
|
"@stoplight/better-ajv-errors/leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
|
||||||
|
|
||||||
"@stoplight/json/jsonc-parser": ["jsonc-parser@2.2.1", "", {}, "sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w=="],
|
"@stoplight/json/jsonc-parser": ["jsonc-parser@2.2.1", "", {}, "sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w=="],
|
||||||
@@ -2365,10 +2498,14 @@
|
|||||||
|
|
||||||
"@vercel/routing-utils/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
"@vercel/routing-utils/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||||
|
|
||||||
|
"@vercel/routing-utils/path-to-regexp": ["path-to-regexp@6.1.0", "", {}, "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw=="],
|
||||||
|
|
||||||
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
|
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
|
||||||
|
|
||||||
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||||
|
|
||||||
|
"astro/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||||
|
|
||||||
"astro/get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="],
|
"astro/get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="],
|
||||||
|
|
||||||
"bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
"bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||||
@@ -2423,6 +2560,10 @@
|
|||||||
|
|
||||||
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||||
|
|
||||||
|
"html-to-text/dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||||
|
|
||||||
|
"html-to-text/htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
|
||||||
|
|
||||||
"htmlparser2/entities": ["entities@3.0.1", "", {}, "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q=="],
|
"htmlparser2/entities": ["entities@3.0.1", "", {}, "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q=="],
|
||||||
|
|
||||||
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||||
@@ -2433,10 +2574,16 @@
|
|||||||
|
|
||||||
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||||
|
|
||||||
|
"miniflare/sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="],
|
||||||
|
|
||||||
|
"miniflare/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||||
|
|
||||||
"node-emoji/@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
"node-emoji/@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||||
|
|
||||||
"node-html-parser/css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
"node-html-parser/css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||||
|
|
||||||
|
"node-html-parser/entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
|
||||||
|
|
||||||
"ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
"ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||||
|
|
||||||
"ow/@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
"ow/@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||||
@@ -2485,8 +2632,62 @@
|
|||||||
|
|
||||||
"yaml-language-server/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
|
"yaml-language-server/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
|
||||||
|
|
||||||
|
"youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||||
|
|
||||||
"@astrojs/check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
"@astrojs/check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||||
|
|
||||||
|
"@astrojs/vercel/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||||
|
|
||||||
"@asyncapi/converter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
"@asyncapi/converter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||||
|
|
||||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
@@ -2517,6 +2718,58 @@
|
|||||||
|
|
||||||
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||||
|
|
||||||
|
"astro/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||||
|
|
||||||
"cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
|
"cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
|
||||||
|
|
||||||
"cli-highlight/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"cli-highlight/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
@@ -2545,8 +2798,64 @@
|
|||||||
|
|
||||||
"hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
"hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||||
|
|
||||||
|
"html-to-text/dom-serializer/domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||||
|
|
||||||
|
"html-to-text/htmlparser2/domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||||
|
|
||||||
|
"html-to-text/htmlparser2/domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||||
|
|
||||||
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="],
|
||||||
|
|
||||||
"node-html-parser/css-select/domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
"node-html-parser/css-select/domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||||
|
|
||||||
"node-html-parser/css-select/domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
"node-html-parser/css-select/domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||||
@@ -2577,6 +2886,10 @@
|
|||||||
|
|
||||||
"filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="],
|
||||||
|
|
||||||
|
"miniflare/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="],
|
||||||
|
|
||||||
"node-html-parser/css-select/domutils/dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
"node-html-parser/css-select/domutils/dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||||
|
|
||||||
"svgo/css-select/domutils/dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
"svgo/css-select/domutils/dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||||
@@ -2586,7 +2899,5 @@
|
|||||||
"cli-highlight/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"cli-highlight/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"node-html-parser/css-select/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
"node-html-parser/css-select/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
"svgo/css-select/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
---
|
|
||||||
title: 'Frequency Map'
|
|
||||||
description: 'Converging/parallel index walk that prunes the O(n^2) pair space using order.'
|
|
||||||
---
|
|
||||||
|
|
||||||
## The idea
|
|
||||||
|
|
||||||
A frequency map is a hash table with one job: **count things**.
|
|
||||||
|
|
||||||
Key = the thing. Value = how many times you saw it.
|
|
||||||
One pass to build. O(1) to ask "how many?"
|
|
||||||
|
|
||||||
Most string and array problems fall to this question:
|
|
||||||
"Do these two collections contain the same stuff?"
|
|
||||||
Count both. Compare the counts.
|
|
||||||
|
|
||||||
## The picture
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
S["'banana'"] --> C["Counter"]
|
|
||||||
C --> B["b → 1"]
|
|
||||||
C --> A["a → 3"]
|
|
||||||
C --> N["n → 2"]
|
|
||||||
|
|
||||||
style C fill:#1565c0,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
## The Python tool
|
|
||||||
|
|
||||||
`Counter` builds the map in one line. `defaultdict(int)` when you need to count by hand.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from collections import Counter, defaultdict
|
|
||||||
|
|
||||||
count = Counter("banana") # {'a': 3, 'n': 2, 'b': 1}
|
|
||||||
count.most_common(2) # [('a', 3), ('n', 2)]
|
|
||||||
|
|
||||||
freq = defaultdict(int)
|
|
||||||
for c in "banana":
|
|
||||||
freq[c] += 1 # no KeyError, starts at 0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ransom Note (LC 383) — do I have enough letters?
|
|
||||||
|
|
||||||
Count the magazine. Spend letters as the note needs them.
|
|
||||||
Counter subtraction does this in two lines.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from collections import Counter
|
|
||||||
|
|
||||||
def can_construct(ransom_note: str, magazine: str) -> bool:
|
|
||||||
need = Counter(ransom_note)
|
|
||||||
have = Counter(magazine)
|
|
||||||
return all(have[c] >= n for c, n in need.items())
|
|
||||||
```
|
|
||||||
|
|
||||||
## Top K Frequent (LC 347) — count, then rank
|
|
||||||
|
|
||||||
Two steps: count everything, then pick the k biggest counts.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
A["nums:<br/>1 1 1 2 2 3"] --> B["Count:<br/>1→3, 2→2, 3→1"]
|
|
||||||
B --> C["Buckets by count:<br/>slot 3: [1]<br/>slot 2: [2]<br/>slot 1: [3]"]
|
|
||||||
C --> D["Walk from top:<br/>[1, 2] ✓"]
|
|
||||||
|
|
||||||
style D fill:#2e7d32,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
Bucket sort trick: a number can appear at most n times.
|
|
||||||
So make n+1 buckets. Put each number in the bucket of its count.
|
|
||||||
Walk buckets from the top. O(n) — no sort, no heap.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from collections import Counter
|
|
||||||
|
|
||||||
def top_k_frequent(nums: list[int], k: int) -> list[int]:
|
|
||||||
count = Counter(nums)
|
|
||||||
buckets = [[] for _ in range(len(nums) + 1)]
|
|
||||||
for num, freq in count.items():
|
|
||||||
buckets[freq].append(num)
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for freq in range(len(buckets) - 1, 0, -1):
|
|
||||||
for num in buckets[freq]:
|
|
||||||
result.append(num)
|
|
||||||
if len(result) == k:
|
|
||||||
return result
|
|
||||||
```
|
|
||||||
|
|
||||||
## Say the trade-off out loud
|
|
||||||
|
|
||||||
Three ways to rank counts. Name them in the interview:
|
|
||||||
|
|
||||||
| Method | Time | When |
|
|
||||||
|---|---|---|
|
|
||||||
| Sort the counts | O(n log n) | Fine, simple |
|
|
||||||
| Heap of size k | O(n log k) | k small, streaming |
|
|
||||||
| Bucket sort | O(n) | Best — counts are bounded by n |
|
|
||||||
|
|
||||||
## Complexity
|
|
||||||
|
|
||||||
| | Time | Space |
|
|
||||||
|---|---|---|
|
|
||||||
| Build the map | O(n) | O(u) — unique items |
|
|
||||||
| Lookup | O(1) | — |
|
|
||||||
|
|
||||||
## Close-out ritual
|
|
||||||
|
|
||||||
Before you submit, say out loud:
|
|
||||||
1. Time and space complexity.
|
|
||||||
2. One edge case trace: empty string, k = number of unique items, or one element repeated n times.
|
|
||||||
|
|
||||||
## Plan problems
|
|
||||||
|
|
||||||
**A-set:** LC 217 · 242 · 1 · 49 · 347
|
|
||||||
**B-set:** LC 383 · 205 · 128 · 290
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
---
|
|
||||||
title: 'Two Pointers'
|
|
||||||
description: 'Converging/parallel index walk that prunes the O(n^2) pair space using order.'
|
|
||||||
---
|
|
||||||
|
|
||||||
## The idea
|
|
||||||
|
|
||||||
Checking every pair costs O(n²). That is n² boxes to open.
|
|
||||||
|
|
||||||
But if the array is **ordered**, you do not need every pair.
|
|
||||||
Put one pointer at each end. Look at the sum:
|
|
||||||
- Too small? Only a bigger left value can help. Move left in.
|
|
||||||
- Too big? Only a smaller right value can help. Move right in.
|
|
||||||
|
|
||||||
Each move kills a whole row of the pair space. O(n²) → O(n).
|
|
||||||
|
|
||||||
## The picture
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A["2 7 11 15 target 18<br/>L R"] --> B["2 + 15 = 17 < 18<br/>too small → move L right"]
|
|
||||||
B --> C["2 7 11 15<br/> L R"]
|
|
||||||
C --> D["7 + 15 = 22 > 18<br/>too big → move R left"]
|
|
||||||
D --> E["2 7 11 15<br/> L R"]
|
|
||||||
E --> F["7 + 11 = 18 ✓"]
|
|
||||||
|
|
||||||
style F fill:#2e7d32,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
## Not the same as binary search
|
|
||||||
|
|
||||||
Binary search **jumps** to the middle and discards half unseen — O(log n).
|
|
||||||
Two pointers **walks** — every element gets inspected once — O(n).
|
|
||||||
Both need order. Different moves.
|
|
||||||
|
|
||||||
## Two Sum II (LC 167) — the pure template
|
|
||||||
|
|
||||||
```python
|
|
||||||
def two_sum(numbers: list[int], target: int) -> list[int]:
|
|
||||||
lo, hi = 0, len(numbers) - 1
|
|
||||||
|
|
||||||
while lo < hi:
|
|
||||||
total = numbers[lo] + numbers[hi]
|
|
||||||
if total == target:
|
|
||||||
return [lo + 1, hi + 1]
|
|
||||||
if total < target:
|
|
||||||
lo += 1 # only a bigger value helps
|
|
||||||
else:
|
|
||||||
hi -= 1 # only a smaller value helps
|
|
||||||
```
|
|
||||||
|
|
||||||
## Container With Most Water (LC 11) — move the shorter wall
|
|
||||||
|
|
||||||
Water = width × shorter wall.
|
|
||||||
Moving the taller wall can never help — width shrinks, height cannot grow.
|
|
||||||
So always move the shorter one. Say this proof in the interview.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def max_area(height: list[int]) -> int:
|
|
||||||
lo, hi = 0, len(height) - 1
|
|
||||||
best = 0
|
|
||||||
|
|
||||||
while lo < hi:
|
|
||||||
best = max(best, (hi - lo) * min(height[lo], height[hi]))
|
|
||||||
if height[lo] < height[hi]:
|
|
||||||
lo += 1
|
|
||||||
else:
|
|
||||||
hi -= 1
|
|
||||||
return best
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3Sum (LC 15) — fix one, converge two
|
|
||||||
|
|
||||||
Sort first. Fix the smallest number. Now it is Two Sum II on the rest.
|
|
||||||
Skip duplicates at every level or you get repeat triplets.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def three_sum(nums: list[int]) -> list[list[int]]:
|
|
||||||
nums.sort()
|
|
||||||
result = []
|
|
||||||
|
|
||||||
for i in range(len(nums) - 2):
|
|
||||||
if i > 0 and nums[i] == nums[i - 1]:
|
|
||||||
continue # skip duplicate anchor
|
|
||||||
lo, hi = i + 1, len(nums) - 1
|
|
||||||
while lo < hi:
|
|
||||||
total = nums[i] + nums[lo] + nums[hi]
|
|
||||||
if total < 0:
|
|
||||||
lo += 1
|
|
||||||
elif total > 0:
|
|
||||||
hi -= 1
|
|
||||||
else:
|
|
||||||
result.append([nums[i], nums[lo], nums[hi]])
|
|
||||||
lo += 1
|
|
||||||
while lo < hi and nums[lo] == nums[lo - 1]:
|
|
||||||
lo += 1 # skip duplicate pair
|
|
||||||
return result
|
|
||||||
```
|
|
||||||
|
|
||||||
## Complexity
|
|
||||||
|
|
||||||
| | Time | Space |
|
|
||||||
|---|---|---|
|
|
||||||
| Converging pair | O(n) | O(1) |
|
|
||||||
| 3Sum | O(n²) — sort + n passes | O(1) extra |
|
|
||||||
|
|
||||||
## Close-out ritual
|
|
||||||
|
|
||||||
Before you submit, say out loud:
|
|
||||||
1. Time and space complexity.
|
|
||||||
2. One edge case trace: two elements, all duplicates, or no valid answer.
|
|
||||||
|
|
||||||
## Plan problems
|
|
||||||
|
|
||||||
**A-set:** LC 125 · 167 · 15 · 11 · 42
|
|
||||||
**B-set:** LC 392 · 977 · 189 · 80
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
---
|
|
||||||
title: 'Prefix Sum'
|
|
||||||
description: 'Precomputed cumulative state for O(1) range queries.'
|
|
||||||
---
|
|
||||||
|
|
||||||
## The idea
|
|
||||||
|
|
||||||
Precompute running totals once. Then any range sum costs O(1).
|
|
||||||
|
|
||||||
Like mile markers on a highway.
|
|
||||||
Distance from mile 30 to mile 80? Subtract: 80 − 30 = 50.
|
|
||||||
You do not re-drive the road.
|
|
||||||
|
|
||||||
**The formula:** `sum(i..j) = prefix[j + 1] - prefix[i]`
|
|
||||||
|
|
||||||
## The picture
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A["nums: 3 1 4 1 5"] --> B["prefix: 0 3 4 8 9 14"]
|
|
||||||
B --> C["sum(1..3) = prefix[4] − prefix[1]<br/>= 9 − 3 = 6"]
|
|
||||||
C --> D["Check: 1 + 4 + 1 = 6 ✓"]
|
|
||||||
|
|
||||||
style D fill:#2e7d32,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
The leading 0 matters. It makes ranges that start at index 0 work with no special case.
|
|
||||||
|
|
||||||
## Range Sum Query (LC 303)
|
|
||||||
|
|
||||||
Pay O(n) once at build time. Answer every query in O(1).
|
|
||||||
|
|
||||||
```python
|
|
||||||
from itertools import accumulate
|
|
||||||
|
|
||||||
class NumArray:
|
|
||||||
def __init__(self, nums: list[int]):
|
|
||||||
self.prefix = [0] + list(accumulate(nums))
|
|
||||||
|
|
||||||
def sum_range(self, left: int, right: int) -> int:
|
|
||||||
return self.prefix[right + 1] - self.prefix[left]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Prefix + Hashmap (LC 560 — Subarray Sum Equals K)
|
|
||||||
|
|
||||||
The signature trick of this topic. The question flips:
|
|
||||||
"Which subarrays sum to k?" becomes
|
|
||||||
"At each point, how many *earlier* prefixes equal `current − k`?"
|
|
||||||
|
|
||||||
Because: if `prefix[j] − prefix[i] = k`, the slice between them sums to k.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
A["Walk the array,<br/>carry running sum"] --> B["Ask the map:<br/>seen sum − k before?"]
|
|
||||||
B --> C["Yes, m times →<br/>add m to answer"]
|
|
||||||
B --> D["Record current sum<br/>in the map"]
|
|
||||||
D --> A
|
|
||||||
|
|
||||||
style C fill:#2e7d32,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
def subarray_sum(nums: list[int], k: int) -> int:
|
|
||||||
count = 0
|
|
||||||
current = 0
|
|
||||||
seen = defaultdict(int)
|
|
||||||
seen[0] = 1 # empty prefix — subarrays that start at 0
|
|
||||||
|
|
||||||
for n in nums:
|
|
||||||
current += n
|
|
||||||
count += seen[current - k] # ask first
|
|
||||||
seen[current] += 1 # record after
|
|
||||||
return count
|
|
||||||
```
|
|
||||||
|
|
||||||
**Order matters.** Ask before you record, or a subarray of length 0 counts itself when k = 0.
|
|
||||||
|
|
||||||
## Product variant (LC 238 — Product of Array Except Self)
|
|
||||||
|
|
||||||
Same idea, two directions. Prefix products from the left, suffix products from the right.
|
|
||||||
`answer[i] = left[i] × right[i]` — everything except i.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def product_except_self(nums: list[int]) -> list[int]:
|
|
||||||
n = len(nums)
|
|
||||||
result = [1] * n
|
|
||||||
|
|
||||||
left = 1
|
|
||||||
for i in range(n):
|
|
||||||
result[i] = left
|
|
||||||
left *= nums[i]
|
|
||||||
|
|
||||||
right = 1
|
|
||||||
for i in range(n - 1, -1, -1):
|
|
||||||
result[i] *= right
|
|
||||||
right *= nums[i]
|
|
||||||
return result
|
|
||||||
```
|
|
||||||
|
|
||||||
## Complexity
|
|
||||||
|
|
||||||
| | Time | Space |
|
|
||||||
|---|---|---|
|
|
||||||
| Build | O(n) | O(n) |
|
|
||||||
| Each range query | O(1) | — |
|
|
||||||
| Prefix + hashmap | O(n) one pass | O(n) |
|
|
||||||
|
|
||||||
## Close-out ritual
|
|
||||||
|
|
||||||
Before you submit, say out loud:
|
|
||||||
1. Time and space complexity.
|
|
||||||
2. One edge case trace: range starting at 0, k = 0 with zeros in the array, or negative numbers (this is why sliding window fails and prefix + hashmap wins).
|
|
||||||
|
|
||||||
## Plan problems
|
|
||||||
|
|
||||||
**A-set:** LC 303 · 238 · 560
|
|
||||||
**B-set:** LC 525 · 974
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
---
|
|
||||||
title: 'Sliding Window'
|
|
||||||
description: 'Two pointers plus incremental state between them.'
|
|
||||||
---
|
|
||||||
|
|
||||||
## The idea
|
|
||||||
|
|
||||||
Two pointers that move the **same direction**, carrying state between them.
|
|
||||||
|
|
||||||
Think of a caterpillar:
|
|
||||||
- The head crawls forward and eats (expand right).
|
|
||||||
- When the rule breaks, the tail pulls in until the rule holds again (contract left).
|
|
||||||
|
|
||||||
The window is always a **contiguous** slice. You never rebuild it — you update the carried state as edges move. That is what makes it O(n).
|
|
||||||
|
|
||||||
**The recipe:**
|
|
||||||
1. Expand right. Add the new element to your state.
|
|
||||||
2. Rule broken? Contract left until it holds.
|
|
||||||
3. Record the best window. Repeat.
|
|
||||||
|
|
||||||
## The picture
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A["a b c a b c<br/>[a] window = a"] --> B["[a b] expand → ab"]
|
|
||||||
B --> C["[a b c] expand → abc, best = 3"]
|
|
||||||
C --> D["a [b c a] 'a' repeats → contract, then expand"]
|
|
||||||
D --> E["a b [c a b] keep sliding, best stays 3"]
|
|
||||||
|
|
||||||
style C fill:#2e7d32,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
## Longest Substring Without Repeating (LC 3)
|
|
||||||
|
|
||||||
State = a set of chars in the window. Repeat found? Shrink from the left until it is gone.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def length_of_longest_substring(s: str) -> int:
|
|
||||||
window = set()
|
|
||||||
left = 0
|
|
||||||
best = 0
|
|
||||||
|
|
||||||
for right, c in enumerate(s):
|
|
||||||
while c in window: # rule broken
|
|
||||||
window.remove(s[left]) # contract left
|
|
||||||
left += 1
|
|
||||||
window.add(c) # expand right
|
|
||||||
best = max(best, right - left + 1)
|
|
||||||
return best
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Time to Buy and Sell (LC 121) — the hidden window
|
|
||||||
|
|
||||||
Looks like a stock problem. It is a window problem.
|
|
||||||
Left = cheapest buy so far. Right = today. Carry one number: the min price.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def max_profit(prices: list[int]) -> int:
|
|
||||||
min_price = prices[0]
|
|
||||||
best = 0
|
|
||||||
|
|
||||||
for p in prices[1:]:
|
|
||||||
best = max(best, p - min_price)
|
|
||||||
min_price = min(min_price, p)
|
|
||||||
return best
|
|
||||||
```
|
|
||||||
|
|
||||||
## Character Replacement (LC 424) — window with a budget
|
|
||||||
|
|
||||||
Rule: window is valid if `window size − count of top letter ≤ k`.
|
|
||||||
That many replacements fix the window. Carry a frequency map.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
def character_replacement(s: str, k: int) -> int:
|
|
||||||
count = defaultdict(int)
|
|
||||||
left = 0
|
|
||||||
best = 0
|
|
||||||
top = 0 # highest letter count seen
|
|
||||||
|
|
||||||
for right, c in enumerate(s):
|
|
||||||
count[c] += 1
|
|
||||||
top = max(top, count[c])
|
|
||||||
if (right - left + 1) - top > k: # over budget
|
|
||||||
count[s[left]] -= 1 # contract exactly one step
|
|
||||||
left += 1
|
|
||||||
best = max(best, right - left + 1)
|
|
||||||
return best
|
|
||||||
```
|
|
||||||
|
|
||||||
## When the window fails
|
|
||||||
|
|
||||||
Sliding window needs a one-way rule: **growing can only hurt, shrinking can only help.**
|
|
||||||
Negative numbers break this — a bigger window can flip from bad to good.
|
|
||||||
That is when you reach for prefix sum + hashmap (LC 560) instead.
|
|
||||||
Say this decision out loud in the interview.
|
|
||||||
|
|
||||||
## Complexity
|
|
||||||
|
|
||||||
| | Time | Space |
|
|
||||||
|---|---|---|
|
|
||||||
| All patterns above | O(n) — each index enters and leaves once | O(1) or O(alphabet) |
|
|
||||||
|
|
||||||
## Close-out ritual
|
|
||||||
|
|
||||||
Before you submit, say out loud:
|
|
||||||
1. Time and space complexity — note each element enters and leaves the window at most once.
|
|
||||||
2. One edge case trace: empty string, all same char, or k larger than the string.
|
|
||||||
|
|
||||||
## Plan problems
|
|
||||||
|
|
||||||
**A-set:** LC 121 · 3 · 424 · 567 · 76
|
|
||||||
**B-set:** LC 209 · 1004 · 643
|
|
||||||
|
|
||||||
<YouTube id="QGNAVBn1_bc" title="Sliding Window" />
|
|
||||||
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
---
|
|
||||||
title: 'Binary Search'
|
|
||||||
description: 'In algorithms, arrays and strings are very similar. They are both ordered collections of elements. The difference is that arrays are mutable, while strings are immutable.'
|
|
||||||
---
|
|
||||||
|
|
||||||
## The idea
|
|
||||||
|
|
||||||
You look for a name in a phone book. You do not start at page 1.
|
|
||||||
You open the middle. Too far? Go left. Not far enough? Go right.
|
|
||||||
Each step throws away half the book.
|
|
||||||
|
|
||||||
100 items → 7 steps. 4 billion items → 32 steps.
|
|
||||||
|
|
||||||
**The core rule:** ask a yes/no question that flips exactly once.
|
|
||||||
Everything left of the flip is "no". Everything right is "yes".
|
|
||||||
Binary search finds the flip point.
|
|
||||||
|
|
||||||
## The picture
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A["Array: 1 3 5 7 9 11 13<br/>Target: 9"] --> B["mid = 7<br/>7 < 9 → discard left half"]
|
|
||||||
B --> C["Array: 9 11 13<br/>mid = 11<br/>11 > 9 → discard right half"]
|
|
||||||
C --> D["Array: 9<br/>mid = 9<br/>Found ✓"]
|
|
||||||
|
|
||||||
style D fill:#2e7d32,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
## The template
|
|
||||||
|
|
||||||
```python
|
|
||||||
def binary_search(nums: list[int], target: int) -> int:
|
|
||||||
lo, hi = 0, len(nums) - 1
|
|
||||||
|
|
||||||
while lo <= hi:
|
|
||||||
mid = (lo + hi) // 2
|
|
||||||
if nums[mid] == target:
|
|
||||||
return mid
|
|
||||||
if nums[mid] < target:
|
|
||||||
lo = mid + 1
|
|
||||||
else:
|
|
||||||
hi = mid - 1
|
|
||||||
return -1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Search the answer space (LC 875 — Koko Eating Bananas)
|
|
||||||
|
|
||||||
The array does not need to exist. You can binary search over *possible answers*.
|
|
||||||
Ask: "does speed k work?" Slow speeds fail. Fast speeds work.
|
|
||||||
The answer flips once. Find the flip.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
A["k=1<br/>NO"] --> B["k=2<br/>NO"] --> C["k=3<br/>NO"] --> D["k=4<br/>YES ← answer"] --> E["k=5<br/>YES"] --> F["k=6<br/>YES"]
|
|
||||||
|
|
||||||
style D fill:#2e7d32,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
import math
|
|
||||||
|
|
||||||
def min_eating_speed(piles: list[int], h: int) -> int:
|
|
||||||
def k_works(k: int) -> bool:
|
|
||||||
return sum(math.ceil(p / k) for p in piles) <= h
|
|
||||||
|
|
||||||
lo, hi = 1, max(piles)
|
|
||||||
|
|
||||||
while lo < hi:
|
|
||||||
mid = (lo + hi) // 2
|
|
||||||
if k_works(mid):
|
|
||||||
hi = mid # mid works — keep it, look left
|
|
||||||
else:
|
|
||||||
lo = mid + 1 # mid fails — discard it
|
|
||||||
return lo
|
|
||||||
```
|
|
||||||
|
|
||||||
## Not the same as two pointers
|
|
||||||
|
|
||||||
Two pointers **walks** — it inspects every element it passes. O(n).
|
|
||||||
Binary search **jumps** — it inspects one midpoint and discards half unseen. O(log n).
|
|
||||||
|
|
||||||
## Complexity
|
|
||||||
|
|
||||||
| | Time | Space |
|
|
||||||
|---|---|---|
|
|
||||||
| Sorted array | O(log n) | O(1) |
|
|
||||||
| Answer space | O(n log m) | O(1) |
|
|
||||||
|
|
||||||
n = array size, m = answer range.
|
|
||||||
|
|
||||||
## Close-out ritual
|
|
||||||
|
|
||||||
Before you submit, say out loud:
|
|
||||||
1. Time and space complexity.
|
|
||||||
2. One edge case trace: empty array, one element, or target not present.
|
|
||||||
|
|
||||||
## Plan problems
|
|
||||||
|
|
||||||
**A-set:** LC 704 · 74 · 875 · 33 · 153
|
|
||||||
**B-set:** LC 35 · 162 · 34 · 4 ⭐
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
---
|
|
||||||
title: Breadth-First Search
|
|
||||||
---
|
|
||||||
|
|
||||||
# Breadth-First Search (BFS)
|
|
||||||
|
|
||||||
## The idea
|
|
||||||
|
|
||||||
You want a mango seller. You ask your friends first.
|
|
||||||
No luck? You ask friends-of-friends. Then their friends.
|
|
||||||
You search in **rings**, closest first.
|
|
||||||
|
|
||||||
That is BFS. It answers two questions:
|
|
||||||
1. **Is there a path** from A to B?
|
|
||||||
2. **What is the shortest path?** (unweighted — fewest hops)
|
|
||||||
|
|
||||||
BFS is *always* shortest path on unweighted graphs. First time you reach a node = fewest possible steps.
|
|
||||||
|
|
||||||
## The picture
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
YOU((You)) --> A((Alice))
|
|
||||||
YOU --> B((Bob))
|
|
||||||
A --> C((Claire))
|
|
||||||
A --> D((Dan))
|
|
||||||
B --> E((Eve))
|
|
||||||
E --> M((Mango seller ✓))
|
|
||||||
|
|
||||||
style YOU fill:#1565c0,color:#fff
|
|
||||||
style A fill:#6a1b9a,color:#fff
|
|
||||||
style B fill:#6a1b9a,color:#fff
|
|
||||||
style C fill:#ef6c00,color:#fff
|
|
||||||
style D fill:#ef6c00,color:#fff
|
|
||||||
style E fill:#ef6c00,color:#fff
|
|
||||||
style M fill:#2e7d32,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
Blue = start. Purple = ring 1. Orange = ring 2. Green = found at ring 3.
|
|
||||||
|
|
||||||
## The two tools
|
|
||||||
|
|
||||||
1. **Queue** — first in, first out. This keeps the rings in order.
|
|
||||||
2. **Visited set** — never check the same node twice. Without it, cycles loop forever.
|
|
||||||
|
|
||||||
## The template
|
|
||||||
|
|
||||||
```python
|
|
||||||
from collections import deque
|
|
||||||
|
|
||||||
def bfs(start, graph: dict) -> None:
|
|
||||||
queue = deque([start])
|
|
||||||
visited = {start}
|
|
||||||
|
|
||||||
while queue:
|
|
||||||
level_size = len(queue) # snapshot = one full ring
|
|
||||||
for _ in range(level_size):
|
|
||||||
node = queue.popleft()
|
|
||||||
# process node here
|
|
||||||
for nxt in graph.get(node, []):
|
|
||||||
if nxt not in visited:
|
|
||||||
visited.add(nxt) # mark WHEN queued, not when popped
|
|
||||||
queue.append(nxt)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Multi-source BFS (LC 994 — Rotting Oranges)
|
|
||||||
|
|
||||||
Start with *all* rotten oranges in the queue at once.
|
|
||||||
Each ring = one minute of rot spreading.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from collections import deque
|
|
||||||
|
|
||||||
def oranges_rotting(grid: list[list[int]]) -> int:
|
|
||||||
rows, cols = len(grid), len(grid[0])
|
|
||||||
queue = deque()
|
|
||||||
fresh = 0
|
|
||||||
|
|
||||||
for r in range(rows):
|
|
||||||
for c in range(cols):
|
|
||||||
if grid[r][c] == 2:
|
|
||||||
queue.append((r, c))
|
|
||||||
elif grid[r][c] == 1:
|
|
||||||
fresh += 1
|
|
||||||
|
|
||||||
minutes = 0
|
|
||||||
dirs = [(1, 0), (-1, 0), (0, 1), (0, -1)]
|
|
||||||
|
|
||||||
while queue and fresh > 0:
|
|
||||||
for _ in range(len(queue)):
|
|
||||||
r, c = queue.popleft()
|
|
||||||
for dr, dc in dirs:
|
|
||||||
nr, nc = r + dr, c + dc
|
|
||||||
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
|
|
||||||
grid[nr][nc] = 2
|
|
||||||
fresh -= 1
|
|
||||||
queue.append((nr, nc))
|
|
||||||
minutes += 1
|
|
||||||
|
|
||||||
return minutes if fresh == 0 else -1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Complexity
|
|
||||||
|
|
||||||
| | Time | Space |
|
|
||||||
|---|---|---|
|
|
||||||
| BFS | O(V + E) | O(V) |
|
|
||||||
|
|
||||||
V = nodes, E = edges. On a grid: O(rows × cols).
|
|
||||||
|
|
||||||
## Close-out ritual
|
|
||||||
|
|
||||||
Before you submit, say out loud:
|
|
||||||
1. Time and space complexity.
|
|
||||||
2. One edge case trace: empty graph, start = target, or unreachable target.
|
|
||||||
|
|
||||||
## Plan problems
|
|
||||||
|
|
||||||
**Tree BFS A-set:** LC 102 · 199 · 1448
|
|
||||||
**Graph BFS A-set:** LC 994 · 127
|
|
||||||
**B-set:** LC 103 · 637 · 909 · 433 · 286
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
---
|
|
||||||
title: Depth-First Search
|
|
||||||
---
|
|
||||||
|
|
||||||
# Depth-First Search (DFS)
|
|
||||||
|
|
||||||
## The idea
|
|
||||||
|
|
||||||
BFS searches in rings. DFS picks one path and follows it to the end.
|
|
||||||
Dead end? Back up one step. Try the next branch.
|
|
||||||
|
|
||||||
Like exploring a maze with one hand on the wall.
|
|
||||||
|
|
||||||
Use DFS when:
|
|
||||||
- The answer **composes from subtrees** (depth, path sums, subtree checks).
|
|
||||||
- You need **all** of a region (islands, flood fill, connected components).
|
|
||||||
|
|
||||||
Use BFS when you need **shortest path**. DFS does not give shortest path.
|
|
||||||
|
|
||||||
## The picture
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A((1)) --> B((2))
|
|
||||||
A --> C((5))
|
|
||||||
B --> D((3))
|
|
||||||
B --> E((4))
|
|
||||||
C --> F((6))
|
|
||||||
|
|
||||||
style A fill:#1565c0,color:#fff
|
|
||||||
style B fill:#1565c0,color:#fff
|
|
||||||
style D fill:#1565c0,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
Visit order: 1 → 2 → 3 (bottom!) → back up → 4 → back up → 5 → 6.
|
|
||||||
Numbers show the order. DFS goes **deep before wide**.
|
|
||||||
|
|
||||||
## Tree DFS — answers flow up (LC 104 — Max Depth)
|
|
||||||
|
|
||||||
The pattern: ask each child a question. Combine the answers. Return up.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def max_depth(root) -> int:
|
|
||||||
if root is None:
|
|
||||||
return 0 # base case
|
|
||||||
left = max_depth(root.left)
|
|
||||||
right = max_depth(root.right)
|
|
||||||
return 1 + max(left, right) # combine + return up
|
|
||||||
```
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart BT
|
|
||||||
D["leaf returns 1"] --> B["returns 1 + max(1,1) = 2"]
|
|
||||||
E["leaf returns 1"] --> B
|
|
||||||
B --> A["root returns 1 + max(2,1) = 3"]
|
|
||||||
C["leaf returns 1"] --> A
|
|
||||||
|
|
||||||
style A fill:#2e7d32,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
## Graph DFS — add a visited set (LC 200 — Number of Islands)
|
|
||||||
|
|
||||||
Graphs have cycles. Trees do not.
|
|
||||||
So graph DFS needs one new thing: **mark where you have been.**
|
|
||||||
On a grid, sink the land as you visit it — the grid *is* the visited set.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def num_islands(grid: list[list[str]]) -> int:
|
|
||||||
rows, cols = len(grid), len(grid[0])
|
|
||||||
count = 0
|
|
||||||
|
|
||||||
def sink(r: int, c: int) -> None:
|
|
||||||
if r < 0 or r >= rows or c < 0 or c >= cols:
|
|
||||||
return
|
|
||||||
if grid[r][c] != "1":
|
|
||||||
return
|
|
||||||
grid[r][c] = "0" # mark visited
|
|
||||||
sink(r + 1, c)
|
|
||||||
sink(r - 1, c)
|
|
||||||
sink(r, c + 1)
|
|
||||||
sink(r, c - 1)
|
|
||||||
|
|
||||||
for r in range(rows):
|
|
||||||
for c in range(cols):
|
|
||||||
if grid[r][c] == "1":
|
|
||||||
count += 1
|
|
||||||
sink(r, c) # eat the whole island
|
|
||||||
return count
|
|
||||||
```
|
|
||||||
|
|
||||||
## Complexity
|
|
||||||
|
|
||||||
| | Time | Space |
|
|
||||||
|---|---|---|
|
|
||||||
| Tree DFS | O(n) | O(h) — call stack, h = height |
|
|
||||||
| Graph DFS | O(V + E) | O(V) |
|
|
||||||
|
|
||||||
Worst case h = n (a stick-shaped tree).
|
|
||||||
|
|
||||||
## Close-out ritual
|
|
||||||
|
|
||||||
Before you submit, say out loud:
|
|
||||||
1. Time and space complexity — name the call stack.
|
|
||||||
2. One edge case trace: null root, single node, or all-water grid.
|
|
||||||
|
|
||||||
## Plan problems
|
|
||||||
|
|
||||||
**Tree DFS A-set:** LC 226 · 104 · 100 · 572 · 236 · 124 ⭐
|
|
||||||
**Graph DFS A-set:** LC 200 · 695 · 133 · 417 · 130
|
|
||||||
**B-set:** LC 101 · 112 · 129 · 543 · 399 · 261
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
---
|
|
||||||
title: Dynamic Programming (1-D)
|
|
||||||
---
|
|
||||||
|
|
||||||
# Dynamic Programming (1-D)
|
|
||||||
|
|
||||||
## The idea
|
|
||||||
|
|
||||||
DP is backtracking with a memory.
|
|
||||||
|
|
||||||
Backtracking tries every path. Many paths repeat the same subproblem.
|
|
||||||
DP solves each subproblem **once**, saves the answer, and reuses it.
|
|
||||||
|
|
||||||
Grokking's rule: break the big problem into small problems.
|
|
||||||
Solve the small ones first. Build up.
|
|
||||||
|
|
||||||
**The two things you must find:**
|
|
||||||
1. **The state** — what does `dp[i]` mean, in one sentence?
|
|
||||||
2. **The recurrence** — how does `dp[i]` come from earlier answers?
|
|
||||||
|
|
||||||
## The picture — why memo matters (Climbing Stairs)
|
|
||||||
|
|
||||||
Without memo, `f(5)` computes `f(3)` twice and `f(2)` three times:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A["f(5)"] --> B["f(4)"]
|
|
||||||
A --> C["f(3)"]
|
|
||||||
B --> D["f(3)"]
|
|
||||||
B --> E["f(2)"]
|
|
||||||
C --> F["f(2)"]
|
|
||||||
C --> G["f(1)"]
|
|
||||||
D --> H["f(2)"]
|
|
||||||
D --> I["f(1)"]
|
|
||||||
|
|
||||||
style C fill:#c62828,color:#fff
|
|
||||||
style D fill:#c62828,color:#fff
|
|
||||||
style E fill:#ef6c00,color:#fff
|
|
||||||
style F fill:#ef6c00,color:#fff
|
|
||||||
style H fill:#ef6c00,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
Red and orange = repeated work. Memo turns the tree into a straight line: O(2ⁿ) → O(n).
|
|
||||||
|
|
||||||
## Climbing Stairs (LC 70)
|
|
||||||
|
|
||||||
State: `dp[i]` = ways to reach step i.
|
|
||||||
Recurrence: you arrive from one step below or two below.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def climb_stairs(n: int) -> int:
|
|
||||||
if n <= 2:
|
|
||||||
return n
|
|
||||||
prev2, prev1 = 1, 2
|
|
||||||
for _ in range(3, n + 1):
|
|
||||||
prev2, prev1 = prev1, prev1 + prev2
|
|
||||||
return prev1
|
|
||||||
```
|
|
||||||
|
|
||||||
## House Robber (LC 198)
|
|
||||||
|
|
||||||
State: `dp[i]` = max loot using houses 0..i.
|
|
||||||
Recurrence at each house: **rob it** (skip the neighbor) or **skip it**.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
A["House i"] --> B["Rob:<br/>nums[i] + dp[i-2]"]
|
|
||||||
A --> C["Skip:<br/>dp[i-1]"]
|
|
||||||
B --> D["dp[i] = max of both"]
|
|
||||||
C --> D
|
|
||||||
|
|
||||||
style D fill:#2e7d32,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
def rob(nums: list[int]) -> int:
|
|
||||||
skip = take = 0
|
|
||||||
for n in nums:
|
|
||||||
skip, take = max(skip, take), skip + n # skip it / rob it
|
|
||||||
return max(skip, take)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Coin Change (LC 322)
|
|
||||||
|
|
||||||
State: `dp[a]` = fewest coins to make amount a.
|
|
||||||
Recurrence: try each coin, take the best.
|
|
||||||
|
|
||||||
```python
|
|
||||||
import math
|
|
||||||
|
|
||||||
def coin_change(coins: list[int], amount: int) -> int:
|
|
||||||
dp = [math.inf] * (amount + 1)
|
|
||||||
dp[0] = 0
|
|
||||||
for a in range(1, amount + 1):
|
|
||||||
for c in coins:
|
|
||||||
if c <= a:
|
|
||||||
dp[a] = min(dp[a], dp[a - c] + 1)
|
|
||||||
return dp[amount] if dp[amount] != math.inf else -1
|
|
||||||
```
|
|
||||||
|
|
||||||
## The interview script
|
|
||||||
|
|
||||||
Say these four lines out loud, in order:
|
|
||||||
1. "The brute force is backtracking — try everything."
|
|
||||||
2. "Subproblems overlap, so I will memoize."
|
|
||||||
3. "State: dp[i] means ___." (one sentence)
|
|
||||||
4. "Recurrence: dp[i] = ___."
|
|
||||||
|
|
||||||
## Complexity
|
|
||||||
|
|
||||||
| | Time | Space |
|
|
||||||
|---|---|---|
|
|
||||||
| Climbing Stairs / House Robber | O(n) | O(1) with rolling vars |
|
|
||||||
| Coin Change | O(amount × coins) | O(amount) |
|
|
||||||
|
|
||||||
## Close-out ritual
|
|
||||||
|
|
||||||
Before you submit, say out loud:
|
|
||||||
1. Time and space complexity.
|
|
||||||
2. One edge case trace: n = 0 or 1, empty array, or unreachable amount (return -1).
|
|
||||||
|
|
||||||
## Plan problems
|
|
||||||
|
|
||||||
**A-set:** LC 70 · 746 · 198 · 213 · 322 · 300
|
|
||||||
**B-set:** LC 139 · 91 · 647
|
|
||||||
|
|
||||||
Note: DP matters only if Google or Databricks advance to later rounds. Your fintech targets skew toward simulation, hashmap, and heap.
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
---
|
|
||||||
title: Greedy Algorithms
|
|
||||||
---
|
|
||||||
|
|
||||||
# Greedy Algorithms
|
|
||||||
|
|
||||||
## The idea
|
|
||||||
|
|
||||||
At each step, take the best move *right now*. Never look back.
|
|
||||||
|
|
||||||
Grokking's example: the classroom problem.
|
|
||||||
You want the most classes in one room. Which do you pick?
|
|
||||||
**Always pick the class that ends first.** It leaves the most room for the rest.
|
|
||||||
|
|
||||||
Greedy works only when the local best is *provably* the global best.
|
|
||||||
When it works, it beats DP — no memo table, no lookback, often O(n).
|
|
||||||
|
|
||||||
## The picture
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
gantt
|
|
||||||
dateFormat HH:mm
|
|
||||||
axisFormat %H:%M
|
|
||||||
section Pick ✓
|
|
||||||
Art (ends first) :done, 09:00, 45m
|
|
||||||
Math (ends next) :done, 10:00, 60m
|
|
||||||
Music :done, 11:00, 60m
|
|
||||||
section Skip ✗
|
|
||||||
English (overlaps Art) :crit, 09:30, 60m
|
|
||||||
CS (overlaps Math) :crit, 10:30, 60m
|
|
||||||
```
|
|
||||||
|
|
||||||
Pick by earliest end time. Skip anything that overlaps a pick.
|
|
||||||
|
|
||||||
## Kadane's — DP squeezed to one variable (LC 53 — Maximum Subarray)
|
|
||||||
|
|
||||||
The greedy question at each element:
|
|
||||||
"Do I extend the running sum, or start fresh here?"
|
|
||||||
If the running sum is negative, it only hurts. Drop it.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def max_sub_array(nums: list[int]) -> int:
|
|
||||||
best = current = nums[0]
|
|
||||||
|
|
||||||
for n in nums[1:]:
|
|
||||||
current = max(n, current + n) # extend or restart
|
|
||||||
best = max(best, current)
|
|
||||||
return best
|
|
||||||
```
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
A["-2"] --> B["1<br/>restart"] --> C["-2<br/>extend"] --> D["4<br/>restart"] --> E["3<br/>extend"] --> F["5<br/>extend"] --> G["6<br/>extend ← best"]
|
|
||||||
|
|
||||||
style G fill:#2e7d32,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
## Jump Game (LC 55) — track the farthest reach
|
|
||||||
|
|
||||||
One pass. Keep the farthest index you can touch.
|
|
||||||
If your position ever passes the reach, you are stuck.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def can_jump(nums: list[int]) -> bool:
|
|
||||||
reach = 0
|
|
||||||
for i, n in enumerate(nums):
|
|
||||||
if i > reach:
|
|
||||||
return False # stuck
|
|
||||||
reach = max(reach, i + n)
|
|
||||||
return True
|
|
||||||
```
|
|
||||||
|
|
||||||
## How to justify greedy in an interview
|
|
||||||
|
|
||||||
Use an **exchange argument**: "If an optimal answer made a different choice here, I could swap in my greedy choice without making it worse."
|
|
||||||
Say this out loud. It is the difference between guessing and proving.
|
|
||||||
|
|
||||||
## Complexity
|
|
||||||
|
|
||||||
| | Time | Space |
|
|
||||||
|---|---|---|
|
|
||||||
| One-pass greedy (Kadane, Jump) | O(n) | O(1) |
|
|
||||||
| Sort-then-commit (intervals) | O(n log n) | O(1) |
|
|
||||||
|
|
||||||
## Close-out ritual
|
|
||||||
|
|
||||||
Before you submit, say out loud:
|
|
||||||
1. Time and space complexity.
|
|
||||||
2. One edge case trace: all-negative array, single element, or zero at index 0.
|
|
||||||
|
|
||||||
## Plan problems
|
|
||||||
|
|
||||||
**A-set:** LC 53 · 55 · 45 · 134
|
|
||||||
**B-set:** LC 122 · 918 · 763
|
|
||||||
**Feeds into topic 24 (Intervals):** LC 57 · 56 · 435 · 253
|
|
||||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user