Files
leetcode/apps/docs/islands/ProgressDashboard.tsx
T

423 lines
13 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>;
/** 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<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 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 (
<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>
);
}
// 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 (
<div style={card}>
<div style={heading}>Phases</div>
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap", marginBottom: "0.6rem" }}>
{SETS.map(([set, color]) => (
<span key={set} style={muted}>
<span
style={{
display: "inline-block",
width: "0.65rem",
height: "0.65rem",
borderRadius: "2px",
background: color,
marginRight: "0.35rem",
}}
/>
{set}
</span>
))}
</div>
{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 (
<div
key={p.milestone}
title={`core ${p.core_done}/${p.core_total} · optional ${p.optional_done}/${p.optional_total} · deferred ${p.deferred_done}/${p.deferred_total}`}
style={{ display: "flex", alignItems: "center", gap: "0.75rem", padding: "0.25rem 0" }}
>
<span style={{ width: "9.5rem", flexShrink: 0 }}>{p.name}</span>
<span style={{ flex: 1 }}>
<span
style={{
display: "flex",
width: `${(total / maxTotal) * 100}%`,
height: "0.65rem",
borderRadius: "var(--blume-radius)",
background: "var(--blume-muted)",
overflow: "hidden",
}}
>
{SETS.map(([set, color]) => {
const solved = p[`${set}_done`];
return solved > 0 ? (
<span
key={set}
style={{ width: `${(solved / total) * 100}%`, background: color }}
/>
) : null;
})}
</span>
</span>
<span style={{ ...muted, width: "3.5rem", textAlign: "right", flexShrink: 0 }}>
{done}/{total}
</span>
</div>
);
})}
</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 — the first weekly review opens Saturday night.</span>
) : (
stats.gates.map((g) => (
<div
key={g.week}
style={{
display: "flex",
gap: "0.75rem",
alignItems: "baseline",
flexWrap: "wrap",
padding: "0.15rem 0",
}}
>
<span style={{ width: "4.5rem", flexShrink: 0 }}>Week {g.week}</span>
{g.issue === null ? (
<span style={muted}>no review issue</span>
) : (
<a
href={`https://github.com/prdlk/leetcode/issues/${g.issue}`}
target="_blank"
rel="noreferrer"
>
#{g.issue}
</a>
)}
{g.pass_rate === null ? (
<span style={muted}>in progress</span>
) : (
<span>
{percent(g.pass_rate)} passed
{g.closed_on && <span style={muted}> · closed {g.closed_on}</span>}
</span>
)}
</div>
))
)}
<div style={{ ...muted, marginTop: "0.4rem" }}>
One gate per campaign week — scored when its review issue closes.
</div>
</div>
);
}
// 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 (
<div style={card}>
<div style={heading}>Last 7 days</div>
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
{stats.recent.map((r) => (
<span
key={r.date}
title={`${r.date}: ${r.passes}/${r.attempts} passed`}
style={{ textAlign: "center" }}
>
<span
style={{
display: "block",
width: "2.6rem",
height: "2rem",
borderRadius: "var(--blume-radius)",
background: GREENS[Math.min(r.attempts, 4)],
}}
/>
<span style={{ ...muted, display: "block", marginTop: "0.25rem", fontSize: "0.75em" }}>
{dayLabel(r.date)}
</span>
</span>
))}
</div>
<div style={{ ...muted, marginTop: "0.5rem", display: "flex", alignItems: "center", gap: "0.25rem" }}>
<span>Less</span>
{GREENS.map((color) => (
<span
key={color}
style={{ display: "inline-block", width: "0.65rem", height: "0.65rem", borderRadius: "2px", background: color }}
/>
))}
<span>More · colour = attempts, hover for passes</span>
</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>
);
}