mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
242 lines
8.9 KiB
TypeScript
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 };
|
|
}
|