mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 15:36:26 +00:00
352 lines
11 KiB
TypeScript
352 lines
11 KiB
TypeScript
/**
|
||
* 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 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 (
|
||
<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>
|
||
);
|
||
}
|