mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
mise ~/.config/mise/config.toml tools: crush@0.91.0
feat(api): add React Email rendering and PNG heatmap endpoint
This commit is contained in:
1 parent
6b9a259ec7
commit
881530a808
8 files changed
+1058
-168
No files matched your search
+74
-120
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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 and drill lines carry number + difficulty only —
|
||||
* never the topic, never a solution link. The learning day's core list is
|
||||
* Retrieval rules: review and drill 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 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.
|
||||
*
|
||||
@@ -26,62 +29,58 @@ import {
|
||||
streak,
|
||||
weekdayOf,
|
||||
} from "./srs.ts";
|
||||
import { type DigestData, type DigestRow, renderDigest } from "./email.tsx";
|
||||
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>`
|
||||
);
|
||||
/** 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}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildDigest(env: Env, date: string): Promise<Digest> {
|
||||
/** 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 day = campaignDay(date);
|
||||
const week = campaignWeek(date);
|
||||
const header = `${prettyDate(date)} — Day ${day}/${CAMPAIGN_DAYS} · Week ${week}`;
|
||||
const rest = weekdayOf(date) === 0;
|
||||
|
||||
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.`,
|
||||
};
|
||||
}
|
||||
// Reviews take the cap first; drills fill whatever is left.
|
||||
const due = rest ? [] : await dueReviews(db, date);
|
||||
const capped = due.slice(0, DAILY_CAP);
|
||||
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - capped.length);
|
||||
|
||||
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 data: DigestData = {
|
||||
day: prettyDate(date),
|
||||
progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`,
|
||||
streak: await streak(db, date),
|
||||
rest,
|
||||
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`,
|
||||
};
|
||||
|
||||
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];
|
||||
// New topic — the only section allowed to name problems and link them.
|
||||
const topicIssue = rest ? undefined : SCHEDULE[date];
|
||||
if (topicIssue !== undefined) {
|
||||
const topic = await db
|
||||
.prepare("SELECT name FROM topics WHERE issue = ?")
|
||||
@@ -93,57 +92,14 @@ export async function buildDigest(env: Env, date: string): Promise<Digest> {
|
||||
)
|
||||
.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");
|
||||
data.topic = {
|
||||
name: topic?.name ?? `#${topicIssue}`,
|
||||
core: core.map((p) => ({
|
||||
lc: p.lc_number,
|
||||
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
|
||||
solved: p.stage !== "new",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Saturday: the review issue already exists (created at midnight ET).
|
||||
@@ -153,40 +109,38 @@ export async function buildDigest(env: Env, date: string): Promise<Digest> {
|
||||
.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}`);
|
||||
data.gate = {
|
||||
week: campaignWeek(date),
|
||||
url: `https://github.com/${env.REPO}/issues/${gate.issue}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Footer: yesterday's log, gate rate to date, progress page.
|
||||
const yesterday = addDays(date, -1);
|
||||
// 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, kind, result FROM attempts WHERE date = ? ORDER BY id")
|
||||
.bind(yesterday)
|
||||
.all<{ lc_number: number; kind: string; result: string }>();
|
||||
.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 }>();
|
||||
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`);
|
||||
if (lastGate) data.gateRate = lastGate.pass_rate;
|
||||
|
||||
const load = reviews.length + drills.length;
|
||||
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: `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"),
|
||||
subject: `(Day ${campaignDay(date)}/${CAMPAIGN_DAYS}) LeetCode Daily Digest`,
|
||||
html,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user