feat(api): add SRS Cloudflare Worker with email digest, charts, and D1 integration

This commit is contained in:
Prad Nukala
2026-08-25 11:19:19 -04:00
parent f551878961
commit a3431da300
21 changed files with 3166 additions and 0 deletions
+92
View File
@@ -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 };
}
+406
View File
@@ -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 &amp; 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>`
);
}
+180
View File
@@ -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 };
}
+451
View File
@@ -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) };
}
+270
View File
@@ -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)}%` };
}
+94
View File
@@ -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 }),
});
},
};
}
+324
View File
@@ -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;
},
};
+79
View File
@@ -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);
}
+172
View File
@@ -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 });
}),
};
}
+254
View File
@@ -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;
}
}
+59
View File
@@ -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")));
});
});
+330
View File
@@ -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 023 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 (MonSun), 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, FisherYates 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,
};
}
+107
View File
@@ -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,
};
}