mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
docs: add toggle UI components, submission chart, and update stats schema
This commit is contained in:
1 parent
5d2d1dff3c
commit
d194cedf7a
6 files changed
+449
-65
No files matched your search
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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 { StatsPending } from "../components/progress/shared";
|
||||
import { Card, CardAction, CardContent, CardFooter, CardHeader } from "../components/ui/card";
|
||||
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "../components/ui/chart";
|
||||
import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group";
|
||||
import { DIFFICULTIES, type Day, type Difficulty, useStats, utcDate } from "../lib/stats";
|
||||
|
||||
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.
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user