Migrate SRS to Cloudflare Worker: D1 state, email digest, review issues, live charts

This commit is contained in:
Prad Nukala
2026-08-24 19:11:07 -04:00
parent 6be5d55366
commit 7213cc1c8a
40 changed files with 2933 additions and 2132 deletions
+351
View File
@@ -0,0 +1,351 @@
/**
* Progress dashboard island — the expanded, always-current version of the
* daily email footer. Rendered on /progress; fetches campaign state from the
* SRS Worker at page load, entirely client-side, so `blume build` never
* depends on the API being up. API_BASE is a placeholder stamped in after the
* Worker deploys — do not inline it anywhere else.
*
* Invariants: the component never throws on malformed API data (a shape guard
* downgrades to the error state), and the first frame is always the loading
* state so server and client render identically.
*/
import { useEffect, useState, type CSSProperties } from "react";
export const client = "load";
const API_BASE = "https://srs-api.prdlk.workers.dev";
// ── /api/stats response shape ────────────────────────────────────
const STAGES = ["new", "+2", "+5", "+10", "retired"] as const;
type Stage = (typeof STAGES)[number];
interface Stats {
generated: string;
campaign: { day: number; week: number; start: string; days: number };
phases: {
milestone: number;
name: string;
core_done: number;
core_total: number;
optional_done: number;
optional_total: number;
deferred_done: number;
deferred_total: number;
}[];
ladder: Record<Stage, number>;
gates: {
week: number;
issue: number | null;
pass_rate: number | null;
closed_on: string | null;
}[];
streak: number;
queue: { date: string; due: number }[];
recent: { date: string; attempts: number; passes: number }[];
}
function isStats(value: unknown): value is Stats {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v.campaign === "object" &&
v.campaign !== null &&
typeof v.ladder === "object" &&
v.ladder !== null &&
Array.isArray(v.phases) &&
Array.isArray(v.gates) &&
Array.isArray(v.queue) &&
Array.isArray(v.recent) &&
typeof v.streak === "number"
);
}
// ── formatting ───────────────────────────────────────────────────
/** Legacy gates stored integer percent; the Worker may emit a 01 fraction. */
function percent(rate: number): string {
return `${Math.round(rate <= 1 ? rate * 100 : rate)}%`;
}
/** "2026-08-24" → "Mon 24" without timezone drift. */
function dayLabel(iso: string): string {
const d = new Date(`${iso}T00:00:00`);
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
return `${weekday} ${iso.slice(8)}`;
}
// ── shared styles (theme tokens only — no hand-rolled palette) ───
const card: CSSProperties = {
border: "1px solid var(--blume-border)",
borderRadius: "var(--blume-radius)",
padding: "0.75rem 1rem",
marginBottom: "1rem",
};
const muted: CSSProperties = {
color: "var(--blume-muted-foreground)",
fontSize: "0.85em",
};
const heading: CSSProperties = {
fontWeight: 600,
marginBottom: "0.5rem",
};
function Bar({ done, total }: { done: number; total: number }) {
const ratio = total > 0 ? Math.min(done / total, 1) : 0;
return (
<span
style={{
display: "inline-block",
width: "10rem",
maxWidth: "40vw",
height: "0.5rem",
borderRadius: "var(--blume-radius)",
background: "var(--blume-muted)",
verticalAlign: "middle",
overflow: "hidden",
}}
>
<span
style={{
display: "block",
width: `${ratio * 100}%`,
height: "100%",
background: "var(--blume-accent)",
}}
/>
</span>
);
}
// ── sections ─────────────────────────────────────────────────────
function Header({ stats }: { stats: Stats }) {
const { campaign, streak } = stats;
return (
<div style={{ ...card, display: "flex", gap: "1.5rem", flexWrap: "wrap" }}>
<span>
<strong>
Day {campaign.day}/{campaign.days}
</strong>
</span>
<span>Week {campaign.week}</span>
<span>
Streak: <strong>{streak}</strong> day{streak === 1 ? "" : "s"}
</span>
<span style={muted}>started {campaign.start}</span>
</div>
);
}
function Phases({ stats }: { stats: Stats }) {
return (
<div style={card}>
<div style={heading}>Phases</div>
<table style={{ borderCollapse: "collapse", width: "100%" }}>
<tbody>
{stats.phases.map((p) => (
<tr key={p.milestone}>
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>{p.name}</td>
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
<Bar done={p.core_done} total={p.core_total} />{" "}
<span style={muted}>
core {p.core_done}/{p.core_total}
</span>
</td>
<td style={{ padding: "0.2rem 0" }}>
<span style={muted}>
optional {p.optional_done}/{p.optional_total}
{p.deferred_total > 0 &&
` · deferred ${p.deferred_done}/${p.deferred_total}`}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function Ladder({ stats }: { stats: Stats }) {
return (
<div style={card}>
<div style={heading}>SRS ladder</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{STAGES.map((stage) => (
<span
key={stage}
style={{
background: "var(--blume-muted)",
borderRadius: "var(--blume-radius)",
padding: "0.35rem 0.75rem",
}}
>
<span style={muted}>{stage}</span>{" "}
<strong>{stats.ladder[stage] ?? 0}</strong>
</span>
))}
</div>
</div>
);
}
function Queue({ stats }: { stats: Stats }) {
const max = Math.max(1, ...stats.queue.map((q) => q.due));
return (
<div style={card}>
<div style={heading}>Review queue next 14 days</div>
<table style={{ borderCollapse: "collapse" }}>
<tbody>
{stats.queue.map((q) => (
<tr key={q.date}>
<td style={{ ...muted, padding: "0.1rem 1rem 0.1rem 0" }}>
{dayLabel(q.date)}
</td>
<td style={{ padding: "0.1rem 0.75rem 0.1rem 0" }}>
<Bar done={q.due} total={max} />
</td>
<td>{q.due}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function Gates({ stats }: { stats: Stats }) {
return (
<div style={card}>
<div style={heading}>Gate log</div>
{stats.gates.length === 0 ? (
<span style={muted}>No gates yet.</span>
) : (
<table style={{ borderCollapse: "collapse", width: "100%" }}>
<thead>
<tr>
{["Week", "Issue", "Pass rate", "Closed"].map((h) => (
<th key={h} style={{ ...muted, textAlign: "left", padding: "0.2rem 1rem 0.2rem 0" }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{stats.gates.map((g) => (
<tr key={g.week}>
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>{g.week}</td>
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
{g.issue === null ? (
<span style={muted}></span>
) : (
<a
href={`https://github.com/prdlk/leetcode/issues/${g.issue}`}
target="_blank"
rel="noreferrer"
>
#{g.issue}
</a>
)}
</td>
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
{g.pass_rate === null ? (
<span style={muted}>open</span>
) : (
percent(g.pass_rate)
)}
</td>
<td style={{ padding: "0.2rem 0" }}>
{g.closed_on ?? <span style={muted}></span>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
function Recent({ stats }: { stats: Stats }) {
return (
<div style={card}>
<div style={heading}>Last 7 days</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{stats.recent.map((r) => (
<span
key={r.date}
style={{
border: "1px solid var(--blume-border)",
borderRadius: "var(--blume-radius)",
padding: "0.35rem 0.6rem",
textAlign: "center",
}}
>
<span style={{ ...muted, display: "block" }}>{dayLabel(r.date)}</span>
<strong>{r.passes}</strong>
<span style={muted}>/{r.attempts}</span>
</span>
))}
</div>
<div style={{ ...muted, marginTop: "0.4rem" }}>passes / attempts</div>
</div>
);
}
// ── dashboard ────────────────────────────────────────────────────
type Load =
| { phase: "loading" }
| { phase: "error" }
| { phase: "ready"; stats: Stats };
export default function ProgressDashboard() {
const [load, setLoad] = useState<Load>({ phase: "loading" });
useEffect(() => {
const controller = new AbortController();
fetch(`${API_BASE}/api/stats`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((body: unknown) => {
setLoad(isStats(body) ? { phase: "ready", stats: body } : { phase: "error" });
})
.catch(() => {
if (!controller.signal.aborted) setLoad({ phase: "error" });
});
return () => controller.abort();
}, []);
if (load.phase === "loading") {
return <p style={muted}>Loading progress</p>;
}
if (load.phase === "error") {
return (
<p style={muted}>
API unreachable stats are served live from the SRS Worker and it did
not respond.
</p>
);
}
const { stats } = load;
return (
<div>
<Header stats={stats} />
<Phases stats={stats} />
<Ladder stats={stats} />
<Queue stats={stats} />
<Gates stats={stats} />
<Recent stats={stats} />
<p style={muted}>Generated {stats.generated}</p>
</div>
);
}