mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 07:26:27 +00:00
Migrate SRS to Cloudflare Worker: D1 state, email digest, review issues, live charts
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Nightly GitHub → D1 catalog reconcile. GitHub issues own the catalog
|
||||
* (topics, problems, set/diff labels, milestones); this recomputes desired
|
||||
* rows from scratch on every run — re-runs are no-ops. SRS-owned columns
|
||||
* (stage, next_review) are NEVER overwritten; defer_until is set once, on
|
||||
* first sight of a deferred problem. The Worker never invents catalog rows.
|
||||
*/
|
||||
import { addDays } from "./srs.ts";
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
const TITLE_RE = /^LC (\d+) · (.+) · (Easy|Medium|Hard) · (core|optional|deferred)$/;
|
||||
|
||||
/** Deferred Hards enter the queue from this date, two per day. */
|
||||
const DEFER_FROM = "2026-09-28";
|
||||
|
||||
export interface ReconcileReport {
|
||||
topics: number;
|
||||
problems: number;
|
||||
}
|
||||
|
||||
export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<ReconcileReport> {
|
||||
interface TopicIssue {
|
||||
number: number;
|
||||
title: string;
|
||||
milestone: number | null;
|
||||
}
|
||||
const topics: TopicIssue[] = [];
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if ("pull_request" in raw) continue;
|
||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||
let milestone: number | null = null;
|
||||
if (
|
||||
"milestone" in raw &&
|
||||
raw.milestone &&
|
||||
typeof raw.milestone === "object" &&
|
||||
"number" in raw.milestone &&
|
||||
typeof raw.milestone.number === "number"
|
||||
) {
|
||||
milestone = raw.milestone.number;
|
||||
}
|
||||
topics.push({ number: raw.number, title: raw.title, milestone });
|
||||
}
|
||||
|
||||
const statements: D1PreparedStatement[] = [];
|
||||
for (const t of topics) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO topics (issue, name, milestone) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(issue) DO UPDATE SET name = ?2, milestone = ?3`,
|
||||
)
|
||||
.bind(t.number, t.title.replace(/^Topic \d+ — /, ""), t.milestone),
|
||||
);
|
||||
}
|
||||
|
||||
let problems = 0;
|
||||
let deferredSeen = 0;
|
||||
for (const t of topics) {
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||
const m = raw.title.match(TITLE_RE);
|
||||
if (!m) continue; // non-curriculum sub-issue
|
||||
const lc = Number(m[1]);
|
||||
const set = m[4]!;
|
||||
// Two deferred Hards per day from DEFER_FROM, in catalog walk order —
|
||||
// applied only when the row is first created (SRS owns it afterwards).
|
||||
const defer = set === "deferred" ? addDays(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
|
||||
if (set === "deferred") deferredSeen++;
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO problems (lc_number, issue, topic_issue, title, difficulty, set_label, defer_until, next_review)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
|
||||
ON CONFLICT(lc_number) DO UPDATE SET
|
||||
issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`,
|
||||
)
|
||||
.bind(lc, raw.number, t.number, m[2]!, m[3]!.toLowerCase(), set, defer),
|
||||
);
|
||||
problems++;
|
||||
}
|
||||
}
|
||||
|
||||
// D1 batches are transactional; chunk to stay under statement limits.
|
||||
for (let i = 0; i < statements.length; i += 50) {
|
||||
await db.batch(statements.slice(i, i + 50));
|
||||
}
|
||||
return { topics: topics.length, problems };
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Hand-rolled SVG chart generation for the SRS Worker.
|
||||
*
|
||||
* Feeds four endpoints — /chart/progress.svg, /chart/ladder.svg,
|
||||
* /chart/heatmap.svg, /badge/gate.svg — each served with
|
||||
* `Cache-Control: public, max-age=300`.
|
||||
*
|
||||
* Everything is string-built: no chart library, no runtime imports. 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. GitHub renders these on white; no dark-mode variants.
|
||||
* Every function renders a valid (if empty-looking) SVG on a zero-row DB.
|
||||
*/
|
||||
// D1Database comes from the generated worker-configuration.d.ts runtime types.
|
||||
|
||||
const FONT = "Verdana,DejaVu Sans,sans-serif";
|
||||
|
||||
// Green ramp shared by the heatmap and the ladder (GitHub contribution hues).
|
||||
const GREENS = ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"];
|
||||
|
||||
// Labels are stored SVG-ready: phase II's "&" is pre-escaped since these
|
||||
// strings go straight into markup and nothing else here needs escaping.
|
||||
const PHASES = [
|
||||
{ milestone: 1, name: "I — Linear" },
|
||||
{ milestone: 2, name: "II — Nodal & Grid" },
|
||||
{ milestone: 3, name: "III — Hierarchical" },
|
||||
{ milestone: 4, name: "IV — Relational" },
|
||||
{ milestone: 5, name: "V — Decision Space" },
|
||||
];
|
||||
|
||||
const STAGES = ["new", "+2", "+5", "+10", "retired"];
|
||||
|
||||
// ── svg helpers ──────────────────────────────────────────────────
|
||||
|
||||
/** Opening tag plus the white background rect 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}" fill="#ffffff"/>`
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 = "#1f2328", 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][] = [
|
||||
["#2da44e", "core done"],
|
||||
["#0969da", "optional done"],
|
||||
["#d0d7de", "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: "#57606a" }));
|
||||
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, "#2da44e"],
|
||||
[p.optionalDone, "#0969da"],
|
||||
[p.remaining, "#d0d7de"],
|
||||
];
|
||||
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 === "#d0d7de" ? "#57606a" : "#ffffff";
|
||||
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: "#57606a" }));
|
||||
});
|
||||
|
||||
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 = 34;
|
||||
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)];
|
||||
parts.push(text(40, 20, "review ladder", { size: 13, weight: "bold" }));
|
||||
|
||||
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, y - 6, String(count), { size: 12, anchor: "middle", weight: "bold" }));
|
||||
parts.push(text(cx, plotBottom + 18, STAGES[i]!, { size: 12, fill: "#57606a", anchor: "middle" }));
|
||||
});
|
||||
|
||||
parts.push(`<line x1="40" y1="${plotBottom}" x2="${width - 40}" y2="${plotBottom}" stroke="#d0d7de"/>`);
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// ── heatmap: attempts per campaign day ───────────────────────────
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
export async function heatmapChart(db: D1Database, start: string, days: number): Promise<string> {
|
||||
// YYYY-MM-DD → UTC midnight; all per-day dates derive from this timestamp,
|
||||
// so local-timezone drift never shifts a cell.
|
||||
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 width = 560;
|
||||
const height = 160;
|
||||
const gridX = 34;
|
||||
const gridY = 24;
|
||||
const cell = 16;
|
||||
const step = 19; // cell + 3px gap
|
||||
const weeks = Math.ceil(days / 7);
|
||||
|
||||
const parts = [svgOpen(width, height)];
|
||||
|
||||
// Week numbers across the top, Mon/Wed/Fri down the left.
|
||||
for (let w = 0; w < weeks; w++) {
|
||||
parts.push(text(gridX + w * step + cell / 2, gridY - 7, `W${w + 1}`, { size: 10, fill: "#57606a", anchor: "middle" }));
|
||||
}
|
||||
const weekdays: [number, string][] = [
|
||||
[0, "Mon"],
|
||||
[2, "Wed"],
|
||||
[4, "Fri"],
|
||||
];
|
||||
for (const [row, label] of weekdays) {
|
||||
parts.push(text(gridX - 6, gridY + row * step + cell - 4, label, { size: 10, fill: "#57606a", anchor: "end" }));
|
||||
}
|
||||
|
||||
// One cell per campaign day; the start date is a Monday, so day i sits at
|
||||
// column i/7, row i%7 — but the row is derived from the real weekday so an
|
||||
// off-Monday start still lands correctly.
|
||||
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;
|
||||
const col = Math.floor(i / 7);
|
||||
const row = (day.getUTCDay() + 6) % 7; // Mon = 0
|
||||
const color = GREENS[Math.min(attempts, 4)];
|
||||
parts.push(
|
||||
`<rect x="${gridX + col * step}" y="${gridY + row * step}" width="${cell}" height="${cell}" rx="2" fill="${color}"/>`,
|
||||
);
|
||||
}
|
||||
|
||||
// Less → More ramp fills the space right of the grid.
|
||||
const legendX = gridX + weeks * step + 40;
|
||||
const legendY = gridY + 3 * step;
|
||||
parts.push(text(legendX - 6, legendY + cell - 4, "Less", { size: 10, fill: "#57606a", anchor: "end" }));
|
||||
GREENS.forEach((color, i) => {
|
||||
parts.push(`<rect x="${legendX + i * step}" y="${legendY}" width="${cell}" height="${cell}" rx="2" fill="${color}"/>`);
|
||||
});
|
||||
parts.push(text(legendX + GREENS.length * step + 3, legendY + cell - 4, "More", { size: 10, fill: "#57606a" }));
|
||||
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// ── gate badge: shields.io flat 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 color = row ? (row.pass_rate >= 0.7 ? "#4c1" : "#e05d44") : "#9f9f9f";
|
||||
|
||||
// Shields flat geometry: height 20, rx 3, verdana 11px, ~6.5px per char
|
||||
// plus 5px padding each side. Each caption is drawn twice — a dark
|
||||
// 30%-opacity copy 1px low as the shadow, then white on top.
|
||||
const labelW = Math.round(label.length * 6.5) + 10;
|
||||
const valueW = Math.round(value.length * 6.5) + 10;
|
||||
const total = labelW + valueW;
|
||||
const labelX = labelW / 2;
|
||||
const valueX = labelW + valueW / 2;
|
||||
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="20" role="img" aria-label="${label}: ${value}">` +
|
||||
`<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>` +
|
||||
`<clipPath id="r"><rect width="${total}" height="20" rx="3" fill="#fff"/></clipPath>` +
|
||||
`<g clip-path="url(#r)">` +
|
||||
`<rect width="${labelW}" height="20" fill="#555"/>` +
|
||||
`<rect x="${labelW}" width="${valueW}" height="20" fill="${color}"/>` +
|
||||
`<rect width="${total}" height="20" fill="url(#s)"/>` +
|
||||
`</g>` +
|
||||
`<g fill="#fff" text-anchor="middle" font-family="${FONT}" font-size="11">` +
|
||||
`<text x="${labelX}" y="15" fill="#010101" fill-opacity=".3">${label}</text>` +
|
||||
`<text x="${labelX}" y="14">${label}</text>` +
|
||||
`<text x="${valueX}" y="15" fill="#010101" fill-opacity=".3">${value}</text>` +
|
||||
`<text x="${valueX}" y="14">${value}</text>` +
|
||||
`</g>` +
|
||||
`</svg>`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* The daily digest email — built from D1 + the bundled schedule, sent at
|
||||
* 8 AM ET via the send_email binding. Plain inline-styled HTML that renders
|
||||
* clean in Gmail mobile; always sent with a text alternative.
|
||||
*
|
||||
* Retrieval rules: review and drill lines carry number + difficulty only —
|
||||
* never the topic, never a solution link. 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 { signLink } from "./links.ts";
|
||||
|
||||
const DAILY_CAP = 6;
|
||||
|
||||
function cap(difficulty: string): string {
|
||||
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
|
||||
}
|
||||
|
||||
export interface Digest {
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** One-tap pass/fail anchor pair for a problem line. */
|
||||
async function tapLinks(env: Env, lc: number, date: string): Promise<string> {
|
||||
const pass = await signLink(env.LINK_KEY, lc, "pass", date);
|
||||
const fail = await signLink(env.LINK_KEY, lc, "fail", date);
|
||||
const url = (r: string, sig: string) =>
|
||||
`${env.PUBLIC_URL}/log?p=${lc}&r=${r}&d=${date}&sig=${sig}`;
|
||||
return (
|
||||
`<a href="${url("pass", pass)}" style="color:#1a7f37;font-weight:bold">pass</a>` +
|
||||
` · <a href="${url("fail", fail)}" style="color:#cf222e;font-weight:bold">fail</a>`
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildDigest(env: Env, date: string): Promise<Digest> {
|
||||
const db = env.DB;
|
||||
const day = campaignDay(date);
|
||||
const week = campaignWeek(date);
|
||||
const header = `${prettyDate(date)} — Day ${day}/${CAMPAIGN_DAYS} · Week ${week}`;
|
||||
|
||||
if (weekdayOf(date) === 0) {
|
||||
return {
|
||||
subject: `SRS — ${prettyDate(date)} — rest day`,
|
||||
html: `<p><strong>${header}</strong></p><p>Rest day. Nothing is due. Overdue reviews moved to Monday.</p>`,
|
||||
text: `${header}\nRest day. Nothing is due. Overdue reviews moved to Monday.`,
|
||||
};
|
||||
}
|
||||
|
||||
const due = await dueReviews(db, date);
|
||||
const reviews = due.slice(0, DAILY_CAP);
|
||||
const carried = due.length - reviews.length;
|
||||
const drills = await pickDrills(db, date, DAILY_CAP - reviews.length);
|
||||
const currentStreak = await streak(db, date);
|
||||
|
||||
const htmlParts: string[] = [];
|
||||
const textParts: string[] = [];
|
||||
htmlParts.push(
|
||||
`<h2 style="margin:0 0 4px">${header}</h2>`,
|
||||
`<p style="margin:0 0 16px;color:#57606a">🔥 streak: ${currentStreak} day${currentStreak === 1 ? "" : "s"}</p>`,
|
||||
);
|
||||
textParts.push(header, `streak: ${currentStreak}`);
|
||||
|
||||
// New topic — the only labeled section.
|
||||
const topicIssue = 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>();
|
||||
const links = core
|
||||
.map(
|
||||
(p) =>
|
||||
`<a href="https://github.com/${env.REPO}/issues/${p.issue}">LC ${p.lc_number}</a>${p.stage === "new" ? "" : " ✓"}`,
|
||||
)
|
||||
.join(" · ");
|
||||
htmlParts.push(
|
||||
`<h3 style="margin:16px 0 4px">New topic: ${topic?.name ?? `#${topicIssue}`}</h3>`,
|
||||
`<p style="margin:0">Core: ${links}</p>`,
|
||||
);
|
||||
textParts.push(`New topic: ${topic?.name ?? topicIssue} — core: ${core.map((p) => `LC ${p.lc_number}`).join(", ")}`);
|
||||
}
|
||||
|
||||
// Reviews due — unlabeled lines, one-tap links.
|
||||
htmlParts.push(`<h3 style="margin:16px 0 4px">Reviews due (${reviews.length})</h3>`);
|
||||
textParts.push(`Reviews due (${reviews.length})`);
|
||||
if (reviews.length) {
|
||||
htmlParts.push(
|
||||
`<p style="margin:0 0 4px;color:#57606a">Solve each from scratch. Do not open your old solution first.</p>`,
|
||||
);
|
||||
for (const p of reviews) {
|
||||
htmlParts.push(
|
||||
`<p style="margin:2px 0">LC ${p.lc_number} — ${cap(p.difficulty)} — stage ${p.stage} — ${await tapLinks(env, p.lc_number, date)}</p>`,
|
||||
);
|
||||
textParts.push(`- LC ${p.lc_number} — ${cap(p.difficulty)} — stage ${p.stage}`);
|
||||
}
|
||||
if (carried > 0) {
|
||||
htmlParts.push(`<p style="margin:4px 0;color:#57606a">${carried} more carried to tomorrow (cap ${DAILY_CAP}).</p>`);
|
||||
textParts.push(`${carried} more carried to tomorrow.`);
|
||||
}
|
||||
} else {
|
||||
htmlParts.push(`<p style="margin:0">None.</p>`);
|
||||
textParts.push("none");
|
||||
}
|
||||
|
||||
// Blind drills — unlabeled.
|
||||
htmlParts.push(`<h3 style="margin:16px 0 4px">Blind drills (${drills.length})</h3>`);
|
||||
textParts.push(`Blind drills (${drills.length})`);
|
||||
if (drills.length) {
|
||||
htmlParts.push(
|
||||
`<p style="margin:0 0 4px;color:#57606a">No topic given. Name the pattern out loud before you code.</p>`,
|
||||
);
|
||||
for (const p of drills) {
|
||||
htmlParts.push(
|
||||
`<p style="margin:2px 0">LC ${p.lc_number} — ${cap(p.difficulty)} — ${await tapLinks(env, p.lc_number, date)}</p>`,
|
||||
);
|
||||
textParts.push(`- LC ${p.lc_number} — ${cap(p.difficulty)}`);
|
||||
}
|
||||
} else {
|
||||
htmlParts.push(`<p style="margin:0">None today.</p>`);
|
||||
textParts.push("none");
|
||||
}
|
||||
|
||||
// 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) {
|
||||
htmlParts.push(
|
||||
`<h3 style="margin:16px 0 4px">Saturday gate</h3>`,
|
||||
`<p style="margin:0"><a href="https://github.com/${env.REPO}/issues/${gate.issue}">Review — Week ${campaignWeek(date)} (#${gate.issue})</a> — timed, blind, then close the issue.</p>`,
|
||||
);
|
||||
textParts.push(`Saturday gate: https://github.com/${env.REPO}/issues/${gate.issue}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer: yesterday's log, gate rate to date, progress page.
|
||||
const yesterday = addDays(date, -1);
|
||||
const { results: logged } = await db
|
||||
.prepare("SELECT lc_number, kind, result FROM attempts WHERE date = ? ORDER BY id")
|
||||
.bind(yesterday)
|
||||
.all<{ lc_number: number; kind: string; result: string }>();
|
||||
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 }>();
|
||||
const footerBits = [
|
||||
logged.length
|
||||
? `Yesterday: ${logged.map((a) => `LC ${a.lc_number} ${a.result === "pass" ? "✅" : "❌"}`).join(" · ")}`
|
||||
: "Yesterday: no attempts logged.",
|
||||
lastGate ? `Gate pass rate: ${Math.round(lastGate.pass_rate * 100)}%` : "No gates scored yet.",
|
||||
];
|
||||
htmlParts.push(
|
||||
`<hr style="border:none;border-top:1px solid #d0d7de;margin:16px 0">`,
|
||||
`<p style="margin:0;color:#57606a;font-size:13px">${footerBits.join(" · ")} · <a href="${env.DOCS_URL}/progress">progress page</a></p>`,
|
||||
);
|
||||
textParts.push(...footerBits, `progress: ${env.DOCS_URL}/progress`);
|
||||
|
||||
const load = reviews.length + drills.length;
|
||||
return {
|
||||
subject: `SRS — ${prettyDate(date)} — ${load} on deck`,
|
||||
html: `<div style="font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;max-width:600px;margin:0 auto;padding:12px">${htmlParts.join("\n")}</div>`,
|
||||
text: textParts.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Saturday review issue: created at midnight ET so the 8 AM digest can link
|
||||
* to it; scored when the issue closes (webhook).
|
||||
*
|
||||
* The gate half is a topic-blind quiz — one unsolved optional problem per
|
||||
* topic scheduled this week (which is why daily drills only draw from
|
||||
* earlier weeks). The recap half is generated data, deliberately NOT a task
|
||||
* list: the week's solved problems are already scheduled by the +2 ladder,
|
||||
* and re-assigning them on Saturday would be massed practice.
|
||||
*
|
||||
* Label is `review` (the retired Actions system owned `gate`). Creation is
|
||||
* idempotent by week; deferred Hards never appear.
|
||||
*/
|
||||
import {
|
||||
type ProblemRow,
|
||||
type Result,
|
||||
SCHEDULE,
|
||||
campaignWeek,
|
||||
isoWeek,
|
||||
rng,
|
||||
sample,
|
||||
streak,
|
||||
weekMonday,
|
||||
} from "./srs.ts";
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
const PASS_TARGET = 0.7;
|
||||
const REVIEW_LABEL = "review";
|
||||
const MAX_GATE_PROBLEMS = 5;
|
||||
|
||||
function capitalized(difficulty: string): string {
|
||||
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
|
||||
}
|
||||
|
||||
/** Unsolved problems of a topic in a set — never attempted, never drilled. */
|
||||
async function freshPool(db: D1Database, topic: number, set: string): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM problems
|
||||
WHERE topic_issue = ?1 AND set_label = ?2 AND stage = 'new' AND defer_until IS NULL
|
||||
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = problems.lc_number)
|
||||
ORDER BY lc_number`,
|
||||
)
|
||||
.bind(topic, set)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface CreateReport {
|
||||
created: boolean;
|
||||
issue?: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export async function createReviewIssue(env: Env, gh: GitHub, date: string): Promise<CreateReport> {
|
||||
const db = env.DB;
|
||||
const week = campaignWeek(date);
|
||||
const iso = isoWeek(date);
|
||||
const title = `Review — Week ${week}`;
|
||||
|
||||
const existing = await db
|
||||
.prepare("SELECT issue FROM gates WHERE week = ? AND issue IS NOT NULL")
|
||||
.bind(iso)
|
||||
.first<{ issue: number }>();
|
||||
if (existing) {
|
||||
return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` };
|
||||
}
|
||||
|
||||
// Topics scheduled this week; review-only weeks (Sep 23 onward) sample
|
||||
// across every topic that still has unsolved optional problems.
|
||||
let topics = Object.entries(SCHEDULE)
|
||||
.filter(([d]) => campaignWeek(d) === week)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([, t]) => t);
|
||||
if (topics.length === 0) {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT DISTINCT topic_issue AS t FROM problems
|
||||
WHERE set_label = 'optional' AND stage = 'new'
|
||||
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||
ORDER BY topic_issue`,
|
||||
)
|
||||
.all<{ t: number }>();
|
||||
topics = sample(results.map((r) => r.t), MAX_GATE_PROBLEMS, rng(`gate-topics-${iso}`)).sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
}
|
||||
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
|
||||
|
||||
const random = rng(`gate-${iso}`);
|
||||
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
||||
for (const topic of topics) {
|
||||
const optional = await freshPool(db, topic, "optional");
|
||||
if (optional.length > 0) {
|
||||
picks.push({ p: sample(optional, 1, random)[0]!, fallback: false });
|
||||
continue;
|
||||
}
|
||||
const core = await freshPool(db, topic, "core");
|
||||
if (core.length > 0) picks.push({ p: sample(core, 1, random)[0]!, fallback: true });
|
||||
// Both pools exhausted: the topic is fully solved; nothing to quiz.
|
||||
}
|
||||
if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` };
|
||||
|
||||
// Recap: everything attempted this week, plus streak — data, not tasks.
|
||||
const monday = weekMonday(date);
|
||||
const { results: attempts } = await db
|
||||
.prepare(
|
||||
`SELECT a.lc_number, a.date, a.kind, a.result FROM attempts a
|
||||
WHERE a.date >= ? AND a.date < ? AND a.source != 'import'
|
||||
ORDER BY a.date, a.id`,
|
||||
)
|
||||
.bind(monday, date)
|
||||
.all<{ lc_number: number; date: string; kind: string; result: string }>();
|
||||
const reviewsDone = attempts.filter((a) => a.kind === "review" && a.result === "pass").length;
|
||||
const currentStreak = await streak(db, date);
|
||||
|
||||
const gateLines = picks.map(
|
||||
({ p, fallback }) =>
|
||||
`- LC ${p.lc_number} — ${capitalized(p.difficulty)}${fallback ? " *(core fallback — optional pool exhausted)*" : ""}`,
|
||||
);
|
||||
const recapLines = attempts.length
|
||||
? attempts.map(
|
||||
(a) => `| LC ${a.lc_number} | ${a.kind} | ${a.result === "pass" ? "✅" : "❌"} | ${a.date} |`,
|
||||
)
|
||||
: ["| — | no attempts logged this week | | |"];
|
||||
const body = [
|
||||
"## Gate — blind set",
|
||||
`**Target: ${Math.round(PASS_TARGET * 100)}% first-attempt pass rate. Timed. Narrate out loud.**`,
|
||||
"No topics given. Log with `/done <n> pass|fail`, then close this issue.",
|
||||
...gateLines,
|
||||
"",
|
||||
`## Week ${week} recap`,
|
||||
`${attempts.length} attempts · ${reviewsDone} reviews passed · streak ${currentStreak}`,
|
||||
"",
|
||||
"| Problem | Kind | Result | Day |",
|
||||
"| --- | --- | --- | --- |",
|
||||
...recapLines,
|
||||
].join("\n");
|
||||
|
||||
// Ensure the `review` label exists (422 = already there).
|
||||
try {
|
||||
await gh.rest(`/repos/${gh.repo}/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: REVIEW_LABEL,
|
||||
color: "5319e7",
|
||||
description: "Weekly blind gate + recap",
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!String(err).includes("422")) throw err;
|
||||
}
|
||||
|
||||
const milestone = await db
|
||||
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
|
||||
.bind(topics[0])
|
||||
.first<{ milestone: number }>();
|
||||
|
||||
const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
body,
|
||||
labels: [REVIEW_LABEL],
|
||||
milestone: milestone?.milestone,
|
||||
}),
|
||||
})) as { number: number };
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO gates (week, issue, problems) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(week) DO UPDATE SET issue = ?2, problems = ?3`,
|
||||
)
|
||||
.bind(iso, created.number, JSON.stringify(picks.map(({ p }) => p.lc_number)))
|
||||
.run();
|
||||
|
||||
// Last week's boosted topics had their remedial week — clear the flags.
|
||||
await db.prepare("UPDATE topics SET boost = 0 WHERE boost = 1").run();
|
||||
|
||||
return { created: true, issue: created.number, reason: `created #${created.number}` };
|
||||
}
|
||||
|
||||
export interface ScoreReport {
|
||||
scored: boolean;
|
||||
rate?: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grade a closed review issue: first-attempt pass rate over its gate set.
|
||||
* Unlogged problems count as failures — skipping a gate problem is not a
|
||||
* pass. Boosts topics that failed a gate problem or reached 2 drill misses;
|
||||
* miss counters reset once consumed. The badge/charts read D1 live, so
|
||||
* "refreshing the badge" is this row update.
|
||||
*/
|
||||
export async function scoreReview(
|
||||
env: Env,
|
||||
gh: GitHub,
|
||||
issueNumber: number,
|
||||
date: string,
|
||||
): Promise<ScoreReport> {
|
||||
const db = env.DB;
|
||||
const gate = await db
|
||||
.prepare("SELECT week, problems, pass_rate FROM gates WHERE issue = ?")
|
||||
.bind(issueNumber)
|
||||
.first<{ week: number; problems: string; pass_rate: number | null }>();
|
||||
if (!gate) return { scored: false, reason: `issue #${issueNumber} has no gate record` };
|
||||
if (gate.pass_rate !== null) return { scored: false, rate: gate.pass_rate, reason: "already scored" };
|
||||
|
||||
const lcs: number[] = JSON.parse(gate.problems);
|
||||
const results: Record<number, Result | undefined> = {};
|
||||
for (const lc of lcs) {
|
||||
const row = await db
|
||||
.prepare("SELECT result FROM attempts WHERE lc_number = ? AND kind = 'gate' ORDER BY id LIMIT 1")
|
||||
.bind(lc)
|
||||
.first<{ result: Result }>();
|
||||
results[lc] = row?.result;
|
||||
}
|
||||
const passes = lcs.filter((lc) => results[lc] === "pass");
|
||||
const unlogged = lcs.filter((lc) => results[lc] === undefined);
|
||||
const rate = passes.length / lcs.length;
|
||||
|
||||
await db
|
||||
.prepare("UPDATE gates SET pass_rate = ?, closed_on = ? WHERE issue = ?")
|
||||
.bind(rate, date, issueNumber)
|
||||
.run();
|
||||
|
||||
const boosted: number[] = [];
|
||||
for (const lc of lcs) {
|
||||
if (results[lc] !== "fail") continue;
|
||||
const p = await db
|
||||
.prepare("SELECT topic_issue FROM problems WHERE lc_number = ?")
|
||||
.bind(lc)
|
||||
.first<{ topic_issue: number }>();
|
||||
if (p) boosted.push(p.topic_issue);
|
||||
}
|
||||
const { results: missed } = await db
|
||||
.prepare("SELECT issue FROM topics WHERE misses >= 2")
|
||||
.all<{ issue: number }>();
|
||||
boosted.push(...missed.map((m) => m.issue));
|
||||
const boostSet = [...new Set(boosted)];
|
||||
if (boostSet.length) {
|
||||
await db.batch(
|
||||
boostSet.map((t) => db.prepare("UPDATE topics SET boost = 1, misses = 0 WHERE issue = ?").bind(t)),
|
||||
);
|
||||
}
|
||||
|
||||
const passed = rate >= PASS_TARGET;
|
||||
const summary = [
|
||||
`## Gate — Week ${gate.week}: **${Math.round(rate * 100)}%** first-attempt (target ${Math.round(PASS_TARGET * 100)}%) — ${passed ? "✅ pass" : "❌ fail"}`,
|
||||
"",
|
||||
...lcs.map((lc) => {
|
||||
const r = results[lc];
|
||||
return `- LC ${lc} — ${r === "pass" ? "✅ pass" : r === "fail" ? "❌ fail" : "⬜ not logged (counted as fail)"}`;
|
||||
}),
|
||||
"",
|
||||
unlogged.length ? `${unlogged.length} problem(s) were never logged.` : "",
|
||||
boostSet.length
|
||||
? `Boosted topics for next week's drills: ${boostSet.map((t) => `#${t}`).join(", ")}.`
|
||||
: "No topics boosted.",
|
||||
"",
|
||||
`Live charts: ${env.PUBLIC_URL}/chart/progress.svg · progress: ${env.DOCS_URL}/progress`,
|
||||
]
|
||||
.filter((l) => l !== "")
|
||||
.join("\n");
|
||||
await gh.comment(issueNumber, summary);
|
||||
|
||||
return { scored: true, rate, reason: `scored ${Math.round(rate * 100)}%` };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* GitHub REST + GraphQL client on GH_PAT. Hand-rolled fetch, no SDK —
|
||||
* matching the repo's dependency-free automation rule.
|
||||
*/
|
||||
|
||||
export interface GitHub {
|
||||
repo: string;
|
||||
rest(path: string, init?: RequestInit): Promise<unknown>;
|
||||
graphql(query: string, variables?: Record<string, unknown>): Promise<unknown>;
|
||||
list(path: string): AsyncGenerator<unknown, void, void>;
|
||||
/** Comment first, then close — a failed close still leaves a visible note. */
|
||||
closeIssue(issue: number, comment: string): Promise<void>;
|
||||
comment(issue: number, body: string): Promise<void>;
|
||||
react(commentId: number, content: string): Promise<void>;
|
||||
}
|
||||
|
||||
const PER_PAGE = 100;
|
||||
|
||||
export function github(token: string, repo: string): GitHub {
|
||||
async function rest(path: string, init: RequestInit = {}): Promise<unknown> {
|
||||
const res = await fetch(`https://api.github.com${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
accept: "application/vnd.github+json",
|
||||
authorization: `Bearer ${token}`,
|
||||
"user-agent": "srs-api",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
...(init.body ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`);
|
||||
}
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
async function graphql(
|
||||
query: string,
|
||||
variables: Record<string, unknown> = {},
|
||||
): Promise<unknown> {
|
||||
const res = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
"user-agent": "srs-api",
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
|
||||
const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
|
||||
if (payload.errors?.length) throw new Error(payload.errors.map((e) => e.message).join("; "));
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
async function* list(path: string): AsyncGenerator<unknown, void, void> {
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
for (let page = 1; ; page++) {
|
||||
const batch = await rest(`${path}${sep}per_page=${PER_PAGE}&page=${page}`);
|
||||
if (!Array.isArray(batch)) throw new Error(`unexpected ${path} payload: not an array`);
|
||||
yield* batch;
|
||||
if (batch.length < PER_PAGE) return;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repo,
|
||||
rest,
|
||||
graphql,
|
||||
list,
|
||||
async closeIssue(issue, comment) {
|
||||
await rest(`/repos/${repo}/issues/${issue}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: comment }),
|
||||
});
|
||||
await rest(`/repos/${repo}/issues/${issue}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
|
||||
});
|
||||
},
|
||||
async comment(issue, body) {
|
||||
await rest(`/repos/${repo}/issues/${issue}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
},
|
||||
async react(commentId, content) {
|
||||
await rest(`/repos/${repo}/issues/comments/${commentId}/reactions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 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, 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",
|
||||
};
|
||||
|
||||
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 === "/badge/gate.svg") {
|
||||
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
|
||||
}
|
||||
|
||||
if (path === "/api/stats") {
|
||||
const cors = {
|
||||
"access-control-allow-origin": env.DOCS_ORIGIN,
|
||||
"access-control-allow-methods": "GET",
|
||||
vary: "Origin",
|
||||
};
|
||||
if (request.method === "OPTIONS") return new Response(null, { headers: cors });
|
||||
const stats = await buildStats(env.DB, etDate(new Date()));
|
||||
return Response.json(stats, { headers: cors });
|
||||
}
|
||||
|
||||
return new Response("srs-api", { status: path === "/" ? 200 : 404 });
|
||||
} catch (err) {
|
||||
console.error(`unhandled ${request.method} ${path}: ${err}`);
|
||||
return new Response("internal error", { status: 500 });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* DST-proof cron dispatch: crons fire at both possible UTC hours; the
|
||||
* computed ET hour decides. 8 AM daily → catalog reconcile + digest;
|
||||
* midnight Saturday → review issue (so the 8 AM digest can link to it).
|
||||
*/
|
||||
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||
const now = new Date(controller.scheduledTime);
|
||||
const hour = etHour(now);
|
||||
const date = etDate(now);
|
||||
|
||||
if (hour === 8) {
|
||||
try {
|
||||
const report = await reconcileCatalog(env.DB, github(env.GH_PAT, env.REPO));
|
||||
console.log(`catalog: ${report.topics} topics, ${report.problems} problems`);
|
||||
} catch (err) {
|
||||
console.warn(`catalog reconcile failed (digest still goes out): ${err}`);
|
||||
}
|
||||
const report = await sendDigest(env, date);
|
||||
console.log(`digest ${date}: ${report.reason}`);
|
||||
return;
|
||||
}
|
||||
if (hour === 0 && weekdayOf(date) === 6) {
|
||||
const report = await createReviewIssue(env, github(env.GH_PAT, env.REPO), date);
|
||||
console.log(`review issue ${date}: ${report.reason}`);
|
||||
return;
|
||||
}
|
||||
console.log(`cron at ET hour ${hour} on ${date}: no-op (DST guard)`);
|
||||
void ctx;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* One-tap email links: `GET /log?p=704&r=pass&d=2026-08-31&sig=<hmac>`.
|
||||
* The sig is HMAC-SHA256 over `p|r|d` with LINK_KEY (WebCrypto, hex).
|
||||
* Verification is constant-time; links older than 3 days are rejected.
|
||||
*/
|
||||
|
||||
async function hmacKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
}
|
||||
|
||||
export async function signLink(
|
||||
key: string,
|
||||
p: number,
|
||||
r: string,
|
||||
d: string,
|
||||
): Promise<string> {
|
||||
const mac = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
await hmacKey(key),
|
||||
new TextEncoder().encode(`${p}|${r}|${d}`),
|
||||
);
|
||||
return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function verifyLink(
|
||||
key: string,
|
||||
p: number,
|
||||
r: string,
|
||||
d: string,
|
||||
sig: string,
|
||||
): Promise<boolean> {
|
||||
if (!/^[0-9a-f]{64}$/.test(sig)) return false;
|
||||
const bytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) bytes[i] = Number.parseInt(sig.slice(i * 2, i * 2 + 2), 16);
|
||||
// crypto.subtle.verify is constant-time; never compare hex strings directly.
|
||||
return crypto.subtle.verify(
|
||||
"HMAC",
|
||||
await hmacKey(key),
|
||||
bytes,
|
||||
new TextEncoder().encode(`${p}|${r}|${d}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Constant-time equality for webhook signatures and admin bearer keys. */
|
||||
export async function timingSafeEqual(a: string, b: string): Promise<boolean> {
|
||||
// HMAC both sides with a random key: unequal-length inputs and content
|
||||
// differences are equally invisible to timing.
|
||||
const key = await crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, false, [
|
||||
"sign",
|
||||
]);
|
||||
const enc = new TextEncoder();
|
||||
const [ma, mb] = await Promise.all([
|
||||
crypto.subtle.sign("HMAC", key, enc.encode(a)),
|
||||
crypto.subtle.sign("HMAC", key, enc.encode(b)),
|
||||
]);
|
||||
const va = new Uint8Array(ma);
|
||||
const vb = new Uint8Array(mb);
|
||||
let diff = 0;
|
||||
for (let i = 0; i < va.length; i++) diff |= va[i]! ^ vb[i]!;
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/** GitHub webhook `X-Hub-Signature-256: sha256=<hex>` verification. */
|
||||
export async function verifyWebhook(
|
||||
secret: string,
|
||||
body: ArrayBuffer,
|
||||
header: string | null,
|
||||
): Promise<boolean> {
|
||||
if (!header?.startsWith("sha256=")) return false;
|
||||
const mac = await crypto.subtle.sign("HMAC", await hmacKey(secret), body);
|
||||
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return timingSafeEqual(header.slice(7), expected);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Best-effort mirror into the "Interview Prep" user Project — ported from
|
||||
* scripts/srs-project.ts. D1 is the truth; a GraphQL failure here becomes a
|
||||
* console warning and never blocks a state write. Only problem issues are
|
||||
* ever passed in, so topic rows' Target Date is never written.
|
||||
*
|
||||
* Field/option IDs are resolved by NAME per invocation (a Worker isolate is
|
||||
* short-lived; the two extra queries per mirror burst are irrelevant at this
|
||||
* volume and survive field re-creation).
|
||||
*/
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
const PROJECT_TITLE = "Interview Prep";
|
||||
|
||||
interface SelectField {
|
||||
id: string;
|
||||
options: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ProjectInfo {
|
||||
id: string;
|
||||
targetDate: string;
|
||||
srsStage: SelectField;
|
||||
firstAttempt: SelectField;
|
||||
}
|
||||
|
||||
export interface Mirror {
|
||||
setTargetDate(issue: number, date: string | null): Promise<void>;
|
||||
setStage(issue: number, stage: string): Promise<void>;
|
||||
setFirstAttempt(issue: number, result: string): Promise<void>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function projectMirror(gh: GitHub, owner: string): Mirror {
|
||||
const warnings: string[] = [];
|
||||
let info: ProjectInfo | undefined;
|
||||
const itemIds = new Map<number, string | undefined>();
|
||||
|
||||
async function resolve(): Promise<ProjectInfo> {
|
||||
if (info) return info;
|
||||
const data = (await gh.graphql(
|
||||
`query($owner: String!, $title: String!) {
|
||||
user(login: $owner) {
|
||||
projectsV2(first: 10, query: $title) {
|
||||
nodes {
|
||||
id title
|
||||
fields(first: 30) {
|
||||
nodes {
|
||||
... on ProjectV2FieldCommon { id name dataType }
|
||||
... on ProjectV2SingleSelectField { id name options { id name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner, title: PROJECT_TITLE },
|
||||
)) as {
|
||||
user: {
|
||||
projectsV2: {
|
||||
nodes: {
|
||||
id: string;
|
||||
title: string;
|
||||
fields: {
|
||||
nodes: { id: string; name: string; options?: { id: string; name: string }[] }[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
|
||||
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
|
||||
const select = (name: string): SelectField => {
|
||||
const f = node.fields.nodes.find((n) => n.name === name);
|
||||
if (!f?.options) throw new Error(`single-select field "${name}" missing`);
|
||||
return { id: f.id, options: Object.fromEntries(f.options.map((o) => [o.name, o.id])) };
|
||||
};
|
||||
const date = node.fields.nodes.find((n) => n.name === "Target Date");
|
||||
if (!date) throw new Error(`date field "Target Date" missing`);
|
||||
info = {
|
||||
id: node.id,
|
||||
targetDate: date.id,
|
||||
srsStage: select("SRS Stage"),
|
||||
firstAttempt: select("First Attempt"),
|
||||
};
|
||||
return info;
|
||||
}
|
||||
|
||||
async function itemId(issue: number): Promise<string> {
|
||||
if (itemIds.has(issue)) {
|
||||
const cached = itemIds.get(issue);
|
||||
if (!cached) throw new Error(`issue #${issue} is not in the project`);
|
||||
return cached;
|
||||
}
|
||||
const project = await resolve();
|
||||
const [repoOwner, repoName] = gh.repo.split("/");
|
||||
const data = (await gh.graphql(
|
||||
`query($owner: String!, $name: String!, $issue: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issue(number: $issue) {
|
||||
projectItems(first: 10, includeArchived: true) {
|
||||
nodes { id project { id } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner: repoOwner, name: repoName, issue },
|
||||
)) as {
|
||||
repository: { issue: { projectItems: { nodes: { id: string; project: { id: string } }[] } } };
|
||||
};
|
||||
const item = data.repository.issue.projectItems.nodes.find((n) => n.project.id === project.id);
|
||||
itemIds.set(issue, item?.id);
|
||||
if (!item) throw new Error(`issue #${issue} is not in the project`);
|
||||
return item.id;
|
||||
}
|
||||
|
||||
async function setField(issue: number, fieldId: string, value: object): Promise<void> {
|
||||
const project = await resolve();
|
||||
await gh.graphql(
|
||||
`mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
|
||||
updateProjectV2ItemFieldValue(
|
||||
input: { projectId: $project, itemId: $item, fieldId: $field, value: $value }
|
||||
) { projectV2Item { id } }
|
||||
}`,
|
||||
{ project: project.id, item: await itemId(issue), field: fieldId, value },
|
||||
);
|
||||
}
|
||||
|
||||
async function attempt(what: string, op: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await op();
|
||||
} catch (err) {
|
||||
const message = `mirror ${what}: ${err instanceof Error ? err.message : String(err)}`;
|
||||
warnings.push(message);
|
||||
console.warn(message); // observability picks this up; never rethrow
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
warnings,
|
||||
setTargetDate: (issue, date) =>
|
||||
attempt(`Target Date #${issue}`, async () => {
|
||||
const project = await resolve();
|
||||
if (date === null) {
|
||||
await gh.graphql(
|
||||
`mutation($project: ID!, $item: ID!, $field: ID!) {
|
||||
clearProjectV2ItemFieldValue(
|
||||
input: { projectId: $project, itemId: $item, fieldId: $field }
|
||||
) { projectV2Item { id } }
|
||||
}`,
|
||||
{ project: project.id, item: await itemId(issue), field: project.targetDate },
|
||||
);
|
||||
} else {
|
||||
await setField(issue, project.targetDate, { date });
|
||||
}
|
||||
}),
|
||||
setStage: (issue, stage) =>
|
||||
attempt(`SRS Stage #${issue}`, async () => {
|
||||
const project = await resolve();
|
||||
const option = project.srsStage.options[stage];
|
||||
if (!option) throw new Error(`no option "${stage}"`);
|
||||
await setField(issue, project.srsStage.id, { singleSelectOptionId: option });
|
||||
}),
|
||||
setFirstAttempt: (issue, result) =>
|
||||
attempt(`First Attempt #${issue}`, async () => {
|
||||
const project = await resolve();
|
||||
const option = project.firstAttempt.options[result];
|
||||
if (!option) throw new Error(`no option "${result}"`);
|
||||
await setField(issue, project.firstAttempt.id, { singleSelectOptionId: option });
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,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")));
|
||||
});
|
||||
});
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* SRS domain: ET dates, the interval ladder, deterministic sampling, and the
|
||||
* one write path for attempts — ported from scripts/srs.ts, re-homed on D1.
|
||||
*
|
||||
* D1 is the single source of truth. The stage names the review a problem must
|
||||
* pass NEXT (`+2` = due 2 days after last clean solve). Passing advances
|
||||
* new → +2 → +5 → +10 → retired; any failure resets to +2. A problem's
|
||||
* FIRST-ever log enters the ladder at +2 regardless of result: a pass earns
|
||||
* a +2 review, a fail must be re-solved just as soon.
|
||||
*
|
||||
* All dates are America/New_York calendar strings (YYYY-MM-DD); arithmetic is
|
||||
* anchored at noon UTC so DST edges cannot shift a date. Never raw Date math.
|
||||
*/
|
||||
import scheduleJson from "../data/schedule.json";
|
||||
|
||||
// ── dates (America/New_York) ─────────────────────────────────────
|
||||
|
||||
export const CAMPAIGN_START = "2026-08-17";
|
||||
export const CAMPAIGN_DAYS = 56;
|
||||
|
||||
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "America/New_York",
|
||||
dateStyle: "short",
|
||||
});
|
||||
const ET_HOUR = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "America/New_York",
|
||||
hour: "numeric",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
|
||||
/** ET calendar date of an instant. */
|
||||
export function etDate(now: Date): string {
|
||||
return ET_DATE.format(now);
|
||||
}
|
||||
|
||||
/** ET hour 0–23 of an instant — the DST-proof cron guard. */
|
||||
export function etHour(now: Date): number {
|
||||
return Number(ET_HOUR.format(now));
|
||||
}
|
||||
|
||||
/** Noon-UTC anchor: date-only arithmetic immune to DST edges. */
|
||||
function atNoon(date: string): Date {
|
||||
return new Date(`${date}T12:00:00Z`);
|
||||
}
|
||||
|
||||
export function addDays(date: string, days: number): string {
|
||||
return new Date(atNoon(date).getTime() + days * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function daysBetween(from: string, to: string): number {
|
||||
return Math.round((atNoon(to).getTime() - atNoon(from).getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/** 0 = Sunday … 6 = Saturday. */
|
||||
export function weekdayOf(date: string): number {
|
||||
return atNoon(date).getUTCDay();
|
||||
}
|
||||
|
||||
export function campaignDay(date: string): number {
|
||||
return daysBetween(CAMPAIGN_START, date) + 1;
|
||||
}
|
||||
|
||||
/** 1-based campaign week (Mon–Sun), aligned to CAMPAIGN_START. */
|
||||
export function campaignWeek(date: string): number {
|
||||
return Math.floor(daysBetween(CAMPAIGN_START, date) / 7) + 1;
|
||||
}
|
||||
|
||||
/** Monday of the date's campaign week. */
|
||||
export function weekMonday(date: string): string {
|
||||
return addDays(CAMPAIGN_START, (campaignWeek(date) - 1) * 7);
|
||||
}
|
||||
|
||||
export function isoWeek(date: string): number {
|
||||
const d = atNoon(date);
|
||||
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||
const jan1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((d.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7);
|
||||
}
|
||||
|
||||
export function prettyDate(date: string): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "UTC",
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(atNoon(date));
|
||||
}
|
||||
|
||||
// ── the schedule (bundled; humans edit api/data/schedule.json) ───
|
||||
|
||||
export const SCHEDULE: Record<string, number> = scheduleJson;
|
||||
|
||||
/** Week in which a topic was (or will be) taught. */
|
||||
export function topicWeek(topic: number): number | undefined {
|
||||
for (const [date, t] of Object.entries(SCHEDULE)) {
|
||||
if (t === topic) return campaignWeek(date);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── deterministic sampling ───────────────────────────────────────
|
||||
|
||||
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
|
||||
export function rng(seed: string): () => number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return () => {
|
||||
h = Math.imul(h ^ (h >>> 15), h | 1);
|
||||
h ^= h + Math.imul(h ^ (h >>> 7), h | 61);
|
||||
return ((h ^ (h >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** Up to n elements, Fisher–Yates order driven by the seeded PRNG. */
|
||||
export function sample<T>(pool: T[], n: number, random: () => number): T[] {
|
||||
const copy = [...pool];
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j]!, copy[i]!];
|
||||
}
|
||||
return copy.slice(0, n);
|
||||
}
|
||||
|
||||
// ── rows ─────────────────────────────────────────────────────────
|
||||
|
||||
export type Stage = "new" | "+2" | "+5" | "+10" | "retired";
|
||||
export type Result = "pass" | "fail";
|
||||
export type Kind = "first" | "review" | "drill" | "gate";
|
||||
|
||||
export interface ProblemRow {
|
||||
lc_number: number;
|
||||
issue: number;
|
||||
topic_issue: number;
|
||||
title: string;
|
||||
difficulty: string;
|
||||
set_label: string;
|
||||
stage: Stage;
|
||||
next_review: string | null;
|
||||
defer_until: string | null;
|
||||
}
|
||||
|
||||
export const INTERVAL: Record<string, number> = { "+2": 2, "+5": 5, "+10": 10 };
|
||||
const NEXT_STAGE: Record<string, Stage> = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" };
|
||||
|
||||
// ── queries ──────────────────────────────────────────────────────
|
||||
|
||||
export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow | null> {
|
||||
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
||||
}
|
||||
|
||||
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
||||
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM problems
|
||||
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||
AND (defer_until IS NULL OR defer_until <= ?1)
|
||||
ORDER BY next_review, lc_number`,
|
||||
)
|
||||
.bind(date)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blind drills: unsolved optional problems from topics taught in EARLIER
|
||||
* weeks (the current week's optional pool is reserved for Saturday's gate),
|
||||
* 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)
|
||||
ORDER BY p.lc_number`,
|
||||
)
|
||||
.all<ProblemRow & { boost: number }>();
|
||||
const pool = results.filter((p) => {
|
||||
const w = topicWeek(p.topic_issue);
|
||||
return w !== undefined && w < week;
|
||||
});
|
||||
const boosted = pool.filter((p) => p.boost === 1);
|
||||
const regular = pool.filter((p) => p.boost !== 1);
|
||||
const boostPicks = sample(boosted, Math.min(2, budget), rng(`boost-${date}`));
|
||||
const regularPicks = sample(
|
||||
regular,
|
||||
Math.min(2, Math.max(0, budget - boostPicks.length)),
|
||||
rng(`drill-${date}`),
|
||||
);
|
||||
return [...boostPicks, ...regularPicks].slice(0, budget);
|
||||
}
|
||||
|
||||
/** Consecutive days with ≥1 attempt, ending today or yesterday. */
|
||||
export async function streak(db: D1Database, today: string): Promise<number> {
|
||||
const { results } = await db
|
||||
.prepare("SELECT DISTINCT date FROM attempts ORDER BY date DESC LIMIT 90")
|
||||
.all<{ date: string }>();
|
||||
const days = new Set(results.map((r) => r.date));
|
||||
let cursor = days.has(today) ? today : addDays(today, -1);
|
||||
let n = 0;
|
||||
while (days.has(cursor)) {
|
||||
n++;
|
||||
cursor = addDays(cursor, -1);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── the one write path ───────────────────────────────────────────
|
||||
|
||||
export interface LogOutcome {
|
||||
lc: number;
|
||||
title: string;
|
||||
kind: Kind;
|
||||
result: Result;
|
||||
stage: Stage;
|
||||
next_review: string | null;
|
||||
/** First-ever attempt — caller closes the sub-issue on pass. */
|
||||
first: boolean;
|
||||
issue: number;
|
||||
/** Email one-tap replay: nothing changed. */
|
||||
duplicate: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an attempt and move the ladder. Ladder semantics live here and only
|
||||
* here — email one-taps, webhook /done lines, and gate scoring all converge.
|
||||
*
|
||||
* Email idempotency comes from the partial unique index on
|
||||
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
||||
* nothing and must not touch the ladder. Webhook corrections (pass then fail
|
||||
* on the same day) remain legal — every webhook attempt appends.
|
||||
*/
|
||||
export async function logAttempt(
|
||||
db: D1Database,
|
||||
opts: { lc: number; date: string; result: Result; source: "email" | "webhook"; gate?: boolean },
|
||||
): Promise<LogOutcome> {
|
||||
const p = await getProblem(db, opts.lc);
|
||||
const nothing: LogOutcome = {
|
||||
lc: opts.lc,
|
||||
title: "",
|
||||
kind: "review",
|
||||
result: opts.result,
|
||||
stage: "new",
|
||||
next_review: null,
|
||||
first: false,
|
||||
issue: 0,
|
||||
duplicate: false,
|
||||
};
|
||||
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
|
||||
if (p.stage === "retired") {
|
||||
return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` };
|
||||
}
|
||||
|
||||
const attempted = await db
|
||||
.prepare("SELECT 1 AS x FROM attempts WHERE lc_number = ? LIMIT 1")
|
||||
.bind(opts.lc)
|
||||
.first();
|
||||
const first = !attempted && p.stage === "new";
|
||||
const kind: Kind = opts.gate ? "gate" : first ? (p.set_label === "optional" ? "drill" : "first") : "review";
|
||||
|
||||
const inserted = await db
|
||||
.prepare(
|
||||
`INSERT INTO attempts (lc_number, date, kind, result, source) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
)
|
||||
.bind(opts.lc, opts.date, kind, opts.result, opts.source)
|
||||
.run();
|
||||
if (opts.source === "email" && inserted.meta.changes === 0) {
|
||||
return { ...nothing, title: p.title, kind, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true };
|
||||
}
|
||||
|
||||
// Ladder move. First-ever logs enter at +2 for pass AND fail.
|
||||
let stage: Stage;
|
||||
if (first) {
|
||||
stage = "+2";
|
||||
} else if (opts.result === "pass") {
|
||||
stage = NEXT_STAGE[p.stage] ?? "retired";
|
||||
} else {
|
||||
stage = "+2";
|
||||
}
|
||||
const next = stage === "retired" ? null : addDays(opts.date, INTERVAL[stage]!);
|
||||
|
||||
const writes = [
|
||||
db.prepare(
|
||||
"UPDATE problems SET stage = ?, next_review = ?, defer_until = NULL WHERE lc_number = ?",
|
||||
).bind(stage, next, opts.lc),
|
||||
];
|
||||
if (first && kind === "drill") {
|
||||
writes.push(
|
||||
db.prepare("INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (?)").bind(opts.lc),
|
||||
);
|
||||
if (opts.result === "fail") {
|
||||
writes.push(
|
||||
db.prepare("UPDATE topics SET misses = misses + 1 WHERE issue = ?").bind(p.topic_issue),
|
||||
);
|
||||
}
|
||||
}
|
||||
await db.batch(writes);
|
||||
|
||||
return {
|
||||
lc: opts.lc,
|
||||
title: p.title,
|
||||
kind,
|
||||
result: opts.result,
|
||||
stage,
|
||||
next_review: next,
|
||||
first,
|
||||
issue: p.issue,
|
||||
duplicate: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* GET /api/stats — the one JSON document behind the docs progress page.
|
||||
* Read-only aggregation over D1; shape is the page's contract, change both
|
||||
* together. A problem counts as "done" once it entered the ladder
|
||||
* (stage != 'new').
|
||||
*/
|
||||
import {
|
||||
CAMPAIGN_DAYS,
|
||||
CAMPAIGN_START,
|
||||
addDays,
|
||||
campaignDay,
|
||||
campaignWeek,
|
||||
streak,
|
||||
} from "./srs.ts";
|
||||
|
||||
const PHASE_NAMES: Record<number, string> = {
|
||||
1: "I — Linear",
|
||||
2: "II — Nodal & Grid",
|
||||
3: "III — Hierarchical",
|
||||
4: "IV — Relational",
|
||||
5: "V — Decision Space",
|
||||
};
|
||||
|
||||
export async function buildStats(db: D1Database, today: string): Promise<object> {
|
||||
const { results: phaseRows } = await db
|
||||
.prepare(
|
||||
`SELECT t.milestone AS milestone, p.set_label AS set_label,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done
|
||||
FROM problems p JOIN topics t ON t.issue = p.topic_issue
|
||||
WHERE t.milestone IS NOT NULL
|
||||
GROUP BY t.milestone, p.set_label
|
||||
ORDER BY t.milestone`,
|
||||
)
|
||||
.all<{ milestone: number; set_label: string; total: number; done: number }>();
|
||||
const phases = new Map<
|
||||
number,
|
||||
{ milestone: number; name: string } & Record<string, number | string>
|
||||
>();
|
||||
for (const row of phaseRows) {
|
||||
const phase =
|
||||
phases.get(row.milestone) ??
|
||||
({
|
||||
milestone: row.milestone,
|
||||
name: PHASE_NAMES[row.milestone] ?? `Phase ${row.milestone}`,
|
||||
core_done: 0,
|
||||
core_total: 0,
|
||||
optional_done: 0,
|
||||
optional_total: 0,
|
||||
deferred_done: 0,
|
||||
deferred_total: 0,
|
||||
} as { milestone: number; name: string } & Record<string, number | string>);
|
||||
phase[`${row.set_label}_done`] = row.done;
|
||||
phase[`${row.set_label}_total`] = row.total;
|
||||
phases.set(row.milestone, phase);
|
||||
}
|
||||
|
||||
const { results: ladderRows } = await db
|
||||
.prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage")
|
||||
.all<{ stage: string; n: number }>();
|
||||
const ladder: Record<string, number> = { new: 0, "+2": 0, "+5": 0, "+10": 0, retired: 0 };
|
||||
for (const row of ladderRows) ladder[row.stage] = row.n;
|
||||
|
||||
const { results: gates } = await db
|
||||
.prepare("SELECT week, issue, pass_rate, closed_on FROM gates ORDER BY week")
|
||||
.all<{ week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }>();
|
||||
|
||||
// Review-queue depth for the next 14 days: everything due by that day.
|
||||
const queue: { date: string; due: number }[] = [];
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const date = addDays(today, i);
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM problems
|
||||
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||
AND (defer_until IS NULL OR defer_until <= ?1)`,
|
||||
)
|
||||
.bind(date)
|
||||
.first<{ n: number }>();
|
||||
queue.push({ date, due: row?.n ?? 0 });
|
||||
}
|
||||
|
||||
const recent: { date: string; attempts: number; passes: number }[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = addDays(today, -i);
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS attempts,
|
||||
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes
|
||||
FROM attempts WHERE date = ?`,
|
||||
)
|
||||
.bind(date)
|
||||
.first<{ attempts: number; passes: number | null }>();
|
||||
recent.push({ date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 });
|
||||
}
|
||||
|
||||
return {
|
||||
generated: new Date().toISOString(),
|
||||
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
|
||||
phases: [...phases.values()],
|
||||
ladder,
|
||||
gates,
|
||||
streak: await streak(db, today),
|
||||
queue,
|
||||
recent,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user