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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user