2026-10-05 14:22:28 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* 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";
|
2026-10-13 09:16:30 -04:00
|
|
|
|
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";
|
2026-10-05 14:22:28 -04:00
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|