/** * 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; 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; 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 0–1 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 ( ); } // ── sections ───────────────────────────────────────────────────── function Header({ stats }: { stats: Stats }) { const { campaign, streak } = stats; return (
Day {campaign.day}/{campaign.days} Week {campaign.week} Streak: {streak} day{streak === 1 ? "" : "s"} started {campaign.start}
); } function Phases({ stats }: { stats: Stats }) { return (
Phases
{stats.phases.map((p) => ( ))}
{p.name} {" "} core {p.core_done}/{p.core_total} optional {p.optional_done}/{p.optional_total} {p.deferred_total > 0 && ` · deferred ${p.deferred_done}/${p.deferred_total}`}
); } function Ladder({ stats }: { stats: Stats }) { return (
SRS ladder
{STAGES.map((stage) => ( {stage}{" "} {stats.ladder[stage] ?? 0} ))}
); } function Queue({ stats }: { stats: Stats }) { const max = Math.max(1, ...stats.queue.map((q) => q.due)); return (
Review queue — next 14 days
{stats.queue.map((q) => ( ))}
{dayLabel(q.date)} {q.due}
); } function Gates({ stats }: { stats: Stats }) { return (
Gate log
{stats.gates.length === 0 ? ( No gates yet. ) : ( {["Week", "Issue", "Pass rate", "Closed"].map((h) => ( ))} {stats.gates.map((g) => ( ))}
{h}
{g.week} {g.issue === null ? ( ) : ( #{g.issue} )} {g.pass_rate === null ? ( open ) : ( percent(g.pass_rate) )} {g.closed_on ?? }
)}
); } function Recent({ stats }: { stats: Stats }) { return (
Last 7 days
{stats.recent.map((r) => ( {dayLabel(r.date)} {r.passes} /{r.attempts} ))}
passes / attempts
); } // ── dashboard ──────────────────────────────────────────────────── type Load = | { phase: "loading" } | { phase: "error" } | { phase: "ready"; stats: Stats }; export default function ProgressDashboard() { const [load, setLoad] = useState({ 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

Loading progress…

; } if (load.phase === "error") { return (

API unreachable — stats are served live from the SRS Worker and it did not respond.

); } const { stats } = load; return (

Generated {stats.generated}

); }