Files
leetcode/islands/ProgressDashboard.tsx
T

352 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>
);
}