mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -28,7 +28,14 @@ jobs:
|
|||||||
- uses: oven-sh/setup-bun@v2
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
# No install step: the script only uses Bun builtins + fetch.
|
# No install step: the script only uses Bun builtins + fetch.
|
||||||
- run: bun scripts/close-solved.ts
|
#
|
||||||
|
# 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:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
DRY_RUN: ${{ inputs.dry_run }}
|
DRY_RUN: ${{ inputs.dry_run }}
|
||||||
|
SRS_ADMIN_KEY: ${{ secrets.SRS_ADMIN_KEY }}
|
||||||
@@ -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,99 @@
|
|||||||
|
# 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)
|
||||||
|
bun run submit # submit ONE solution to LeetCode (same picker as test)
|
||||||
|
bun run sync # work/ → apps/docs/content/ pages
|
||||||
|
bun run dev|build # Blume docs site (runs in apps/docs/)
|
||||||
|
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
|
||||||
|
bun run close-topics -- --dry-run
|
||||||
|
# close-solved also pushes the solved set to the Worker; point it at a local
|
||||||
|
# one and nothing production is touched:
|
||||||
|
SRS_API=http://localhost:8787 SRS_ADMIN_KEY=$LINK_KEY bun run close-solved -- --dry-run
|
||||||
|
bun run api:dev # wrangler dev on :8787 (local D1); api:test = DST guard tests
|
||||||
|
bun run api:deploy # deploy the Worker by hand (CI also deploys on main pushes)
|
||||||
|
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
||||||
|
"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,111 +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>
|
||||||
|
|
||||||
## Campaign
|
<table align="center">
|
||||||
|
<tr>
|
||||||
<sub>8-week interview-prep fight camp. Each phase is a milestone; each topic an issue; each problem a sub-issue. Core count reflects closed sub-issues.</sub>
|
<td colspan="2" align="center"><img alt="phase progress" width="100%" src="https://srs-api.prdlk.workers.dev/chart/progress.svg" /></td>
|
||||||
|
</tr>
|
||||||
<table>
|
<tr>
|
||||||
<thead>
|
<td align="center" width="53%"><img alt="SRS ladder" width="100%" src="https://srs-api.prdlk.workers.dev/chart/ladder.svg" /></td>
|
||||||
<tr>
|
<td align="center" width="47%"><img alt="attempt heatmap" width="100%" src="https://srs-api.prdlk.workers.dev/chart/heatmap.svg" /></td>
|
||||||
<th align="center">Phase</th>
|
</tr>
|
||||||
<th align="center">Wk</th>
|
|
||||||
<th align="left">Date</th>
|
|
||||||
<th align="left">Topic</th>
|
|
||||||
<th align="center">Core</th>
|
|
||||||
<th align="left">Core problems</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td rowspan="7" align="center"><strong>I</strong><br><sub>Linear Structures</sub></td>
|
|
||||||
<td align="center">1</td><td>Mon Aug 17</td><td><a href="https://github.com/prdlk/leetcode/issues/3"><strong>01</strong> · Hash-Based Lookup</a></td><td align="center">5/5</td><td><a href="https://leetcode.com/problems/contains-duplicate/">217</a> · <a href="https://leetcode.com/problems/valid-anagram/">242</a> · <a href="https://leetcode.com/problems/two-sum/">1</a> · <a href="https://leetcode.com/problems/group-anagrams/">49</a> · <a href="https://leetcode.com/problems/top-k-frequent-elements/">347</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">1</td><td>Tue Aug 18</td><td><a href="https://github.com/prdlk/leetcode/issues/4"><strong>02</strong> · Prefix Sum</a></td><td align="center">3/3</td><td><a href="https://leetcode.com/problems/range-sum-query-immutable/">303</a> · <a href="https://leetcode.com/problems/product-of-array-except-self/">238</a> · <a href="https://leetcode.com/problems/subarray-sum-equals-k/">560</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">2</td><td>Mon Aug 24</td><td><a href="https://github.com/prdlk/leetcode/issues/5"><strong>03</strong> · Two Pointers</a></td><td align="center">2/4</td><td><a href="https://leetcode.com/problems/valid-palindrome/">125</a> · <a href="https://leetcode.com/problems/two-sum-ii-input-array-is-sorted/">167</a> · <a href="https://leetcode.com/problems/3sum/">15</a> · <a href="https://leetcode.com/problems/container-with-most-water/">11</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">2</td><td>Tue Aug 25</td><td><a href="https://github.com/prdlk/leetcode/issues/6"><strong>04</strong> · Sliding Window</a></td><td align="center">1/4</td><td><a href="https://leetcode.com/problems/best-time-to-buy-and-sell-stock/">121</a> · <a href="https://leetcode.com/problems/longest-substring-without-repeating-characters/">3</a> · <a href="https://leetcode.com/problems/longest-repeating-character-replacement/">424</a> · <a href="https://leetcode.com/problems/permutation-in-string/">567</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">2</td><td>Wed Aug 26</td><td><a href="https://github.com/prdlk/leetcode/issues/7"><strong>05</strong> · Binary Search</a></td><td align="center">3/5</td><td><a href="https://leetcode.com/problems/binary-search/">704</a> · <a href="https://leetcode.com/problems/koko-eating-bananas/">875</a> · <a href="https://leetcode.com/problems/search-in-rotated-sorted-array/">33</a> · <a href="https://leetcode.com/problems/search-a-2d-matrix/">74</a> · <a href="https://leetcode.com/problems/find-minimum-in-rotated-sorted-array/">153</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">2</td><td>Thu Aug 27</td><td><a href="https://github.com/prdlk/leetcode/issues/8"><strong>06</strong> · Stack</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/valid-parentheses/">20</a> · <a href="https://leetcode.com/problems/min-stack/">155</a> · <a href="https://leetcode.com/problems/evaluate-reverse-polish-notation/">150</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">2</td><td>Fri Aug 28</td><td><a href="https://github.com/prdlk/leetcode/issues/9"><strong>07</strong> · Monotonic Stack</a></td><td align="center">0/2</td><td><a href="https://leetcode.com/problems/daily-temperatures/">739</a> · <a href="https://leetcode.com/problems/car-fleet/">853</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td rowspan="3" align="center"><strong>II</strong><br><sub>Nodal & Grid</sub></td>
|
|
||||||
<td align="center">3</td><td>Mon Aug 31</td><td><a href="https://github.com/prdlk/leetcode/issues/10"><strong>08</strong> · Linked List</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/reverse-linked-list/">206</a> · <a href="https://leetcode.com/problems/merge-two-sorted-lists/">21</a> · <a href="https://leetcode.com/problems/linked-list-cycle/">141</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">3</td><td>Tue Sep 1</td><td><a href="https://github.com/prdlk/leetcode/issues/11"><strong>09</strong> · Hybrid Structures</a></td><td align="center">0/2</td><td><a href="https://leetcode.com/problems/lru-cache/">146</a> · <a href="https://leetcode.com/problems/add-two-numbers/">2</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">3</td><td>Wed Sep 2</td><td><a href="https://github.com/prdlk/leetcode/issues/12"><strong>10</strong> · Matrix Index Math</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/rotate-image/">48</a> · <a href="https://leetcode.com/problems/spiral-matrix/">54</a> · <a href="https://leetcode.com/problems/set-matrix-zeroes/">73</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td rowspan="5" align="center"><strong>III</strong><br><sub>Hierarchical</sub></td>
|
|
||||||
<td align="center">3</td><td>Thu Sep 3</td><td><a href="https://github.com/prdlk/leetcode/issues/13"><strong>11</strong> · Tree DFS</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/invert-binary-tree/">226</a> · <a href="https://leetcode.com/problems/maximum-depth-of-binary-tree/">104</a> · <a href="https://leetcode.com/problems/lowest-common-ancestor-of-a-binary-tree/">236</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">3</td><td>Fri Sep 4</td><td><a href="https://github.com/prdlk/leetcode/issues/14"><strong>12</strong> · Tree BFS</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/binary-tree-level-order-traversal/">102</a> · <a href="https://leetcode.com/problems/binary-tree-right-side-view/">199</a> · <a href="https://leetcode.com/problems/count-good-nodes-in-binary-tree/">1448</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">4</td><td>Mon Sep 7</td><td><a href="https://github.com/prdlk/leetcode/issues/15"><strong>13</strong> · Binary Search Tree</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/validate-binary-search-tree/">98</a> · <a href="https://leetcode.com/problems/kth-smallest-element-in-a-bst/">230</a> · <a href="https://leetcode.com/problems/lowest-common-ancestor-of-a-binary-search-tree/">235</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">4</td><td>Tue Sep 8</td><td><a href="https://github.com/prdlk/leetcode/issues/16"><strong>14</strong> · Heap / Priority Queue</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/kth-largest-element-in-an-array/">215</a> · <a href="https://leetcode.com/problems/k-closest-points-to-origin/">973</a> · <a href="https://leetcode.com/problems/merge-k-sorted-lists/">23</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">4</td><td>Wed Sep 9</td><td><a href="https://github.com/prdlk/leetcode/issues/17"><strong>15</strong> · Trie</a></td><td align="center">0/2</td><td><a href="https://leetcode.com/problems/implement-trie-prefix-tree/">208</a> · <a href="https://leetcode.com/problems/design-add-and-search-words-data-structure/">211</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td rowspan="4" align="center"><strong>IV</strong><br><sub>Relational</sub></td>
|
|
||||||
<td align="center">4</td><td>Thu Sep 10</td><td><a href="https://github.com/prdlk/leetcode/issues/18"><strong>16</strong> · Graph DFS</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/number-of-islands/">200</a> · <a href="https://leetcode.com/problems/clone-graph/">133</a> · <a href="https://leetcode.com/problems/pacific-atlantic-water-flow/">417</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">4</td><td>Fri Sep 11</td><td><a href="https://github.com/prdlk/leetcode/issues/19"><strong>17</strong> · Graph BFS</a></td><td align="center">0/2</td><td><a href="https://leetcode.com/problems/rotting-oranges/">994</a> · <a href="https://leetcode.com/problems/word-ladder/">127</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">5</td><td>Mon Sep 14</td><td><a href="https://github.com/prdlk/leetcode/issues/20"><strong>18</strong> · Topological Sort</a></td><td align="center">0/2</td><td><a href="https://leetcode.com/problems/course-schedule/">207</a> · <a href="https://leetcode.com/problems/course-schedule-ii/">210</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">5</td><td>Tue Sep 15</td><td><a href="https://github.com/prdlk/leetcode/issues/21"><strong>19</strong> · Union-Find</a></td><td align="center">0/2</td><td><a href="https://leetcode.com/problems/redundant-connection/">684</a> · <a href="https://leetcode.com/problems/number-of-connected-components-in-an-undirected-graph/">323</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td rowspan="5" align="center"><strong>V</strong><br><sub>Decision Space</sub></td>
|
|
||||||
<td align="center">5</td><td>Wed Sep 16</td><td><a href="https://github.com/prdlk/leetcode/issues/22"><strong>20</strong> · Backtracking</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/subsets/">78</a> · <a href="https://leetcode.com/problems/combination-sum/">39</a> · <a href="https://leetcode.com/problems/permutations/">46</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">5</td><td>Thu Sep 17</td><td><a href="https://github.com/prdlk/leetcode/issues/23"><strong>21</strong> · 1-D Dynamic Programming</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/climbing-stairs/">70</a> · <a href="https://leetcode.com/problems/house-robber/">198</a> · <a href="https://leetcode.com/problems/coin-change/">322</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">5</td><td>Fri Sep 18</td><td><a href="https://github.com/prdlk/leetcode/issues/24"><strong>22</strong> · Multi-D / Grid DP</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/unique-paths/">62</a> · <a href="https://leetcode.com/problems/minimum-path-sum/">64</a> · <a href="https://leetcode.com/problems/longest-common-subsequence/">1143</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">6</td><td>Mon Sep 21</td><td><a href="https://github.com/prdlk/leetcode/issues/25"><strong>23</strong> · Greedy</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/maximum-subarray/">53</a> · <a href="https://leetcode.com/problems/jump-game/">55</a> · <a href="https://leetcode.com/problems/gas-station/">134</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">6</td><td>Tue Sep 22</td><td><a href="https://github.com/prdlk/leetcode/issues/26"><strong>24</strong> · Intervals</a></td><td align="center">0/3</td><td><a href="https://leetcode.com/problems/insert-interval/">57</a> · <a href="https://leetcode.com/problems/merge-intervals/">56</a> · <a href="https://leetcode.com/problems/non-overlapping-intervals/">435</a></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## Phase I — Linear Structures (Week 1)
|
## Phase I — Linear Structures · Weeks 1–2
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -117,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>
|
||||||
@@ -129,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>
|
||||||
@@ -137,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>
|
||||||
@@ -149,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>
|
||||||
@@ -160,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>
|
||||||
@@ -172,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>
|
||||||
@@ -180,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>
|
||||||
@@ -189,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>
|
||||||
@@ -204,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>
|
||||||
@@ -217,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>
|
||||||
@@ -233,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>
|
||||||
@@ -248,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>
|
||||||
@@ -261,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>
|
||||||
@@ -269,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>
|
||||||
@@ -279,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>
|
||||||
@@ -291,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>
|
||||||
@@ -313,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>
|
||||||
@@ -323,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>
|
||||||
@@ -331,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>
|
||||||
@@ -346,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>
|
||||||
@@ -361,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>
|
||||||
@@ -372,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>
|
||||||
@@ -384,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>
|
||||||
@@ -395,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>
|
||||||
@@ -405,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,107 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
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;
|
||||||
|
|
||||||
|
const { results: gates } = 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 }>();
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Close the GitHub issue for every LeetCode problem actually solved under work/.
|
* Close the GitHub issue for every LeetCode problem actually solved under work/,
|
||||||
|
* then report the solved set to the SRS Worker so D1 agrees.
|
||||||
*
|
*
|
||||||
* Reconciles state instead of reacting to a push diff: any `problem`-labelled
|
* Reconciles state instead of reacting to a push diff: any `problem`-labelled
|
||||||
* issue whose LC number has an *implemented* solution file in work/ gets
|
* issue whose LC number has an *implemented* solution file in work/ gets
|
||||||
@@ -11,15 +12,19 @@
|
|||||||
* file existence alone means nothing — a stub must not close its issue. See
|
* file existence alone means nothing — a stub must not close its issue. See
|
||||||
* isImplemented().
|
* isImplemented().
|
||||||
*
|
*
|
||||||
* bun scripts/close-solved.ts # close matches
|
* bun apps/cli/close-solved.ts # close matches, log them in D1
|
||||||
* bun scripts/close-solved.ts --dry-run # report only, touch nothing
|
* bun apps/cli/close-solved.ts --dry-run # report only, touch nothing
|
||||||
*
|
*
|
||||||
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
||||||
|
* SRS_ADMIN_KEY for the D1 push (unset = skip it); SRS_API overrides
|
||||||
|
* the Worker URL for local `wrangler dev` runs.
|
||||||
*/
|
*/
|
||||||
import { $ } from "bun";
|
|
||||||
import { basename, join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..");
|
import { github } from "./github.ts";
|
||||||
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
|
|
||||||
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
const WORK = join(ROOT, "work");
|
const WORK = join(ROOT, "work");
|
||||||
|
|
||||||
const DRY =
|
const DRY =
|
||||||
@@ -78,40 +83,7 @@ function isImplemented(src: string, lang: "js" | "py"): boolean {
|
|||||||
|
|
||||||
// ── repo + auth ──────────────────────────────────────────────────
|
// ── repo + auth ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async function resolveRepo(): Promise<string> {
|
const gh = await github();
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── work/ inventory ──────────────────────────────────────────────
|
// ── work/ inventory ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -181,16 +153,9 @@ function readProblemIssue(
|
|||||||
|
|
||||||
/** LC number -> open issue carrying the `problem` label. */
|
/** LC number -> open issue carrying the `problem` label. */
|
||||||
const open = new Map<number, ProblemIssue>();
|
const open = new Map<number, ProblemIssue>();
|
||||||
for (let page = 1; ; page++) {
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=open`)) {
|
||||||
const batch = await api(
|
const parsed = readProblemIssue(raw);
|
||||||
`/repos/${repo}/issues?labels=problem&state=open&per_page=100&page=${page}`,
|
if (parsed) open.set(parsed.lc, parsed.issue);
|
||||||
);
|
|
||||||
if (!Array.isArray(batch)) throw new Error("unexpected /issues payload: not an array");
|
|
||||||
for (const raw of batch) {
|
|
||||||
const parsed = readProblemIssue(raw);
|
|
||||||
if (parsed) open.set(parsed.lc, parsed.issue);
|
|
||||||
}
|
|
||||||
if (batch.length < 100) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── reconcile ────────────────────────────────────────────────────
|
// ── reconcile ────────────────────────────────────────────────────
|
||||||
@@ -219,57 +184,94 @@ for (const num of [...work.keys()].sort((a, b) => a - b)) {
|
|||||||
|
|
||||||
const links = entry.files
|
const links = entry.files
|
||||||
.map((f) =>
|
.map((f) =>
|
||||||
sha ? `[\`${f}\`](https://github.com/${repo}/blob/${sha}/${encodeURI(f)})` : `\`${f}\``,
|
sha ? `[\`${f}\`](https://github.com/${gh.repo}/blob/${sha}/${encodeURI(f)})` : `\`${f}\``,
|
||||||
)
|
)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
await api(`/repos/${repo}/issues/${issue.number}/comments`, {
|
await gh.closeIssue(
|
||||||
method: "POST",
|
issue.number,
|
||||||
body: JSON.stringify({
|
`Solved — solution committed at ${links}.\n\n` +
|
||||||
body:
|
"Closed automatically by `close-solved`. Fill in the close-out block above if you have not already.",
|
||||||
`Solved — solution committed at ${links}.\n\n` +
|
);
|
||||||
"Closed automatically by `close-solved`. Fill in the close-out block above if you have not already.",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
await api(`/repos/${repo}/issues/${issue.number}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
|
|
||||||
});
|
|
||||||
report.push([`${num}`, `#${issue.number}`, issue.set, "closed"]);
|
report.push([`${num}`, `#${issue.number}`, issue.set, "closed"]);
|
||||||
closed++;
|
closed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── push the solved set into D1 ───────────────────────────────────
|
||||||
|
|
||||||
|
// Closing an issue is invisible to the Worker: its catalog reconcile reads
|
||||||
|
// GitHub for titles, labels and milestones and never looks at issue state,
|
||||||
|
// and D1's SRS columns only ever move through logAttempt(). So a solution
|
||||||
|
// pushed here — rather than tapped in the digest email — has to be reported,
|
||||||
|
// or the charts, the digest's "already solved" ticks, and the drill/gate
|
||||||
|
// pools all keep treating it as untouched.
|
||||||
|
//
|
||||||
|
// The whole implemented set goes over, not just this run's closes: the
|
||||||
|
// Worker drops anything already on the ladder, so the call is a total
|
||||||
|
// recompute and backfills whatever earlier runs missed.
|
||||||
|
const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
||||||
|
const solved = [...work.entries()]
|
||||||
|
.filter(([, entry]) => entry.implemented)
|
||||||
|
.map(([lc]) => lc)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
let syncNote: string;
|
||||||
|
let syncFailed = false;
|
||||||
|
if (!process.env.SRS_ADMIN_KEY) {
|
||||||
|
syncNote = "SRS_ADMIN_KEY unset — D1 not touched";
|
||||||
|
} else if (solved.length === 0) {
|
||||||
|
syncNote = "nothing implemented — D1 not touched";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SRS_API}/admin/solved${DRY ? "?dry=1" : ""}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${process.env.SRS_ADMIN_KEY}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ lc: solved }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
||||||
|
// Narrow the report payload instead of casting it; a shape change should
|
||||||
|
// read as "logged nothing", not throw inside the reconciler.
|
||||||
|
const payload: unknown = await res.json();
|
||||||
|
const fields = payload && typeof payload === "object" ? payload : {};
|
||||||
|
const strings = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : []);
|
||||||
|
const logged = strings("logged" in fields ? fields.logged : null);
|
||||||
|
const skipped = strings("skipped" in fields ? fields.skipped : null);
|
||||||
|
syncNote =
|
||||||
|
`${DRY ? "would log" : "logged"} ${logged.length} of ${solved.length} ` +
|
||||||
|
`(${skipped.length} already on the ladder or off-curriculum)`;
|
||||||
|
for (const line of logged) console.log(` d1: ${line}`);
|
||||||
|
} catch (err) {
|
||||||
|
// The closes above already landed; surface the failure instead of
|
||||||
|
// letting D1 drift silently until the next push.
|
||||||
|
syncFailed = true;
|
||||||
|
syncNote = `D1 push FAILED: ${err instanceof Error ? err.message : String(err)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── report ───────────────────────────────────────────────────────
|
// ── report ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const rows = [["lc", "issue", "set", "status"], ...report];
|
const rows = [["lc", "issue", "set", "status"], ...report];
|
||||||
const widths = rows[0]!.map((_, i) => Math.max(...rows.map((r) => r[i]!.length)));
|
printTable(rows);
|
||||||
const rendered = rows.map((r) =>
|
|
||||||
r
|
|
||||||
.map((cell, i) => cell.padEnd(widths[i]!))
|
|
||||||
.join(" ")
|
|
||||||
.trimEnd(),
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(rendered[0]);
|
|
||||||
console.log(widths.map((n) => "─".repeat(n)).join(" "));
|
|
||||||
for (const row of rendered.slice(1)) console.log(row);
|
|
||||||
|
|
||||||
const implemented = [...work.values()].filter((e) => e.implemented).length;
|
|
||||||
const actionable = report.filter((r) => r[3] !== "no open issue");
|
const actionable = report.filter((r) => r[3] !== "no open issue");
|
||||||
console.log(
|
console.log(
|
||||||
`\n${work.size} in work/ · ${implemented} implemented · ` +
|
`\n${work.size} in work/ · ${solved.length} implemented · ` +
|
||||||
`${actionable.length} matched an open issue · ` +
|
`${actionable.length} matched an open issue · ` +
|
||||||
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}`,
|
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}` +
|
||||||
|
`\nd1: ${syncNote}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
await writeStepSummary(
|
||||||
const body = actionable.map((r) => `| ${r[0]} | ${r[1]} | ${r[2]} | ${r[3]} |`).join("\n");
|
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
|
||||||
await Bun.write(
|
`${work.size} files in \`work/\`, ${solved.length} implemented, ` +
|
||||||
process.env.GITHUB_STEP_SUMMARY,
|
`${actionable.length} matched an open issue.\n\n` +
|
||||||
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
|
(actionable.length
|
||||||
`${work.size} files in \`work/\`, ${implemented} implemented, ` +
|
? `${markdownTable([rows[0]!, ...actionable])}\n`
|
||||||
`${actionable.length} matched an open issue.\n\n` +
|
: "Nothing to close.\n") +
|
||||||
(body
|
`\nD1: ${syncNote}\n`,
|
||||||
? `| LC | Issue | Set | Status |\n| --- | --- | --- | --- |\n${body}\n`
|
);
|
||||||
: "Nothing to close.\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"),
|
||||||
|
);
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Local LeetCode problem cache backed by SQLite at
|
||||||
|
* ~/.local/share/leetcode/data.db (XDG_DATA_HOME honored).
|
||||||
|
*
|
||||||
|
* One `problems` table, two freshness rules:
|
||||||
|
* - the full problem index (id / title / slug / difficulty / paid flag) is
|
||||||
|
* refreshed from leetcode.com at most once per 24h (`meta.index_fetched_at`);
|
||||||
|
* - descriptions (HTML from GraphQL `question.content`) are fetched once per
|
||||||
|
* slug and kept forever. Only the campaign problems linked from README.md
|
||||||
|
* are ever fetched, and paid-only problems are skipped because GraphQL
|
||||||
|
* returns null content for them.
|
||||||
|
*
|
||||||
|
* Library: side-effect-free on import — nothing is opened or fetched until
|
||||||
|
* problemDb() is called. Index upserts never touch the description column, so
|
||||||
|
* a daily refresh cannot evict cached statements.
|
||||||
|
*/
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
export interface Problem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
difficulty: "Easy" | "Medium" | "Hard";
|
||||||
|
paidOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
|
||||||
|
const INDEX_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export const DB_PATH = join(
|
||||||
|
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
|
||||||
|
"leetcode",
|
||||||
|
"data.db",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── README parsing ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Campaign problem slugs linked from README.md, in order, deduplicated. */
|
||||||
|
export function readmeSlugs(markdown: string): string[] {
|
||||||
|
const slugs = new Set<string>();
|
||||||
|
for (const m of markdown.matchAll(/https:\/\/leetcode\.com\/problems\/([\w-]+)\//g)) {
|
||||||
|
slugs.add(m[1]!);
|
||||||
|
}
|
||||||
|
return [...slugs];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── remote fetches ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchIndex(): Promise<Problem[]> {
|
||||||
|
const res = await fetch("https://leetcode.com/api/problems/all/", {
|
||||||
|
headers: { "user-agent": "Mozilla/5.0" },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`GET /api/problems/all -> ${res.status} ${res.statusText}`);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
stat_status_pairs: Array<{
|
||||||
|
stat: {
|
||||||
|
frontend_question_id: number;
|
||||||
|
question__title: string;
|
||||||
|
question__title_slug: string;
|
||||||
|
};
|
||||||
|
difficulty: { level: 1 | 2 | 3 };
|
||||||
|
paid_only: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
return data.stat_status_pairs
|
||||||
|
.map((p) => ({
|
||||||
|
id: p.stat.frontend_question_id,
|
||||||
|
title: p.stat.question__title,
|
||||||
|
slug: p.stat.question__title_slug,
|
||||||
|
difficulty: DIFFICULTY[p.difficulty.level] as Problem["difficulty"],
|
||||||
|
paidOnly: p.paid_only,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.id - b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTML problem statement, or null when LeetCode withholds it (paid-only). */
|
||||||
|
export async function fetchDescription(slug: string): Promise<string | null> {
|
||||||
|
const res = await fetch("https://leetcode.com/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"user-agent": "Mozilla/5.0",
|
||||||
|
referer: `https://leetcode.com/problems/${slug}/`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: "query questionContent($titleSlug: String!) { question(titleSlug: $titleSlug) { content } }",
|
||||||
|
variables: { titleSlug: slug },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`POST /graphql (${slug}) -> ${res.status} ${res.statusText}`);
|
||||||
|
const data = (await res.json()) as { data?: { question?: { content?: string | null } | null } };
|
||||||
|
return data.data?.question?.content ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the cache ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function problemDb(path = DB_PATH) {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
const db = new Database(path);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS problems (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
difficulty TEXT NOT NULL,
|
||||||
|
paid_only INTEGER NOT NULL,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const getMeta = db.query<{ value: string }, [string]>("SELECT value FROM meta WHERE key = ?1");
|
||||||
|
const setMeta = db.query<never, [string, string]>(
|
||||||
|
"INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
);
|
||||||
|
const upsert = db.query<never, [number, string, string, string, number]>(
|
||||||
|
`INSERT INTO problems (id, title, slug, difficulty, paid_only)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
title = excluded.title, slug = excluded.slug,
|
||||||
|
difficulty = excluded.difficulty, paid_only = excluded.paid_only`,
|
||||||
|
);
|
||||||
|
const descState = db.query<{ paid_only: number; described: number }, [string]>(
|
||||||
|
"SELECT paid_only, description IS NOT NULL AS described FROM problems WHERE slug = ?1",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
|
||||||
|
/** True when the index has never been fetched or is older than 24h. */
|
||||||
|
indexStale(): boolean {
|
||||||
|
const row = getMeta.get("index_fetched_at");
|
||||||
|
return !row || Date.now() - Number(row.value) > INDEX_TTL_MS;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Pull the full index from leetcode.com and upsert it. Returns the count. */
|
||||||
|
async refreshIndex(): Promise<number> {
|
||||||
|
const problems = await fetchIndex();
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const p of problems) upsert.run(p.id, p.title, p.slug, p.difficulty, p.paidOnly ? 1 : 0);
|
||||||
|
setMeta.run("index_fetched_at", String(Date.now()));
|
||||||
|
})();
|
||||||
|
return problems.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Every cached problem, ordered by LC number. */
|
||||||
|
all(): Problem[] {
|
||||||
|
const rows = db
|
||||||
|
.query<{ id: number; title: string; slug: string; difficulty: string; paid_only: number }, []>(
|
||||||
|
"SELECT id, title, slug, difficulty, paid_only FROM problems ORDER BY id",
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
slug: r.slug,
|
||||||
|
difficulty: r.difficulty as Problem["difficulty"],
|
||||||
|
paidOnly: r.paid_only === 1,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Free (non-premium) slugs among `slugs` with no cached description yet. */
|
||||||
|
missingDescriptions(slugs: string[]): string[] {
|
||||||
|
return slugs.filter((slug) => {
|
||||||
|
const row = descState.get(slug);
|
||||||
|
return row !== null && !row.paid_only && !row.described;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
saveDescription(slug: string, html: string): void {
|
||||||
|
db.query("UPDATE problems SET description = ?2 WHERE slug = ?1").run(slug, html);
|
||||||
|
},
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
db.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,59 @@
|
|||||||
|
/**
|
||||||
|
* Shared work/ solution picker for the test and submit entries: fuzzy-select
|
||||||
|
* a solution file, then hand it to leetcode-cli. Library: side-effect-free on
|
||||||
|
* import — nothing scans the filesystem until runOnSolution() is called.
|
||||||
|
*/
|
||||||
|
import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
|
||||||
|
import { join, relative } from "node:path";
|
||||||
|
|
||||||
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
|
const WORK_DIR = join(ROOT, "work");
|
||||||
|
|
||||||
|
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
|
||||||
|
export async function runOnSolution(command: "test" | "submit", message: string): Promise<never> {
|
||||||
|
const files = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })]
|
||||||
|
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const picked = await autocomplete<string>({
|
||||||
|
message,
|
||||||
|
placeholder: "Type to search...",
|
||||||
|
maxItems: 12,
|
||||||
|
options: files.map((f) => {
|
||||||
|
// work layout: Difficulty/Category/<id>.<slug>.<ext>
|
||||||
|
const [difficulty, category, name] = f.split("/");
|
||||||
|
return {
|
||||||
|
value: f,
|
||||||
|
label: name ?? f,
|
||||||
|
hint: category ? `${difficulty} · ${category}` : difficulty,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
filter: (search, option) => {
|
||||||
|
const haystack = option.value.toLowerCase();
|
||||||
|
return search
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.every((token) => haystack.includes(token));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isCancel(picked)) {
|
||||||
|
cancel("Nothing selected.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
// bun installs workspace-dep bins into this package's node_modules, not the root's
|
||||||
|
join(import.meta.dir, "node_modules", ".bin", "leetcode"),
|
||||||
|
command,
|
||||||
|
join(WORK_DIR, picked),
|
||||||
|
],
|
||||||
|
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
|
||||||
|
);
|
||||||
|
process.exit(await proc.exited);
|
||||||
|
}
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Fuzzy-select a solution file from work/ and submit it to LeetCode's judge.
|
||||||
|
*/
|
||||||
|
import { runOnSolution } from "./solutions";
|
||||||
|
|
||||||
|
await runOnSolution("submit", "Submit which solution?");
|
||||||
@@ -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",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
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,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
|
||||||
|
```
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
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,351 @@
|
|||||||
|
/**
|
||||||
|
* 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>;
|
||||||
|
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 — no hand-rolled palette) ───
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Phases({ stats }: { stats: Stats }) {
|
||||||
|
return (
|
||||||
|
<div style={card}>
|
||||||
|
<div style={heading}>Phases</div>
|
||||||
|
<table style={{ borderCollapse: "collapse", width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
{stats.phases.map((p) => (
|
||||||
|
<tr key={p.milestone}>
|
||||||
|
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>{p.name}</td>
|
||||||
|
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
|
||||||
|
<Bar done={p.core_done} total={p.core_total} />{" "}
|
||||||
|
<span style={muted}>
|
||||||
|
core {p.core_done}/{p.core_total}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "0.2rem 0" }}>
|
||||||
|
<span style={muted}>
|
||||||
|
optional {p.optional_done}/{p.optional_total}
|
||||||
|
{p.deferred_total > 0 &&
|
||||||
|
` · deferred ${p.deferred_done}/${p.deferred_total}`}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.</span>
|
||||||
|
) : (
|
||||||
|
<table style={{ borderCollapse: "collapse", width: "100%" }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{["Week", "Issue", "Pass rate", "Closed"].map((h) => (
|
||||||
|
<th key={h} style={{ ...muted, textAlign: "left", padding: "0.2rem 1rem 0.2rem 0" }}>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.gates.map((g) => (
|
||||||
|
<tr key={g.week}>
|
||||||
|
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>{g.week}</td>
|
||||||
|
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
|
||||||
|
{g.issue === null ? (
|
||||||
|
<span style={muted}>—</span>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={`https://github.com/prdlk/leetcode/issues/${g.issue}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
#{g.issue}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
|
||||||
|
{g.pass_rate === null ? (
|
||||||
|
<span style={muted}>open</span>
|
||||||
|
) : (
|
||||||
|
percent(g.pass_rate)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "0.2rem 0" }}>
|
||||||
|
{g.closed_on ?? <span style={muted}>—</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Recent({ stats }: { stats: Stats }) {
|
||||||
|
return (
|
||||||
|
<div style={card}>
|
||||||
|
<div style={heading}>Last 7 days</div>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
|
{stats.recent.map((r) => (
|
||||||
|
<span
|
||||||
|
key={r.date}
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--blume-border)",
|
||||||
|
borderRadius: "var(--blume-radius)",
|
||||||
|
padding: "0.35rem 0.6rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ ...muted, display: "block" }}>{dayLabel(r.date)}</span>
|
||||||
|
<strong>{r.passes}</strong>
|
||||||
|
<span style={muted}>/{r.attempts}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ ...muted, marginTop: "0.4rem" }}>passes / attempts</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
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({
|
|
||||||
title: "Algorithms",
|
|
||||||
order: 2,
|
|
||||||
});
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
---
|
|
||||||
title: 'Arrays and Strings'
|
|
||||||
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.'
|
|
||||||
sidebar:
|
|
||||||
label: Arrays & Strings
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
Technically, an array can't be resized. A dynamic array, or list, can be. In the context of algorithm problems, usually when people talk about arrays, they are referring to dynamic arrays. In this entire course, we will be talking about dynamic arrays/lists, but we will just use the word "array".
|
|
||||||
|
|
||||||
## Time Complexity
|
|
||||||
|
|
||||||
Arrays and strings are both ordered collections of elements. The difference is that arrays are mutable, while strings are immutable.
|
|
||||||
|
|
||||||
| Operation | Array/List | String(Immutable) |
|
|
||||||
| --------- | ---------- | ----------------- |
|
|
||||||
| Appending to end | *O(1) | O(n) |
|
|
||||||
| Popping from end | O(1) | O(n) |
|
|
||||||
| Insertion, not from end | O(n) | O(n) |
|
|
||||||
| Deletion, not from end | O(n) | O(n) |
|
|
||||||
| Modifying an element | O(1) | O(n) |
|
|
||||||
| Random access | *O(1) | O(n) |
|
|
||||||
| Checking if an element exists | O(1) | O(n) |
|
|
||||||
|
|
||||||
:::note[Clarification 1]
|
|
||||||
Appending to the end of a list is amortized O(1) time complexity. This means that the time complexity of appending to the end of a list is the same as the time complexity of appending to the end of an array.
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::note[Clarification 2]
|
|
||||||
Random access in this context means that you can access an element at any index in constant time.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Patterns
|
|
||||||
|
|
||||||
<Columns cols={2}>
|
|
||||||
<Column>
|
|
||||||
<Tile
|
|
||||||
title="Two Pointers"
|
|
||||||
description="Ship your first page in minutes."
|
|
||||||
href="/leetcode/two-pointers"
|
|
||||||
>
|
|
||||||
<Icon icon="rocket" size={28} />
|
|
||||||
</Tile>
|
|
||||||
</Column>
|
|
||||||
<Column>
|
|
||||||
<Tile
|
|
||||||
title="Sliding Window"
|
|
||||||
description="Ship your first page in minutes."
|
|
||||||
href="/leetcode/sliding-window"
|
|
||||||
>
|
|
||||||
<Icon icon="rocket" size={28} />
|
|
||||||
</Tile>
|
|
||||||
</Column>
|
|
||||||
<Column>
|
|
||||||
<Tile
|
|
||||||
title="Prefix Sum"
|
|
||||||
description="Ship your first page in minutes."
|
|
||||||
href="/leetcode/prefix-sum"
|
|
||||||
>
|
|
||||||
<Icon icon="rocket" size={28} />
|
|
||||||
</Tile>
|
|
||||||
</Column>
|
|
||||||
</Columns>
|
|
||||||
|
|
||||||
|
|
||||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user