feat(docs): add progress dashboard components and styling

This commit is contained in:
Prad Nukala committed 2026-09-29 17:28:59 -04:00
1 parent 5ed33057ed
commit 63f0a81af3
16 files changed
+1266 -430

No files matched your search

-428
View File
@@ -1,428 +0,0 @@
/**
* 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 }[];
/** Every campaign day, zeros included; this page reads only the tail. */
heat: { 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.heat) &&
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 }) {
// heat spans the whole campaign, future days and all. The window wanted here
// is the seven days ending today, and campaign.day is that day, 1-based.
const week = stats.heat.slice(Math.max(0, stats.campaign.day - 7), stats.campaign.day);
return (
<div style={card}>
<div style={heading}>Last 7 days</div>
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
{week.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>
);
}
+81
View File
@@ -0,0 +1,81 @@
/**
* The ten most recently solved problems — LeetCode's "Recent AC" list. Each
* row is dated by the problem's first pass (an ET calendar date: the Worker
* logs days, not times), read relative to today in ET with the full date on
* hover. A row links to the problem's docs page when one exists: the
* sync-generated short links (`redirects.ts`) are exactly the pages built.
*/
import { StatsPending } from "../components/progress/shared";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { useStats, utcDate } from "../lib/stats";
import redirects from "../redirects";
export const client = "load";
const PAGES = new Map(redirects.map((r) => [r.from, r.to]));
const ET_TODAY = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York" });
const RELATIVE = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const FULL = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" });
const DAY_MS = 86_400_000;
export default function RecentSolves() {
const load = useStats();
if (load.phase !== "ready") {
return (
<Card>
<CardHeader>
<CardTitle>Recently solved</CardTitle>
</CardHeader>
<CardContent>
<StatsPending load={load} className="h-80 w-full" />
</CardContent>
</Card>
);
}
const today = utcDate(ET_TODAY.format(new Date())).getTime();
return (
<Card>
<CardHeader>
<CardTitle>Recently solved</CardTitle>
</CardHeader>
<CardContent>
{load.stats.recent.length === 0 ? (
<p className="my-0! text-muted-foreground text-sm">Nothing solved yet.</p>
) : (
<ol className="my-0! flex flex-col">
{load.stats.recent.map((p) => {
const date = utcDate(p.date);
const page = PAGES.get(`/${p.lc}`);
const name = (
<>
<span className="text-muted-foreground tabular-nums">{p.lc}.</span> {p.title}
</>
);
return (
<li
key={p.lc}
className="flex items-center justify-between gap-4 rounded-lg px-4 py-3 text-sm odd:bg-muted"
>
{page ? (
<a href={page} className="truncate font-normal! text-foreground no-underline! hover:underline!">
{name}
</a>
) : (
<span className="truncate">{name}</span>
)}
<time dateTime={p.date} title={FULL.format(date)} className="shrink-0 text-muted-foreground">
{RELATIVE.format(Math.round((date.getTime() - today) / DAY_MS), "day")}
</time>
</li>
);
})}
</ol>
)}
</CardContent>
</Card>
);
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Solved-by-difficulty ring — LeetCode's profile gauge. A 270° arc split into
* one segment per difficulty, each sized by that difficulty's share of the
* catalog; the faint track is the whole set, the solid arc the solved part.
* The centre reads the total, or one difficulty while its segment (or its
* stat box) is hovered. "Attempting" is the open-miss count: problems whose
* latest attempt failed.
*/
import { useState } from "react";
import { Pie, PieChart } from "recharts";
import { StatsPending } from "../components/progress/shared";
import { Card, CardContent } from "../components/ui/card";
import { type ChartConfig, ChartContainer } from "../components/ui/chart";
import { DIFFICULTIES, type Difficulty, useStats } from "../lib/stats";
import { cn } from "../lib/utils";
export const client = "load";
const chartConfig = {
easy: { label: "Easy", color: "var(--chart-1)" },
medium: { label: "Med.", color: "var(--chart-2)" },
hard: { label: "Hard", color: "var(--chart-3)" },
} satisfies ChartConfig;
// Static class names, so Tailwind's scanner sees every one (the chart's
// --color-* variables exist only inside ChartContainer).
const DIFFICULTY_TEXT: Record<Difficulty, string> = {
easy: "text-chart-1",
medium: "text-chart-2",
hard: "text-chart-3",
};
// The gauge opens at the bottom: from 225° (lower left) clockwise to -45°
// (lower right), in Recharts' counter-clockwise-from-3-o'clock degrees.
const START = 225;
const SWEEP = 270;
const GAP = 8;
export default function SolvedRing() {
const load = useStats();
const [focus, setFocus] = useState<Difficulty | null>(null);
if (load.phase !== "ready") {
return (
<Card>
<CardContent>
<StatsPending load={load} className="h-52 w-full" />
</CardContent>
</Card>
);
}
const rows = DIFFICULTIES.map(
(difficulty) => load.stats.difficulty.find((d) => d.difficulty === difficulty) ?? { difficulty, total: 0, done: 0 },
);
const total = rows.reduce((n, r) => n + r.total, 0);
const done = rows.reduce((n, r) => n + r.done, 0);
const span = SWEEP - GAP * (rows.length - 1);
let cursor = START;
const segments = rows.map((r) => {
const size = total > 0 ? (span * r.total) / total : 0;
const from = cursor;
cursor -= size + GAP;
const solved = r.total > 0 ? size * (r.done / r.total) : 0;
return { ...r, from, to: from - size, solvedTo: from - solved };
});
const focused = focus ? rows.find((r) => r.difficulty === focus) : undefined;
const arc = { innerRadius: "80%", outerRadius: "88%", cornerRadius: 99, stroke: "none", dataKey: "value" } as const;
return (
<Card>
<CardContent className="flex flex-col items-center gap-6 sm:flex-row">
<div className="relative w-full max-w-56 shrink-0">
<ChartContainer config={chartConfig} className="aspect-square w-full">
<PieChart>
{segments.map((s) => (
<Pie
key={`${s.difficulty}-track`}
{...arc}
data={[{ value: 1 }]}
startAngle={s.from}
endAngle={s.to}
fill={`var(--color-${s.difficulty})`}
fillOpacity={focus && focus !== s.difficulty ? 0.12 : 0.25}
isAnimationActive={false}
onMouseEnter={() => setFocus(s.difficulty)}
onMouseLeave={() => setFocus(null)}
/>
))}
{segments
.filter((s) => s.done > 0)
.map((s) => (
<Pie
key={`${s.difficulty}-solved`}
{...arc}
data={[{ value: 1 }]}
startAngle={s.from}
endAngle={s.solvedTo}
fill={`var(--color-${s.difficulty})`}
fillOpacity={focus && focus !== s.difficulty ? 0.35 : 1}
onMouseEnter={() => setFocus(s.difficulty)}
onMouseLeave={() => setFocus(null)}
/>
))}
</PieChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1">
<div className="flex items-baseline">
<span className="font-semibold text-4xl tabular-nums">{focused ? focused.done : done}</span>
<span className="text-muted-foreground tabular-nums">/{focused ? focused.total : total}</span>
</div>
<span className={cn("text-sm", focused ? DIFFICULTY_TEXT[focused.difficulty] : "text-muted-foreground")}>
{focused ? chartConfig[focused.difficulty].label : "Solved"}
</span>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-3 text-center text-muted-foreground text-xs">
<span className="font-semibold text-foreground tabular-nums">{load.stats.attempting}</span> Attempting
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-1">
{rows.map((r) => (
<div
key={r.difficulty}
className={cn(
"flex flex-col items-center gap-0.5 rounded-lg bg-muted px-4 py-2 transition-opacity",
focus && focus !== r.difficulty && "opacity-50",
)}
onPointerEnter={() => setFocus(r.difficulty)}
onPointerLeave={() => setFocus(null)}
>
<span className={cn("font-medium text-sm", DIFFICULTY_TEXT[r.difficulty])}>
{chartConfig[r.difficulty].label}
</span>
<span className="text-sm tabular-nums">
{r.done}/{r.total}
</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
+139
View File
@@ -0,0 +1,139 @@
/**
* The past year of attempts — LeetCode's calendar heatmap: one cell per day,
* weeks as columns, each month its own block with a gap between, and the
* year's attempt count, active days and longest streak above it. The colour
* ramp is the README/email heatmap's (theme.css `--heat-0..4`, level =
* attempts capped at 4) and rows start on Monday like it, so the three
* heatmaps read the same. Drawn as SVG in a viewBox, so it scales to the
* column instead of scrolling.
*/
import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { type Stats, useStats, utcDate } from "../lib/stats";
export const client = "load";
const CELL = 10;
const STEP = 13; // cell + gap
const MONTH_GAP = 8;
const LABEL_H = 16;
const HEAT = ["var(--heat-0)", "var(--heat-1)", "var(--heat-2)", "var(--heat-3)", "var(--heat-4)"];
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
interface Block {
label: string;
x: number;
width: number;
cells: { x: number; y: number; date: Date; attempts: number; passes: number }[];
}
/**
* Month blocks laid left to right. Within a block a day sits at row =
* weekday (Mon = 0) and column = its week counted from the block's first day,
* so a month starting on a Thursday opens with a short first column.
*/
function layout(days: Stats["year"]["days"]): { blocks: Block[]; width: number } {
const blocks: Block[] = [];
let x = 0;
let month = -1;
let block: Block | undefined;
let lead = 0;
let index = 0;
for (const day of days) {
const date = utcDate(day.date);
if (date.getUTCMonth() !== month) {
if (block) x += block.width + MONTH_GAP;
month = date.getUTCMonth();
lead = (date.getUTCDay() + 6) % 7;
index = 0;
block = { label: MONTH.format(date), x, width: 0, cells: [] };
blocks.push(block);
}
const row = (lead + index) % 7;
const col = Math.floor((lead + index) / 7);
block!.cells.push({ x: x + col * STEP, y: row * STEP, date, attempts: day.attempts, passes: day.passes });
block!.width = (col + 1) * STEP - (STEP - CELL);
index++;
}
return { blocks, width: block ? x + block.width : 0 };
}
export default function SubmissionHeatmap() {
const load = useStats();
const hover = useHoverTip<HTMLDivElement>();
if (load.phase !== "ready") {
return (
<Card>
<CardContent>
<StatsPending load={load} className="h-32 w-full" />
</CardContent>
</Card>
);
}
const { year } = load.stats;
const { blocks, width } = layout(year.days);
const height = 7 * STEP - (STEP - CELL) + LABEL_H;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-baseline gap-2">
<span className="font-semibold text-2xl tabular-nums">{year.attempts}</span>
<span className="font-normal text-muted-foreground">attempts in the past one year</span>
</CardTitle>
<CardDescription>
Total active days: <span className="text-foreground tabular-nums">{year.active_days}</span>
</CardDescription>
<CardAction className="text-muted-foreground text-sm">
Max streak: <span className="text-foreground tabular-nums">{year.max_streak}</span>
</CardAction>
</CardHeader>
<CardContent>
<div ref={hover.ref} className="relative" onPointerLeave={hover.hide}>
<svg viewBox={`0 0 ${width} ${height}`} className="h-auto w-full" role="img" aria-label="Attempts per day over the past year">
{blocks.map((block) => (
<g key={`${block.label}-${block.x}`}>
{block.cells.map((cell) => (
<rect
key={cell.x * 1000 + cell.y}
x={cell.x}
y={cell.y}
width={CELL}
height={CELL}
rx={2}
fill={HEAT[Math.min(cell.attempts, 4)]}
onPointerMove={(event) =>
hover.show(
event,
<>
<div className="font-medium">{DAY.format(cell.date)}</div>
<TipRow color={HEAT[Math.min(cell.attempts, 4)]!} label="Attempts" value={cell.attempts} />
<TipRow color="var(--chart-4)" label="Passed" value={cell.passes} />
</>,
)
}
/>
))}
{block.width >= 2 * STEP && (
<text
x={block.x + block.width / 2}
y={height - 3}
textAnchor="middle"
className="fill-muted-foreground text-[9px]"
>
{block.label}
</text>
)}
</g>
))}
</svg>
{hover.node}
</div>
</CardContent>
</Card>
);
}
+139
View File
@@ -0,0 +1,139 @@
/**
* The campaign's topics as packed bubbles — LeetCode's "All Topics" view,
* over our curriculum instead of LeetCode's tags. A bubble's area is the
* topic's problem count; it fills from the bottom like a glass as problems
* are solved, and turns solid once every one is. A topic the schedule has not
* reached yet is dimmed. Packing is d3-hierarchy's front-chain (packSiblings,
* largest first, so the layout is deterministic), drawn as SVG in a viewBox:
* Recharts has no packed layout.
*/
import { packEnclose, packSiblings } from "d3-hierarchy";
import { useId } from "react";
import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { type Stats, useStats } from "../lib/stats";
import { cn } from "../lib/utils";
export const client = "load";
const PAD = 2; // gap between bubbles, in layout units
const UNIT = 12; // radius of a one-problem bubble
type Topic = Stats["topics"][number];
type Bubble = Topic & { x: number; y: number; r: number };
/** Split a name across lines of at most ~10 characters, never breaking a word. */
function lines(name: string): string[] {
const out: string[] = [];
for (const word of name.split(" ")) {
const last = out.at(-1);
if (last !== undefined && `${last} ${word}`.length <= 10) out[out.length - 1] = `${last} ${word}`;
else out.push(word);
}
return out;
}
export default function TopicBubbles() {
const load = useStats();
const hover = useHoverTip<HTMLDivElement>();
const clip = useId().replace(/:/g, "");
if (load.phase !== "ready") {
return (
<Card>
<CardHeader>
<CardTitle>Topics</CardTitle>
</CardHeader>
<CardContent>
<StatsPending load={load} className="aspect-square w-full" />
</CardContent>
</Card>
);
}
const topics = load.stats.topics.filter((t) => t.total > 0);
const circles: Bubble[] = topics
.slice()
.sort((a, b) => b.total - a.total || a.issue - b.issue)
.map((t) => ({ ...t, x: 0, y: 0, r: UNIT * Math.sqrt(t.total) + PAD }));
packSiblings(circles);
const bound = packEnclose(circles) ?? { x: 0, y: 0, r: 1 };
const solved = topics.reduce((n, t) => n + t.done, 0);
const total = topics.reduce((n, t) => n + t.total, 0);
return (
<Card>
<CardHeader>
<CardTitle>Topics</CardTitle>
<CardDescription>
{solved} of {total} problems across {topics.length} topics · size is problem count, fill is solved
</CardDescription>
</CardHeader>
<CardContent>
<div ref={hover.ref} className="relative mx-auto w-full max-w-xl" onPointerLeave={hover.hide}>
<svg
viewBox={`${bound.x - bound.r} ${bound.y - bound.r} ${bound.r * 2} ${bound.r * 2}`}
className="h-auto w-full"
role="img"
aria-label="Campaign topics sized by problem count and filled by problems solved"
>
{circles.map((c) => {
const r = c.r - PAD;
const ratio = c.total > 0 ? c.done / c.total : 0;
const complete = c.done >= c.total;
const words = lines(c.name);
const size = Math.min(r / 3.2, (1.7 * r) / Math.max(...words.map((w) => w.length)));
const id = `${clip}-${c.issue}`;
return (
<g
key={c.issue}
className={cn("transition-opacity hover:opacity-90", !c.taught && "opacity-50")}
onPointerMove={(event) =>
hover.show(
event,
<>
<div className="font-medium">{c.name}</div>
<TipRow color="var(--chart-4)" label="Solved" value={`${c.done}/${c.total}`} />
{!c.taught && <div className="text-muted-foreground">Not reached yet</div>}
</>,
)
}
>
<clipPath id={id}>
<circle cx={c.x} cy={c.y} r={r} />
</clipPath>
<circle cx={c.x} cy={c.y} r={r} className="fill-muted stroke-border" strokeWidth={0.5} />
<rect
x={c.x - r}
y={c.y + r - 2 * r * ratio}
width={2 * r}
height={2 * r * ratio}
clipPath={`url(#${id})`}
fill="var(--chart-4)"
fillOpacity={complete ? 1 : 0.35}
/>
<text
x={c.x}
y={c.y - ((words.length - 1) * size * 1.15) / 2}
textAnchor="middle"
dominantBaseline="central"
clipPath={`url(#${id})`}
className={cn("pointer-events-none select-none", complete ? "fill-background" : "fill-foreground")}
style={{ fontSize: size }}
>
{words.map((w, i) => (
<tspan key={`${i}-${w}`} x={c.x} dy={i === 0 ? 0 : size * 1.15}>
{w}
</tspan>
))}
</text>
</g>
);
})}
</svg>
{hover.node}
</div>
</CardContent>
</Card>
);
}