Files
leetcode/api/src/email.tsx
T

452 lines
16 KiB
TypeScript
Raw Normal View History

/**
* Presentation layer for the daily digest — React Email components rendered
* to HTML (and to the plain-text alternative) inside the Worker.
*
* This file owns *only* layout and wording. Every number, URL and signature
* is computed in digest.ts and handed over as `DigestData`, so the retrieval
* rules (review/drill rows carry number + difficulty only — never the topic,
* never a solution link) are enforced by what the data model can express:
* `DigestRow` has no title and no issue field.
*
* Dark by design. The theme is hard-coded rather than left to the client's
* dark-mode heuristics: `color-scheme: dark` tells Apple Mail and Outlook not
* to re-invert it, and the canvas colour is painted by a full-width <Section>
* table because Gmail drops styles on <body>.
*
* Other email constraints that shape the markup: inline styles only (clients
* strip <style>), real <table> layout for the problem lists so Outlook and
* Gmail agree on column alignment, React Email's <Button> for the one-tap
* links (it emits the MSO padding conditionals a bare <a> lacks), and the
* heatmap arrives as PNG — every major client refuses remote SVG.
*/
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Text,
} from "@react-email/components";
import { render } from "@react-email/render";
import type { CSSProperties } from "react";
// ── palette (GitHub dark) ────────────────────────────────────────
const CANVAS = "#010409";
const CARD = "#0d1117";
const BORDER = "#30363d";
const RULE = "#21262d";
const INK = "#e6edf3";
const MUTED = "#8b949e";
const LINK = "#58a6ff";
const GREEN = "#3fb950";
const AMBER = "#d29922";
const RED = "#f85149";
const PASS_BG = "#238636";
const FAIL_BG = "#da3633";
const GATE_BG = "#1f6feb";
const FONT = '-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif';
// Outlook's Word engine is unreliable with text-transform, so display labels
// are table lookups rather than a CSS trick.
const DIFFICULTY: Record<string, { label: string; color: string }> = {
easy: { label: "Easy", color: GREEN },
medium: { label: "Medium", color: AMBER },
hard: { label: "Hard", color: RED },
};
// The SRS ladder in plain English — "+5" means the last look was 5 days back.
const LAST_SEEN: Record<string, string> = {
new: "first look",
"+2": "2 days ago",
"+5": "5 days ago",
"+10": "10 days ago",
retired: "retired",
};
// ── data model ───────────────────────────────────────────────────
/** A review or drill line: number + difficulty + one-tap links, nothing else. */
export interface DigestRow {
lc: number;
difficulty: string;
/** Reviews only — drills are unstaged by design. */
stage?: string;
passUrl: string;
failUrl: string;
}
export interface DigestData {
/** "Tuesday, August 25" */
day: string;
/** "Day 9 of 56 · Week 2" */
progress: string;
streak: number;
rest: boolean;
topic?: { name: string; core: { lc: number; url: string; solved: boolean }[] };
reviews: DigestRow[];
/** Reviews past the daily cap; they simply stay due. */
carried: number;
drills: DigestRow[];
gate?: { week: number; url: string };
yesterday: { total: number; failed: number[] };
gateRate?: number;
/** PNG, not SVG — email clients refuse the latter. */
heatmapUrl: string;
progressUrl: string;
}
// ── styles ───────────────────────────────────────────────────────
const page: CSSProperties = { backgroundColor: CANVAS, padding: "28px 0" };
const card: CSSProperties = {
backgroundColor: CARD,
border: `1px solid ${BORDER}`,
borderRadius: "10px",
margin: "0 auto",
maxWidth: "600px",
padding: "28px 24px",
};
const h1: CSSProperties = { color: INK, fontSize: "20px", fontWeight: 600, lineHeight: "26px", margin: 0 };
const label: CSSProperties = {
color: MUTED,
fontSize: "11px",
fontWeight: 700,
letterSpacing: "0.8px",
margin: "0 0 10px",
textTransform: "uppercase",
};
const hint: CSSProperties = { color: MUTED, fontSize: "13px", lineHeight: "19px", margin: "0 0 12px" };
const th: CSSProperties = {
borderBottom: `1px solid ${BORDER}`,
color: MUTED,
fontSize: "12px",
fontWeight: 400,
padding: "0 0 8px",
textAlign: "left",
};
const td: CSSProperties = {
borderBottom: `1px solid ${RULE}`,
color: INK,
fontSize: "14px",
padding: "12px 0",
textAlign: "left",
};
const tap: CSSProperties = {
borderRadius: "6px",
color: "#ffffff",
display: "inline-block",
fontSize: "12px",
fontWeight: 600,
lineHeight: "12px",
padding: "9px 15px",
textDecoration: "none",
};
/**
* A problem list as a real table. The "last seen" column appears only for
* reviews, which keeps drills at three columns on a phone.
*/
function ProblemTable({ rows, staged }: { rows: DigestRow[]; staged: boolean }) {
return (
<table
width="100%"
border={0}
cellPadding={0}
cellSpacing={0}
style={{ borderCollapse: "collapse", width: "100%" }}
>
<thead>
<tr>
<th style={th}>Problem</th>
<th style={th}>Level</th>
{staged ? <th style={th}>Last seen</th> : null}
<th style={{ ...th, textAlign: "right" }}>How did it go?</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const difficulty = DIFFICULTY[row.difficulty];
return (
<tr key={row.lc}>
<td style={{ ...td, fontWeight: 600 }}>LC {row.lc}</td>
<td style={{ ...td, color: difficulty?.color ?? INK }}>{difficulty?.label ?? row.difficulty}</td>
{staged ? (
<td style={{ ...td, color: MUTED }}>{(row.stage && LAST_SEEN[row.stage]) ?? row.stage}</td>
) : null}
<td style={{ ...td, textAlign: "right", whiteSpace: "nowrap" }}>
<Button href={row.passUrl} style={{ ...tap, backgroundColor: PASS_BG }}>
got it
</Button>
<Button href={row.failUrl} style={{ ...tap, backgroundColor: FAIL_BG, marginLeft: "6px" }}>
missed it
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
function Block({
title,
note,
rows,
staged,
empty,
}: {
title: string;
note: string;
rows: DigestRow[];
staged: boolean;
empty: string;
}) {
return (
<Section style={{ marginTop: "28px" }}>
<Text style={label}>{title}</Text>
{rows.length === 0 ? (
<Text style={{ ...hint, margin: 0 }}>{empty}</Text>
) : (
<>
<Text style={hint}>{note}</Text>
<ProblemTable rows={rows} staged={staged} />
</>
)}
</Section>
);
}
// ── email ────────────────────────────────────────────────────────
function DigestEmail({ data }: { data: DigestData }) {
const load = data.reviews.length + data.drills.length;
const preview = data.rest
? "Rest day — nothing to do but rest."
: `${load} to work through today${data.topic ? `, starting with ${data.topic.name}` : ""}.`;
const passed = data.yesterday.total - data.yesterday.failed.length;
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
return (
<Html lang="en" dir="ltr">
<Head>
<meta name="color-scheme" content="dark" />
<meta name="supported-color-schemes" content="dark" />
</Head>
<Preview>{preview}</Preview>
<Body style={{ backgroundColor: CANVAS, fontFamily: FONT, margin: 0, padding: 0 }}>
<Section style={page}>
<Container style={card}>
<Heading as="h1" style={h1}>
{data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`}
</Heading>
<Text style={{ color: MUTED, fontSize: "13px", margin: "8px 0 0" }}>
{data.progress}
{data.rest
? " · nothing is due today, and anything overdue waits for Monday."
: data.streak === 0
? " · no streak going yet — today is a good day to start one."
: ` · 🔥 ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`}
</Text>
{data.rest ? null : (
<>
{data.topic ? (
<Section style={{ marginTop: "28px" }}>
<Text style={label}>Something new today</Text>
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
{data.topic.name}
</Text>
<Text style={{ ...hint, margin: 0 }}>
Work through these ticked ones you have already solved:{" "}
{data.topic.core.map((p, i) => (
<span key={p.lc}>
{i > 0 ? " · " : ""}
<Link href={p.url} style={{ color: p.solved ? MUTED : LINK, textDecoration: "none" }}>
LC {p.lc}
</Link>
{p.solved ? <span style={{ color: GREEN }}> </span> : null}
</span>
))}
</Text>
</Section>
) : null}
<Block
title="Time to see these again"
note="Solve each one from scratch, and resist opening your old answer first."
rows={data.reviews}
staged
empty="Nothing is due for review today."
/>
{data.carried > 0 ? (
<Text style={{ ...hint, margin: "12px 0 0" }}>
{data.carried} more {data.carried === 1 ? "is" : "are"} waiting they come back
tomorrow, oldest first.
</Text>
) : null}
<Block
title="Cold start, no hints"
note="You are not told the topic. Say the pattern out loud before you write a line."
rows={data.drills}
staged={false}
empty="No cold starts today."
/>
{data.gate ? (
<Section style={{ marginTop: "28px" }}>
<Text style={label}>Weekly checkpoint</Text>
<Text style={hint}>Timed and blind. Close the issue when you are done.</Text>
<Button
href={data.gate.url}
style={{ ...tap, backgroundColor: GATE_BG, fontSize: "13px", padding: "11px 18px" }}
>
Open week {data.gate.week} review
</Button>
</Section>
) : null}
</>
)}
{/* Eight weeks at a glance — the one thing here that is pure
encouragement rather than instruction. */}
<Section style={{ marginTop: "28px" }}>
<Text style={label}>Every day you showed up</Text>
<Link href={data.progressUrl}>
<Img
src={data.heatmapUrl}
alt="Attempt heatmap across the eight-week campaign"
width="552"
style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }}
/>
</Link>
</Section>
<Hr style={{ borderColor: BORDER, margin: "28px 0 18px" }} />
<Text style={{ color: MUTED, fontSize: "12px", lineHeight: "19px", margin: 0 }}>
{data.yesterday.total === 0
? "You did not log anything yesterday."
: `Yesterday you logged ${data.yesterday.total} and got ${passed} of them` +
(missed ? `; ${missed} got away.` : ".")}{" "}
{data.gateRate === undefined
? "No checkpoints scored yet."
: `Checkpoints are running at ${Math.round(data.gateRate * 100)}%.`}{" "}
<Link href={data.progressUrl} style={{ color: LINK, textDecoration: "none" }}>
See the full picture
</Link>
</Text>
</Container>
</Section>
</Body>
</Html>
);
}
/**
* The plain-text alternative. React Email's `plainText` mode flattens the
* problem tables into one unreadable run, so text gets its own writer — fed
* by the same `DigestData`, so the numbers and links can never disagree with
* the HTML even though the wording lives in two places.
*/
function plainDigest(data: DigestData): string {
const out: string[] = [
data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`,
data.progress +
(data.rest
? " · nothing is due today, and anything overdue waits for Monday."
: data.streak === 0
? " · no streak going yet — today is a good day to start one."
: ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`),
];
if (!data.rest) {
if (data.topic) {
out.push(
"",
`SOMETHING NEW TODAY — ${data.topic.name}`,
"Work through these; the ones marked done you have already solved.",
...data.topic.core.map((p) => ` LC ${p.lc}${p.solved ? " (done)" : ""}${p.url}`),
);
}
const lists: [string, string, DigestRow[], string][] = [
[
"TIME TO SEE THESE AGAIN",
"Solve each one from scratch, and resist opening your old answer first.",
data.reviews,
"Nothing is due for review today.",
],
[
"COLD START, NO HINTS",
"You are not told the topic. Say the pattern out loud before you write a line.",
data.drills,
"No cold starts today.",
],
];
for (const [title, note, rows, empty] of lists) {
out.push("", title);
if (rows.length === 0) {
out.push(empty);
continue;
}
out.push(note);
for (const row of rows) {
const seen = row.stage ? `, last seen ${LAST_SEEN[row.stage] ?? row.stage}` : "";
out.push(
` LC ${row.lc}${DIFFICULTY[row.difficulty]?.label ?? row.difficulty}${seen}`,
` got it: ${row.passUrl}`,
` missed it: ${row.failUrl}`,
);
}
}
if (data.carried > 0) {
out.push(
"",
`${data.carried} more ${data.carried === 1 ? "is" : "are"} waiting — they come back tomorrow, oldest first.`,
);
}
if (data.gate) {
out.push(
"",
"WEEKLY CHECKPOINT",
"Timed and blind. Close the issue when you are done.",
` Week ${data.gate.week} review — ${data.gate.url}`,
);
}
}
const passed = data.yesterday.total - data.yesterday.failed.length;
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
out.push(
"",
"─".repeat(48),
data.yesterday.total === 0
? "You did not log anything yesterday."
: `Yesterday you logged ${data.yesterday.total} and got ${passed} of them` +
(missed ? `; ${missed} got away.` : "."),
data.gateRate === undefined
? "No checkpoints scored yet."
: `Checkpoints are running at ${Math.round(data.gateRate * 100)}%.`,
`Every day you showed up: ${data.heatmapUrl}`,
`See the full picture: ${data.progressUrl}`,
);
return out.join("\n");
}
/**
* The HTML body plus its plain-text alternative — always sent as a pair so a
* client that refuses HTML still gets the same problems and the same links.
*/
export async function renderDigest(data: DigestData): Promise<{ html: string; text: string }> {
return { html: await render(<DigestEmail data={data} />), text: plainDigest(data) };
}