mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
425 lines
13 KiB
TypeScript
425 lines
13 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 ────────────────────────────────────
|
||
|
||
// 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<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>
|
||
);
|
||
}
|