/** * 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 +3/+7 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 { 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(); return results; } export interface CreateReport { created: boolean; issue?: number; reason: string; } export async function createReviewIssue(env: Env, gh: GitHub, date: string): Promise { 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 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 { 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 = {}; 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)}%` }; }