/** * The daily digest email — built from D1 + the bundled schedule, sent at * 8 AM ET via the send_email binding. This module owns data only: it turns * D1 rows into a `DigestData` and hands it to email.tsx, which renders both * the HTML and the plain-text alternative from that single tree (React Email * `render`), so the two can never drift. * * Retrieval rules: review and drill rows carry number + difficulty only — * never the topic, never a solution link. `DigestRow` has no field for * either, so the rule holds by construction. The learning day's core list is * the only labeled section. At most REVIEW_CAP (3) reviews per day; drills * fill toward DAILY_CAP (6) only when the due queue is light. 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, REVIEW_CAP, SCHEDULE, addDays, campaignDay, campaignWeek, dueReviews, isoWeek, levelReviews, pickDrills, prettyDate, streak, weekdayOf, } from "./srs.ts"; import { type DigestData, type DigestRow, 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}`, }; } /** 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; // 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. const due = rest ? [] : await dueReviews(db, date); const capped = due.slice(0, REVIEW_CAP); const drills = rest ? [] : 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, reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))), carried: due.length - capped.length, drills: await Promise.all(drills.map((p) => tapRow(env, p, date, false))), yesterday: { total: 0, failed: [] }, heatmapUrl: `${env.PUBLIC_URL}/chart/heatmap.png`, progressUrl: `${env.DOCS_URL}/progress`, }; // New topic — the only section allowed to name problems and link them. const topicIssue = rest ? undefined : SCHEDULE[date]; if (topicIssue !== undefined) { const topic = await db .prepare("SELECT name FROM topics WHERE issue = ?") .bind(topicIssue) .first<{ name: string }>(); const { results: core } = await db .prepare( "SELECT * FROM problems WHERE topic_issue = ? AND set_label = 'core' ORDER BY lc_number", ) .bind(topicIssue) .all(); data.topic = { name: topic?.name ?? `#${topicIssue}`, core: core.map((p) => ({ lc: p.lc_number, difficulty: p.difficulty, url: `https://github.com/${env.REPO}/issues/${p.issue}`, solved: p.stage !== "new", })), }; } // Saturday: the review issue already exists (created at midnight ET). if (weekdayOf(date) === 6) { const gate = await db .prepare("SELECT issue FROM gates WHERE week = ? AND issue IS NOT NULL") .bind(isoWeek(date)) .first<{ issue: number }>(); if (gate) { data.gate = { week: campaignWeek(date), url: `https://github.com/${env.REPO}/issues/${gate.issue}`, }; } } // Footer: yesterday's log summarised (failures named — they are the // actionable part) and the gate rate to date. const { results: logged } = await db .prepare("SELECT lc_number, result FROM attempts WHERE date = ? ORDER BY id") .bind(addDays(date, -1)) .all<{ lc_number: number; result: string }>(); data.yesterday = { total: logged.length, failed: logged.filter((a) => a.result !== "pass").map((a) => a.lc_number), }; const lastGate = await db .prepare("SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1") .first<{ pass_rate: number }>(); if (lastGate) data.gateRate = lastGate.pass_rate; return data; } export async function buildDigest(env: Env, date: string): Promise { 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 }; }