docs: add /progress page with live stats cards and update navigation and component imports

This commit is contained in:
Prad Nukala committed 2026-10-13 09:16:30 -04:00
1 parent cb4a03b6fa
commit 13734e2e27
11 files changed
+88 -57

No files matched your search

@@ -0,0 +1,79 @@
/**
* 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 { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { useStats, utcDate } from "../../lib/stats";
import redirects from "../../redirects";
import { StatsPending } from "./shared";
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>
);
}
@@ -0,0 +1,143 @@
/**
* 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 { Card, CardContent } from "../ui/card";
import { type ChartConfig, ChartContainer } from "../ui/chart";
import { DIFFICULTIES, type Difficulty, useStats } from "../../lib/stats";
import { cn } from "../../lib/utils";
import { StatsPending } from "./shared";
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);
const segments: ((typeof rows)[number] & { from: number; to: number; solvedTo: number })[] = [];
let cursor = START;
for (const r of rows) {
const size = total > 0 ? (span * r.total) / total : 0;
const solved = r.total > 0 ? size * (r.done / r.total) : 0;
segments.push({ ...r, from: cursor, to: cursor - size, solvedTo: cursor - solved });
cursor -= size + GAP;
}
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>
);
}
@@ -0,0 +1,220 @@
/**
* This calendar year by difficulty — LeetCode's solved/submissions bar chart,
* sharing its row with the heatmap. Stacked Easy / Med. / Hard bars per month
* or per week (Monday-start, like the heatmap rows); "Solved" counts each
* problem once, on its first-pass date (the date `recent` uses), while
* "Submissions" counts every logged attempt. The totals under the chart are
* the year's, in the selected mode. Buckets are cut from `year`, which always
* covers January 1 to today.
*/
import { useState } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { Card, CardAction, CardContent, CardFooter, CardHeader } from "../ui/card";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "../ui/chart";
import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
import { DIFFICULTIES, type Day, type Difficulty, useStats, utcDate } from "../../lib/stats";
import { StatsPending } from "./shared";
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.
const DIFFICULTY_TEXT: Record<Difficulty, string> = {
easy: "text-chart-1",
medium: "text-chart-2",
hard: "text-chart-3",
};
type Mode = "solved" | "attempts";
type Grain = "month" | "week";
const DAY_MS = 86_400_000;
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
const MONTH_YEAR = new Intl.DateTimeFormat("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
const SHORT = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
interface Bucket extends Record<Difficulty, number> {
index: number;
/** Axis tick: the month the bucket starts in. */
tick: string;
/** Tooltip heading. */
label: string;
}
/** The year's buckets, every one present (future ones empty), filled from `days`. */
function buckets(days: Day[], year: number, grain: Grain, mode: Mode): Bucket[] {
const jan1 = Date.UTC(year, 0, 1);
const lead = (new Date(jan1).getUTCDay() + 6) % 7; // Jan 1's Monday-based weekday
const length = Math.round((Date.UTC(year + 1, 0, 1) - jan1) / DAY_MS);
const out: Bucket[] = [];
if (grain === "month") {
for (let m = 0; m < 12; m++) {
const start = new Date(Date.UTC(year, m, 1));
out.push({ index: m, tick: MONTH.format(start), label: MONTH_YEAR.format(start), easy: 0, medium: 0, hard: 0 });
}
} else {
const weeks = Math.floor((length - 1 + lead) / 7) + 1;
for (let w = 0; w < weeks; w++) {
const start = new Date(Math.max(jan1, jan1 + (w * 7 - lead) * DAY_MS));
const end = new Date(Math.min(jan1 + (length - 1) * DAY_MS, jan1 + (w * 7 - lead + 6) * DAY_MS));
out.push({
index: w,
tick: MONTH.format(start),
label: `${SHORT.format(start)} – ${SHORT.format(end)}`,
easy: 0,
medium: 0,
hard: 0,
});
}
}
for (const day of days) {
const date = utcDate(day.date);
if (date.getUTCFullYear() !== year) continue;
const i = grain === "month" ? date.getUTCMonth() : Math.floor(((date.getTime() - jan1) / DAY_MS + lead) / 7);
const bucket = out[i];
if (!bucket) continue;
for (const d of DIFFICULTIES) bucket[d] += day[mode][d];
}
return out;
}
export default function SubmissionChart() {
const load = useStats();
const [mode, setMode] = useState<Mode>("solved");
const [grain, setGrain] = useState<Grain>("month");
if (load.phase !== "ready") {
return (
<Card>
<CardContent>
<StatsPending load={load} className="h-40 w-full" />
</CardContent>
</Card>
);
}
const year = utcDate(load.stats.year.at(-1)?.date ?? "1970-01-01").getUTCFullYear();
const data = buckets(load.stats.year, year, grain, mode);
const totals = Object.fromEntries(
DIFFICULTIES.map((d) => [d, data.reduce((n, b) => n + b[d], 0)]),
);
const peak = Math.max(...data.map((b) => b.easy + b.medium + b.hard));
// A round ceiling for the one labelled gridline, as LeetCode draws it.
const top = peak <= 5 ? 5 : Math.ceil(peak / 10) * 10;
return (
<Card>
<CardHeader className="flex flex-wrap items-center justify-between gap-2">
<ToggleGroup
type="single"
size="sm"
spacing={1}
value={mode}
onValueChange={(value: string) => value && setMode(value as Mode)}
className="rounded-full bg-muted p-0.5"
aria-label="Count"
>
<ToggleGroupItem value="solved" className="h-7 rounded-full px-2.5 text-xs">
Solved
</ToggleGroupItem>
<ToggleGroupItem value="attempts" className="h-7 rounded-full px-2.5 text-xs">
Submissions
</ToggleGroupItem>
</ToggleGroup>
<div className="flex items-center">
<ToggleGroup
type="single"
size="sm"
spacing={1}
value={grain}
onValueChange={(value: string) => value && setGrain(value as Grain)}
className="rounded-full bg-muted p-0.5"
aria-label="Bucket size"
>
<ToggleGroupItem value="month" className="h-7 rounded-full px-2.5 text-xs" aria-label="Per month">
M
</ToggleGroupItem>
<ToggleGroupItem value="week" className="h-7 rounded-full px-2.5 text-xs" aria-label="Per week">
W
</ToggleGroupItem>
</ToggleGroup>
</div>
</CardHeader>
<CardContent className="flex-1">
<ChartContainer config={chartConfig} className="aspect-auto h-40 w-full">
<BarChart accessibilityLayer data={data} margin={{ top: 8, right: 0, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="index"
type="category"
tickLine={false}
axisLine={false}
tickMargin={6}
ticks={[0, data.length - 1]}
interval={0}
// First label anchored at its start, last at its end, so neither
// is clipped when a week band is only a few pixels wide.
tick={({ x, y, payload }) => (
<text
x={x}
y={y}
dy={10}
textAnchor={payload.value === 0 ? "start" : "end"}
className="fill-muted-foreground"
>
{data[Number(payload.value)]?.tick}
</text>
)}
/>
<YAxis
orientation="right"
domain={[0, top]}
ticks={[0, top]}
tickLine={false}
axisLine={false}
width={28}
allowDecimals={false}
/>
<ChartTooltip
cursor={false}
// Recharts applies itemSorter only in its default tooltip; this
// content gets the stack's bottom-up order, so flip it to read
// top-down, Easy first, like the bars.
content={({ active, label, payload }) => (
<ChartTooltipContent
active={active}
label={label}
payload={[...(payload ?? [])].reverse()}
labelFormatter={(_, items) => items[0]?.payload?.label}
indicator="dot"
/>
)}
/>
{(["hard", "medium", "easy"] as const).map((d) => (
<Bar
key={d}
dataKey={d}
stackId="solved"
fill={`var(--color-${d})`}
radius={2}
maxBarSize={grain === "month" ? 10 : 5}
/>
))}
</BarChart>
</ChartContainer>
</CardContent>
<CardFooter className="gap-4 text-sm">
{DIFFICULTIES.map((d) => (
<span key={d} className="flex items-baseline gap-1">
<span className={DIFFICULTY_TEXT[d]}>{chartConfig[d].label}</span>
<span className="text-muted-foreground tabular-nums">{totals[d]}</span>
</span>
))}
<span className="ml-auto text-muted-foreground tabular-nums">{year}</span>
</CardFooter>
</Card>
);
}
@@ -0,0 +1,163 @@
/**
* The last six months of attempts — LeetCode's calendar heatmap, halved to
* share its row with the submissions chart: one cell per day, weeks as
* columns, each month its own block, and the window's attempt count, active
* days and longest streak above it. The figures are counted from exactly the
* cells drawn, so they cannot disagree with the grid. 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 its column instead of scrolling.
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { type Day, useStats, utcDate } from "../../lib/stats";
import { StatsPending, TipRow, useHoverTip } from "./shared";
const MONTHS = 6;
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 Cell {
x: number;
y: number;
date: Date;
attempts: number;
passes: number;
}
interface Block {
label: string;
x: number;
width: number;
cells: Cell[];
}
/**
* Month blocks laid left to right over the last MONTHS calendar months. Within
* a block a day sits at row = weekday (Mon = 0) and column = its week counted
* from the 1st, so a month starting on a Thursday opens with a short column.
*/
function layout(year: Day[]): { blocks: Block[]; width: number } {
const last = utcDate(year.at(-1)?.date ?? "1970-01-01");
const from = Date.UTC(last.getUTCFullYear(), last.getUTCMonth() - (MONTHS - 1), 1);
const blocks: Block[] = [];
let x = 0;
for (const day of year) {
const date = utcDate(day.date);
if (date.getTime() < from) continue;
let block = blocks.at(-1);
if (date.getUTCDate() === 1 || !block) {
if (block) x += block.width + MONTH_GAP;
block = { label: MONTH.format(date), x, width: 0, cells: [] };
blocks.push(block);
}
const first = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
const index = (first.getUTCDay() + 6) % 7 + date.getUTCDate() - 1;
const col = Math.floor(index / 7);
block.cells.push({
x: x + col * STEP,
y: (index % 7) * STEP,
date,
attempts: day.attempts.easy + day.attempts.medium + day.attempts.hard,
passes: day.passes,
});
block.width = col * STEP + CELL;
}
const end = blocks.at(-1);
return { blocks, width: end ? end.x + end.width : 0 };
}
export default function SubmissionHeatmap() {
const load = useStats();
const { ref, show, hide, node } = useHoverTip<HTMLDivElement>();
if (load.phase !== "ready") {
return (
<Card>
<CardContent>
<StatsPending load={load} className="h-40 w-full" />
</CardContent>
</Card>
);
}
const { blocks, width } = layout(load.stats.year);
const cells = blocks.flatMap((b) => b.cells);
let run = 0;
let streak = 0;
for (const c of cells) {
run = c.attempts > 0 ? run + 1 : 0;
streak = Math.max(streak, run);
}
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">{cells.reduce((n, c) => n + c.attempts, 0)}</span>
<span className="font-normal text-muted-foreground text-sm">attempts in the past {MONTHS} months</span>
</CardTitle>
<CardDescription className="flex gap-4">
<span>
Active days:{" "}
<span className="text-foreground tabular-nums">{cells.filter((c) => c.attempts > 0).length}</span>
</span>
<span>
Max streak: <span className="text-foreground tabular-nums">{streak}</span>
</span>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-1 items-center">
<div ref={ref} className="relative w-full" onPointerLeave={hide}>
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-auto w-full"
role="img"
aria-label={`Attempts per day over the past ${MONTHS} months`}
>
{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) =>
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} />
</>,
)
}
/>
))}
<text
x={block.x + block.width / 2}
y={height - 3}
textAnchor="middle"
className="fill-muted-foreground text-[9px]"
>
{block.label}
</text>
</g>
))}
</svg>
{node}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,137 @@
/**
* 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { type Stats, useStats } from "../../lib/stats";
import { cn } from "../../lib/utils";
import { StatsPending, TipRow, useHoverTip } from "./shared";
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 { ref, show, hide, node } = 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={ref} className="relative mx-auto w-full max-w-xl" onPointerLeave={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) =>
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>
{node}
</div>
</CardContent>
</Card>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* What the four progress islands share besides the data: the not-ready body
* What the progress cards share besides the data: the not-ready body
* (a skeleton while /api/stats loads, one muted line if it fails) and the
* hover tooltip for the two charts drawn as plain SVG (the heatmap calendar
* and the topic bubbles — Recharts has neither layout). The tooltip wears the