/** * 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 ──────────────────────────────────── // Mirrors STAGES in apps/api/src/srs.ts — the ladder rungs, in order. The // Worker ships every rung in `ladder`, zeros included. const STAGES = ["new", "+3", "+7", "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; /** week is the 1-based campaign week — the Worker converts from ISO. */ 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, with two deliberate exceptions: the phase-bar sets get // fixed hues (accent-blue core, yellow optional, red deferred — they must be // distinguishable, not shades of one colour), and the last-7-days heatmap // reuses the GREENS ramp from apps/api/src/charts.ts so the page matches the // README/email heatmaps (level 0 stays a theme token so light mode works). 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}
); } // One stacked bar per phase: solved core / optional / deferred segments over // a muted track sized by the phase's total problem count, so both phase size // and progress read at a glance. const SETS = [ ["core", "var(--blume-accent)"], ["optional", "#d29922"], ["deferred", "#e5534b"], ] as const; function Phases({ stats }: { stats: Stats }) { const maxTotal = Math.max( 1, ...stats.phases.map((p) => p.core_total + p.optional_total + p.deferred_total), ); return (
Phases
{SETS.map(([set, color]) => ( {set} ))}
{stats.phases.map((p) => { const total = p.core_total + p.optional_total + p.deferred_total; const done = p.core_done + p.optional_done + p.deferred_done; return (
{p.name} {SETS.map(([set, color]) => { const solved = p[`${set}_done`]; return solved > 0 ? ( ) : null; })} {done}/{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 — the first weekly review opens Saturday night. ) : ( stats.gates.map((g) => (
Week {g.week} {g.issue === null ? ( no review issue ) : ( #{g.issue} )} {g.pass_rate === null ? ( in progress ) : ( {percent(g.pass_rate)} passed {g.closed_on && · closed {g.closed_on}} )}
)) )}
One gate per campaign week — scored when its review issue closes.
); } // GREENS ramp from apps/api/src/charts.ts; level 0 swapped for the theme's // muted token so an empty day looks right in both colour schemes. const GREENS = [ "var(--blume-muted)", "#0e4429", "#006d32", "#26a641", "#39d353", ]; function Recent({ stats }: { stats: Stats }) { return (
Last 7 days
{stats.recent.map((r) => ( {dayLabel(r.date)} ))}
Less {GREENS.map((color) => ( ))} More · colour = attempts, hover for passes
); } // ── 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}

); }