2026-08-25 11:19:19 -04:00
|
|
|
/**
|
|
|
|
|
* 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";
|
2026-08-26 10:34:24 -04:00
|
|
|
import { REVIEW_CAP } from "./srs.ts";
|
2026-08-25 11:19:19 -04:00
|
|
|
|
|
|
|
|
// ── 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 },
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-31 10:41:45 -04:00
|
|
|
// The SRS ladder in plain English — stage "+3" means the last clean look was
|
|
|
|
|
// three days back, so that is what the reader is told.
|
2026-08-25 11:19:19 -04:00
|
|
|
const LAST_SEEN: Record<string, string> = {
|
|
|
|
|
new: "first look",
|
2026-08-31 10:41:45 -04:00
|
|
|
"+3": "3 days ago",
|
|
|
|
|
"+7": "7 days ago",
|
2026-08-25 11:19:19 -04:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 12:14:25 -04:00
|
|
|
/** A plan-named problem — the only rows allowed a link. */
|
2026-08-26 10:34:24 -04:00
|
|
|
export interface TopicRow {
|
|
|
|
|
lc: number;
|
|
|
|
|
difficulty: string;
|
|
|
|
|
url: string;
|
|
|
|
|
solved: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 12:14:25 -04:00
|
|
|
/** One learning-day topic: the core list, then the optional "if time" list. */
|
|
|
|
|
export interface TopicSection {
|
|
|
|
|
name: string;
|
|
|
|
|
core: TopicRow[];
|
|
|
|
|
ifTime: TopicRow[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-25 11:19:19 -04:00
|
|
|
export interface DigestData {
|
|
|
|
|
/** "Tuesday, August 25" */
|
|
|
|
|
day: string;
|
|
|
|
|
/** "Day 9 of 56 · Week 2" */
|
|
|
|
|
progress: string;
|
|
|
|
|
streak: number;
|
|
|
|
|
rest: boolean;
|
2026-09-21 12:14:25 -04:00
|
|
|
/** Topics taught today, in plan order — empty on every other day. */
|
|
|
|
|
topics: TopicSection[];
|
|
|
|
|
/** A set day's problems, assigned by number rather than by topic. */
|
|
|
|
|
assigned?: TopicRow[];
|
2026-08-25 11:19:19 -04:00
|
|
|
reviews: DigestRow[];
|
2026-08-26 10:34:24 -04:00
|
|
|
/** Reviews past the daily cap; levelled onto the coming days. */
|
2026-08-25 11:19:19 -04:00
|
|
|
carried: number;
|
|
|
|
|
drills: DigestRow[];
|
2026-09-21 12:14:25 -04:00
|
|
|
/** A sweep day's miss log: present (even empty) only on a sweep day. */
|
|
|
|
|
sweep?: DigestRow[];
|
2026-08-25 11:19:19 -04:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 10:34:24 -04:00
|
|
|
/**
|
|
|
|
|
* The learning day's core list as the same table shape as reviews — this is
|
|
|
|
|
* the one section allowed to link problems, and ticks replace tap buttons.
|
|
|
|
|
*/
|
|
|
|
|
function TopicTable({ rows }: { rows: TopicRow[] }) {
|
|
|
|
|
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>
|
|
|
|
|
<th style={{ ...th, textAlign: "right" }}>Solved</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{rows.map((p) => {
|
|
|
|
|
const difficulty = DIFFICULTY[p.difficulty];
|
|
|
|
|
return (
|
|
|
|
|
<tr key={p.lc}>
|
|
|
|
|
<td style={{ ...td, fontWeight: 600 }}>
|
|
|
|
|
<Link href={p.url} style={{ color: p.solved ? MUTED : LINK, textDecoration: "none" }}>
|
|
|
|
|
LC {p.lc}
|
|
|
|
|
</Link>
|
|
|
|
|
</td>
|
|
|
|
|
<td style={{ ...td, color: difficulty?.color ?? INK }}>{difficulty?.label ?? p.difficulty}</td>
|
|
|
|
|
<td style={{ ...td, color: p.solved ? GREEN : MUTED, textAlign: "right" }}>
|
|
|
|
|
{p.solved ? "✓" : "—"}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-25 11:19:19 -04:00
|
|
|
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 }) {
|
2026-09-21 12:14:25 -04:00
|
|
|
const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
|
|
|
|
|
const lead = data.topics[0]
|
|
|
|
|
? `, starting with ${data.topics[0].name}`
|
|
|
|
|
: data.assigned
|
|
|
|
|
? ", starting with today's set"
|
|
|
|
|
: data.sweep
|
|
|
|
|
? " — sweep day, nothing new"
|
|
|
|
|
: "";
|
|
|
|
|
const preview = data.rest ? "Rest day — nothing to do but rest." : `${load} to work through today${lead}.`;
|
2026-08-25 11:19:19 -04:00
|
|
|
|
|
|
|
|
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
|
2026-08-31 10:41:45 -04:00
|
|
|
? " · the ladder never books a Sunday, so nothing is due — anything still open waits for Monday."
|
2026-08-25 11:19:19 -04:00
|
|
|
: 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 : (
|
|
|
|
|
<>
|
2026-09-21 12:14:25 -04:00
|
|
|
{data.topics.map((topic, i) => (
|
|
|
|
|
<Section key={topic.name} style={{ marginTop: i === 0 ? "28px" : "20px" }}>
|
|
|
|
|
{i === 0 ? (
|
|
|
|
|
<Text style={label}>
|
|
|
|
|
{data.topics.length === 1 ? "Something new today" : "Two new things today"}
|
|
|
|
|
</Text>
|
|
|
|
|
) : null}
|
2026-08-25 11:19:19 -04:00
|
|
|
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
2026-09-21 12:14:25 -04:00
|
|
|
{topic.name}
|
2026-08-25 11:19:19 -04:00
|
|
|
</Text>
|
2026-08-26 10:34:24 -04:00
|
|
|
<Text style={hint}>Work through these from the top — a tick means it is already solved.</Text>
|
2026-09-21 12:14:25 -04:00
|
|
|
<TopicTable rows={topic.core} />
|
|
|
|
|
{topic.ifTime.length > 0 ? (
|
|
|
|
|
<>
|
|
|
|
|
<Text style={{ ...hint, margin: "12px 0 8px" }}>
|
|
|
|
|
If time — only once the core is done inside the clock.
|
|
|
|
|
</Text>
|
|
|
|
|
<TopicTable rows={topic.ifTime} />
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
2026-08-25 11:19:19 -04:00
|
|
|
</Section>
|
2026-09-21 12:14:25 -04:00
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{data.assigned ? (
|
|
|
|
|
<Section style={{ marginTop: "28px" }}>
|
|
|
|
|
<Text style={label}>Today's set</Text>
|
|
|
|
|
<Text style={hint}>
|
|
|
|
|
Picked by number, not by topic — all of them, in order, inside the clock.
|
|
|
|
|
</Text>
|
|
|
|
|
<TopicTable rows={data.assigned} />
|
|
|
|
|
</Section>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{data.sweep ? (
|
|
|
|
|
<Block
|
|
|
|
|
title="Sweep — redo every red"
|
|
|
|
|
note="Every problem still standing on a miss. Nothing new today; clear these first."
|
|
|
|
|
rows={data.sweep}
|
|
|
|
|
staged
|
|
|
|
|
empty="The miss log is clear — nothing is standing on a fail."
|
|
|
|
|
/>
|
2026-08-25 11:19:19 -04:00
|
|
|
) : 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" }}>
|
2026-08-26 10:34:24 -04:00
|
|
|
{data.carried} more {data.carried === 1 ? "is" : "are"} waiting — spread over the
|
|
|
|
|
coming days, oldest first, never more than {REVIEW_CAP} a day.
|
2026-08-25 11:19:19 -04:00
|
|
|
</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
|
2026-08-31 10:41:45 -04:00
|
|
|
? " · the ladder never books a Sunday, so nothing is due — anything still open waits for Monday."
|
2026-08-25 11:19:19 -04:00
|
|
|
: 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.`),
|
|
|
|
|
];
|
|
|
|
|
|
2026-09-21 12:14:25 -04:00
|
|
|
const linked = (p: TopicRow) =>
|
|
|
|
|
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`;
|
|
|
|
|
|
2026-08-25 11:19:19 -04:00
|
|
|
if (!data.rest) {
|
2026-09-21 12:14:25 -04:00
|
|
|
for (const topic of data.topics) {
|
2026-08-25 11:19:19 -04:00
|
|
|
out.push(
|
|
|
|
|
"",
|
2026-09-21 12:14:25 -04:00
|
|
|
`SOMETHING NEW TODAY — ${topic.name}`,
|
2026-08-26 10:34:24 -04:00
|
|
|
"Work through these from the top; the ones marked done you have already solved.",
|
2026-09-21 12:14:25 -04:00
|
|
|
...topic.core.map(linked),
|
|
|
|
|
);
|
|
|
|
|
if (topic.ifTime.length > 0) {
|
|
|
|
|
out.push(" If time — only once the core is done inside the clock:", ...topic.ifTime.map(linked));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (data.assigned) {
|
|
|
|
|
out.push(
|
|
|
|
|
"",
|
|
|
|
|
"TODAY'S SET",
|
|
|
|
|
"Picked by number, not by topic — all of them, in order, inside the clock.",
|
|
|
|
|
...data.assigned.map(linked),
|
2026-08-25 11:19:19 -04:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.",
|
|
|
|
|
],
|
|
|
|
|
];
|
2026-09-21 12:14:25 -04:00
|
|
|
if (data.sweep) {
|
|
|
|
|
lists.unshift([
|
|
|
|
|
"SWEEP — REDO EVERY RED",
|
|
|
|
|
"Every problem still standing on a miss. Nothing new today; clear these first.",
|
|
|
|
|
data.sweep,
|
|
|
|
|
"The miss log is clear — nothing is standing on a fail.",
|
|
|
|
|
]);
|
|
|
|
|
}
|
2026-08-25 11:19:19 -04:00
|
|
|
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(
|
|
|
|
|
"",
|
2026-08-26 10:34:24 -04:00
|
|
|
`${data.carried} more ${data.carried === 1 ? "is" : "are"} waiting — spread over the coming days, oldest first, never more than ${REVIEW_CAP} a day.`,
|
2026-08-25 11:19:19 -04:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
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) };
|
|
|
|
|
}
|