Files
leetcode/apps/api/src/digest.ts
T

242 lines
8.9 KiB
TypeScript

/**
* 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<DigestRow> {
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<ProblemRow[]> {
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<ProblemRow>();
return results;
}
/** Everything the email needs, read straight out of D1 and the schedule. */
export async function collectDigest(env: Env, date: string): Promise<DigestData> {
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<ProblemRow>();
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<Digest> {
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<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 };
// 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 };
}