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