mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
221 lines
8.3 KiB
TypeScript
221 lines
8.3 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|