/** * 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 { 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 ( `pass` + ` · fail` ); } export async function buildDigest(env: Env, date: string): Promise { 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: `

${header}

Rest day. Nothing is due. Overdue reviews moved to Monday.

`, 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( `

${header}

`, `

🔥 streak: ${currentStreak} day${currentStreak === 1 ? "" : "s"}

`, ); 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(); const links = core .map( (p) => `LC ${p.lc_number}${p.stage === "new" ? "" : " ✓"}`, ) .join(" · "); htmlParts.push( `

New topic: ${topic?.name ?? `#${topicIssue}`}

`, `

Core: ${links}

`, ); 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(`

Reviews due (${reviews.length})

`); textParts.push(`Reviews due (${reviews.length})`); if (reviews.length) { htmlParts.push( `

Solve each from scratch. Do not open your old solution first.

`, ); for (const p of reviews) { htmlParts.push( `

LC ${p.lc_number} — ${cap(p.difficulty)} — stage ${p.stage} — ${await tapLinks(env, p.lc_number, date)}

`, ); textParts.push(`- LC ${p.lc_number} — ${cap(p.difficulty)} — stage ${p.stage}`); } if (carried > 0) { htmlParts.push(`

${carried} more carried to tomorrow (cap ${DAILY_CAP}).

`); textParts.push(`${carried} more carried to tomorrow.`); } } else { htmlParts.push(`

None.

`); textParts.push("none"); } // Blind drills — unlabeled. htmlParts.push(`

Blind drills (${drills.length})

`); textParts.push(`Blind drills (${drills.length})`); if (drills.length) { htmlParts.push( `

No topic given. Name the pattern out loud before you code.

`, ); for (const p of drills) { htmlParts.push( `

LC ${p.lc_number} — ${cap(p.difficulty)} — ${await tapLinks(env, p.lc_number, date)}

`, ); textParts.push(`- LC ${p.lc_number} — ${cap(p.difficulty)}`); } } else { htmlParts.push(`

None today.

`); 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( `

Saturday gate

`, `

Review — Week ${campaignWeek(date)} (#${gate.issue}) — timed, blind, then close the issue.

`, ); 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( `
`, `

${footerBits.join(" · ")} · progress page

`, ); textParts.push(...footerBits, `progress: ${env.DOCS_URL}/progress`); const load = reviews.length + drills.length; return { subject: `SRS — ${prettyDate(date)} — ${load} on deck`, html: `
${htmlParts.join("\n")}
`, 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 { 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 }; }