mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
feat(docs): add progress dashboard components and styling
This commit is contained in:
1 parent
5ed33057ed
commit
63f0a81af3
16 files changed
+1266
-430
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user