docs: add toggle UI components, submission chart, and update stats schema

This commit is contained in:
Prad Nukala committed 2026-09-29 17:45:01 -04:00
1 parent 5d2d1dff3c
commit d194cedf7a
6 files changed
+449 -65

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
"use client"
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
import { toggleVariants } from "./toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
import { Toggle as TogglePrimitive } from "radix-ui"
// Blume's `accent` is its brand colour, not shadcn's subtle surface: the on
// and hover states use background/muted instead (see theme.css).
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-muted hover:text-foreground",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+5 -2
View File
@@ -1,12 +1,15 @@
--- ---
title: Progress title: Progress
description: Live campaign progress — solved by difficulty, the past year of attempts, topic coverage, and the most recently solved problems, fetched from the SRS Worker. description: Live campaign progress — solved by difficulty, recent attempts, the year by difficulty, topic coverage, and the most recently solved problems, fetched from the SRS Worker.
icon: loader icon: loader
--- ---
<div className="not-prose flex flex-col gap-6"> <div className="not-prose flex flex-col gap-6">
<SolvedRing /> <SolvedRing />
<SubmissionHeatmap /> <div className="grid gap-6 md:grid-cols-2">
<SubmissionHeatmap />
<SubmissionChart />
</div>
<TopicBubbles /> <TopicBubbles />
<RecentSolves /> <RecentSolves />
</div> </div>
+222
View File
@@ -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>
);
}
+78 -52
View File
@@ -1,18 +1,20 @@
/** /**
* The past year of attempts — LeetCode's calendar heatmap: one cell per day, * The last six months of attempts — LeetCode's calendar heatmap, halved to
* weeks as columns, each month its own block with a gap between, and the * share its row with the submissions chart: one cell per day, weeks as
* year's attempt count, active days and longest streak above it. The colour * columns, each month its own block, and the window's attempt count, active
* ramp is the README/email heatmap's (theme.css `--heat-0..4`, level = * days and longest streak above it. The figures are counted from exactly the
* attempts capped at 4) and rows start on Monday like it, so the three * cells drawn, so they cannot disagree with the grid. The colour ramp is the
* heatmaps read the same. Drawn as SVG in a viewBox, so it scales to the * README/email heatmap's (theme.css `--heat-0..4`, level = attempts capped at
* column instead of scrolling. * 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 its column instead of scrolling.
*/ */
import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared"; import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { type Stats, useStats, utcDate } from "../lib/stats"; import { type Day, useStats, utcDate } from "../lib/stats";
export const client = "load"; export const client = "load";
const MONTHS = 6;
const CELL = 10; const CELL = 10;
const STEP = 13; // cell + gap const STEP = 13; // cell + gap
const MONTH_GAP = 8; const MONTH_GAP = 8;
@@ -22,42 +24,54 @@ const HEAT = ["var(--heat-0)", "var(--heat-1)", "var(--heat-2)", "var(--heat-3)"
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" }); 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" }); const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
interface Cell {
x: number;
y: number;
date: Date;
attempts: number;
passes: number;
}
interface Block { interface Block {
label: string; label: string;
x: number; x: number;
width: number; width: number;
cells: { x: number; y: number; date: Date; attempts: number; passes: number }[]; cells: Cell[];
} }
/** /**
* Month blocks laid left to right. Within a block a day sits at row = * Month blocks laid left to right over the last MONTHS calendar months. Within
* weekday (Mon = 0) and column = its week counted from the block's first day, * a block a day sits at row = weekday (Mon = 0) and column = its week counted
* so a month starting on a Thursday opens with a short first column. * from the 1st, so a month starting on a Thursday opens with a short column.
*/ */
function layout(days: Stats["year"]["days"]): { blocks: Block[]; width: number } { function layout(year: Day[]): { blocks: Block[]; width: number } {
const last = utcDate(year.at(-1)?.date ?? "1970-01-01");
const from = Date.UTC(last.getUTCFullYear(), last.getUTCMonth() - (MONTHS - 1), 1);
const blocks: Block[] = []; const blocks: Block[] = [];
let x = 0; let x = 0;
let month = -1; for (const day of year) {
let block: Block | undefined;
let lead = 0;
let index = 0;
for (const day of days) {
const date = utcDate(day.date); const date = utcDate(day.date);
if (date.getUTCMonth() !== month) { if (date.getTime() < from) continue;
let block = blocks.at(-1);
if (date.getUTCDate() === 1 || !block) {
if (block) x += block.width + MONTH_GAP; 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: [] }; block = { label: MONTH.format(date), x, width: 0, cells: [] };
blocks.push(block); blocks.push(block);
} }
const row = (lead + index) % 7; const first = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
const col = Math.floor((lead + index) / 7); const index = (first.getUTCDay() + 6) % 7 + date.getUTCDate() - 1;
block!.cells.push({ x: x + col * STEP, y: row * STEP, date, attempts: day.attempts, passes: day.passes }); const col = Math.floor(index / 7);
block!.width = (col + 1) * STEP - (STEP - CELL); block.cells.push({
index++; x: x + col * STEP,
y: (index % 7) * STEP,
date,
attempts: day.attempts.easy + day.attempts.medium + day.attempts.hard,
passes: day.passes,
});
block.width = col * STEP + CELL;
} }
return { blocks, width: block ? x + block.width : 0 }; const end = blocks.at(-1);
return { blocks, width: end ? end.x + end.width : 0 };
} }
export default function SubmissionHeatmap() { export default function SubmissionHeatmap() {
@@ -68,33 +82,47 @@ export default function SubmissionHeatmap() {
return ( return (
<Card> <Card>
<CardContent> <CardContent>
<StatsPending load={load} className="h-32 w-full" /> <StatsPending load={load} className="h-40 w-full" />
</CardContent> </CardContent>
</Card> </Card>
); );
} }
const { year } = load.stats; const { blocks, width } = layout(load.stats.year);
const { blocks, width } = layout(year.days); const cells = blocks.flatMap((b) => b.cells);
let run = 0;
let streak = 0;
for (const c of cells) {
run = c.attempts > 0 ? run + 1 : 0;
streak = Math.max(streak, run);
}
const height = 7 * STEP - (STEP - CELL) + LABEL_H; const height = 7 * STEP - (STEP - CELL) + LABEL_H;
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-baseline gap-2"> <CardTitle className="flex items-baseline gap-2">
<span className="font-semibold text-2xl tabular-nums">{year.attempts}</span> <span className="font-semibold text-2xl tabular-nums">{cells.reduce((n, c) => n + c.attempts, 0)}</span>
<span className="font-normal text-muted-foreground">attempts in the past one year</span> <span className="font-normal text-muted-foreground text-sm">attempts in the past {MONTHS} months</span>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription className="flex gap-4">
Total active days: <span className="text-foreground tabular-nums">{year.active_days}</span> <span>
Active days:{" "}
<span className="text-foreground tabular-nums">{cells.filter((c) => c.attempts > 0).length}</span>
</span>
<span>
Max streak: <span className="text-foreground tabular-nums">{streak}</span>
</span>
</CardDescription> </CardDescription>
<CardAction className="text-muted-foreground text-sm">
Max streak: <span className="text-foreground tabular-nums">{year.max_streak}</span>
</CardAction>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex flex-1 items-center">
<div ref={hover.ref} className="relative" onPointerLeave={hover.hide}> <div ref={hover.ref} className="relative w-full" 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"> <svg
viewBox={`0 0 ${width} ${height}`}
className="h-auto w-full"
role="img"
aria-label={`Attempts per day over the past ${MONTHS} months`}
>
{blocks.map((block) => ( {blocks.map((block) => (
<g key={`${block.label}-${block.x}`}> <g key={`${block.label}-${block.x}`}>
{block.cells.map((cell) => ( {block.cells.map((cell) => (
@@ -118,16 +146,14 @@ export default function SubmissionHeatmap() {
} }
/> />
))} ))}
{block.width >= 2 * STEP && ( <text
<text x={block.x + block.width / 2}
x={block.x + block.width / 2} y={height - 3}
y={height - 3} textAnchor="middle"
textAnchor="middle" className="fill-muted-foreground text-[9px]"
className="fill-muted-foreground text-[9px]" >
> {block.label}
{block.label} </text>
</text>
)}
</g> </g>
))} ))}
</svg> </svg>
+14 -11
View File
@@ -29,27 +29,30 @@ export interface Stats {
topics: { issue: number; name: string; total: number; done: number; taught: boolean }[]; topics: { issue: number; name: string; total: number; done: number; taught: boolean }[];
/** The ten most recently solved, newest first, dated by first pass (ET). */ /** The ten most recently solved, newest first, dated by first pass (ET). */
recent: { lc: number; title: string; difficulty: Difficulty; date: string }[]; recent: { lc: number; title: string; difficulty: Difficulty; date: string }[];
/** The 365 days ending today (ET), zeros included, plus LeetCode's three figures. */ /**
year: { * The 365 days ending today (ET), zeros included — always the current
days: { date: string; attempts: number; passes: number }[]; * calendar year to date. Per difficulty: every logged attempt, and every
attempts: number; * first solve (the first-pass date `recent` uses).
active_days: number; */
max_streak: number; year: Day[];
}; }
export interface Day {
date: string;
passes: number;
attempts: Record<Difficulty, number>;
solved: Record<Difficulty, number>;
} }
function isStats(value: unknown): value is Stats { function isStats(value: unknown): value is Stats {
if (typeof value !== "object" || value === null) return false; if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>; const v = value as Record<string, unknown>;
const year = v.year as Record<string, unknown> | null | undefined;
return ( return (
Array.isArray(v.difficulty) && Array.isArray(v.difficulty) &&
typeof v.attempting === "number" && typeof v.attempting === "number" &&
Array.isArray(v.topics) && Array.isArray(v.topics) &&
Array.isArray(v.recent) && Array.isArray(v.recent) &&
typeof year === "object" && Array.isArray(v.year)
year !== null &&
Array.isArray(year.days)
); );
} }