/** * 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, drill and sweep 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 plan's own sections — a * learning day's topics (core, then the optional "if time" list) and a set * day's problems — are the only labeled, linked ones. At most REVIEW_CAP (3) * reviews per day; drills fill toward DAILY_CAP (6) only when the due queue * is light, and never on a sweep day, which assigns every open miss and * nothing new. Overflow is levelled, not stacked: sendDigest gives every * review past the cap a concrete future date, ≤ REVIEW_CAP per day, oldest * first. Sunday is a two-line rest note. * * Idempotency: email_log keys sends by ET date — a same-day re-send is a * no-op unless forced; the body itself is deterministic (drill picks are * seeded by the date). Levelling keeps that: it runs once, before the send, * and a re-run finds nothing left over the cap. */ import { CAMPAIGN_DAYS, type ProblemRow, RESERVED, REVIEW_CAP, SCHEDULE, type ScheduledDay, addDays, campaignDay, campaignWeek, dueReviews, isoWeek, levelReviews, lookupProblems, pickDrills, prettyDate, streak, weekdayOf, } from "./srs.ts"; import { type DigestData, type DigestRow, type TopicRow, renderDigest } from "./email.tsx"; import { signLink } from "./links.ts"; /** Total daily ceiling: drills only fill what the whole due queue leaves. */ const DAILY_CAP = 6; export interface Digest { subject: string; html: string; text: string; } /** A problem row plus its signed one-tap pass/fail URLs. */ async function tapRow(env: Env, p: ProblemRow, date: string, staged: boolean): Promise { 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}`, }; } /** A plan-named problem: linked to its issue, ticked once it has entered the ladder. */ function topicRow(env: Env, p: ProblemRow): TopicRow { return { lc: p.lc_number, difficulty: p.difficulty, url: `https://github.com/${env.REPO}/issues/${p.issue}`, solved: p.stage !== "new", }; } /** Problems whose most recent attempt failed — the miss log a sweep day works through. */ async function openMisses(db: D1Database): Promise { const { results } = await db .prepare( `SELECT p.* FROM problems p JOIN attempts a ON a.lc_number = p.lc_number WHERE a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number) AND a.result = 'fail' AND p.stage != 'retired' ORDER BY a.date, p.lc_number`, ) .all(); return results; } /** Everything the email needs, read straight out of D1 and the schedule. */ export async function collectDigest(env: Env, date: string): Promise { const db = env.DB; const rest = weekdayOf(date) === 0; const plan: ScheduledDay = rest ? {} : (SCHEDULE[date] ?? {}); // Reviews surface at most REVIEW_CAP; drills fill toward DAILY_CAP only // when the WHOLE due queue is light — a heavy backlog must never trade // review slots for brand-new drill problems — and never on a sweep day. const due = rest ? [] : await dueReviews(db, date); const capped = due.slice(0, REVIEW_CAP); const drills = rest || plan.sweep ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP)); const data: DigestData = { day: prettyDate(date), progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`, streak: await streak(db, date), rest, topics: [], 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 topics — the plan's sections, the only ones allowed to name problems // and link them. Core first; the optional set is "if time", minus anything // the plan has reserved for a gate or a set day. for (const topicIssue of plan.topics ?? []) { const topic = await db .prepare("SELECT name FROM topics WHERE issue = ?") .bind(topicIssue) .first<{ name: string }>(); const { results } = await db .prepare( "SELECT * FROM problems WHERE topic_issue = ? AND set_label IN ('core', 'optional') ORDER BY lc_number", ) .bind(topicIssue) .all(); data.topics.push({ name: topic?.name ?? `#${topicIssue}`, core: results.filter((p) => p.set_label === "core").map((p) => topicRow(env, p)), ifTime: results .filter((p) => p.set_label === "optional" && !RESERVED.has(p.lc_number)) .map((p) => topicRow(env, p)), }); } // A set day assigns problems by number rather than by topic. if (plan.problems) { const { rows, missing } = await lookupProblems(db, plan.problems); if (missing.length) console.warn(`digest ${date}: planned problems not in the catalog: ${missing.join(", ")}`); data.assigned = rows.map((p) => topicRow(env, p)); } // A sweep day works the miss log: every problem still standing on a // failure, minus the ones today's review list already carries. if (plan.sweep) { const reviewed = new Set(capped.map((p) => p.lc_number)); const misses = (await openMisses(db)).filter((p) => !reviewed.has(p.lc_number)); data.sweep = await Promise.all(misses.map((p) => tapRow(env, p, date, true))); } // 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 { 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 { const digest = await buildDigest(env, date); if (opts.dry) return { sent: false, reason: "dry run", digest }; const already = await env.DB.prepare("SELECT sent_at FROM email_log WHERE date = ?") .bind(date) .first(); if (already && !opts.force) return { sent: false, reason: "already sent today", digest }; // The digest above was built against the un-levelled queue (so `carried` // reports the real backlog); now give everything past the cap its future // date. Dry runs never reach here, and a forced re-send finds nothing left. await levelReviews(env.DB, date); await env.EMAIL.send({ to: env.TO_EMAIL, from: { email: env.FROM_EMAIL, name: "SRS" }, subject: digest.subject, html: digest.html, text: digest.text, }); await env.DB.prepare( "INSERT INTO email_log (date, sent_at) VALUES (?, ?) ON CONFLICT(date) DO UPDATE SET sent_at = excluded.sent_at", ) .bind(date, new Date().toISOString()) .run(); return { sent: true, reason: already ? "forced re-send" : "sent", digest }; }