Files
leetcode/api/src/digest.ts
T

226 lines
8.5 KiB
TypeScript
Raw Normal View History

/**
* 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 };
}