mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
feat(api): add SRS Cloudflare Worker with email digest, charts, and D1 integration
This commit is contained in:
1 parent
f551878961
commit
a3431da300
21 files changed
+3166
No files matched your search
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 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 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.
|
||||
*
|
||||
* 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 { type DigestData, type DigestRow, renderDigest } from "./email.tsx";
|
||||
import { signLink } from "./links.ts";
|
||||
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
// 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 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`,
|
||||
};
|
||||
|
||||
// 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 = ?")
|
||||
.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>();
|
||||
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).
|
||||
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 };
|
||||
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user