mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 07:26:27 +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>
|
|||
|
|
);
|
|||
|
|
}
|