mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
feat(api): add SRS Cloudflare Worker with email digest, charts, and D1 integration
This commit is contained in:
@@ -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,92 @@
|
||||
# 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/{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.
|
||||
|
||||
The `sync-d1.yml` Actions workflow additionally pushes curriculum issue
|
||||
edits into D1 immediately (`POST /admin/reconcile` with the
|
||||
`SRS_ADMIN_KEY` repo secret); the morning cron is the backstop.
|
||||
|
||||
## 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,53 @@
|
||||
-- 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|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.
|
||||
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,180 @@
|
||||
/**
|
||||
* 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. Reviews + drills ≤ 6, reviews first, overflow
|
||||
* simply stays due (oldest tomorrow). 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).
|
||||
*/
|
||||
import {
|
||||
CAMPAIGN_DAYS,
|
||||
type ProblemRow,
|
||||
SCHEDULE,
|
||||
addDays,
|
||||
campaignDay,
|
||||
campaignWeek,
|
||||
dueReviews,
|
||||
isoWeek,
|
||||
pickDrills,
|
||||
prettyDate,
|
||||
streak,
|
||||
weekdayOf,
|
||||
} from "./srs.ts";
|
||||
import { type DigestData, type DigestRow, renderDigest } from "./email.tsx";
|
||||
import { signLink } from "./links.ts";
|
||||
|
||||
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 take the cap first; drills fill whatever is left.
|
||||
const due = rest ? [] : await dueReviews(db, date);
|
||||
const capped = due.slice(0, DAILY_CAP);
|
||||
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - capped.length);
|
||||
|
||||
const 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,
|
||||
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 };
|
||||
|
||||
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,451 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
export interface DigestData {
|
||||
/** "Tuesday, August 25" */
|
||||
day: string;
|
||||
/** "Day 9 of 56 · Week 2" */
|
||||
progress: string;
|
||||
streak: number;
|
||||
rest: boolean;
|
||||
topic?: { name: string; core: { lc: number; url: string; solved: boolean }[] };
|
||||
reviews: DigestRow[];
|
||||
/** Reviews past the daily cap; they simply stay due. */
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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, margin: 0 }}>
|
||||
Work through these — ticked ones you have already solved:{" "}
|
||||
{data.topic.core.map((p, i) => (
|
||||
<span key={p.lc}>
|
||||
{i > 0 ? " · " : ""}
|
||||
<Link href={p.url} style={{ color: p.solved ? MUTED : LINK, textDecoration: "none" }}>
|
||||
LC {p.lc}
|
||||
</Link>
|
||||
{p.solved ? <span style={{ color: GREEN }}> ✓</span> : null}
|
||||
</span>
|
||||
))}
|
||||
</Text>
|
||||
</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 — they come back
|
||||
tomorrow, oldest first.
|
||||
</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; the ones marked done you have already solved.",
|
||||
...data.topic.core.map((p) => ` LC ${p.lc}${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 — they come back tomorrow, oldest first.`,
|
||||
);
|
||||
}
|
||||
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,324 @@
|
||||
/**
|
||||
* 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,
|
||||
logAttempt,
|
||||
weekdayOf,
|
||||
} from "./srs.ts";
|
||||
import { buildStats } from "./stats.ts";
|
||||
|
||||
// ── shared side effects after a state write ──────────────────────
|
||||
|
||||
/**
|
||||
* 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);
|
||||
const mirror: Mirror = projectMirror(gh, env.REPO.split("/")[0]!);
|
||||
await mirror.setStage(outcome.issue, outcome.stage);
|
||||
await mirror.setTargetDate(outcome.issue, outcome.next_review);
|
||||
if (outcome.first) await mirror.setFirstAttempt(outcome.issue, outcome.result);
|
||||
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);
|
||||
}
|
||||
|
||||
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/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,330 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, and gate scoring 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.
|
||||
*/
|
||||
export async function logAttempt(
|
||||
db: D1Database,
|
||||
opts: { lc: number; date: string; result: Result; source: "email" | "webhook"; 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` };
|
||||
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 Sending; 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.
|
||||
}
|
||||
Reference in New Issue
Block a user