mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +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,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 }
|
||||
@@ -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 }
|
||||
@@ -1,12 +1,15 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
<div className="not-prose flex flex-col gap-6">
|
||||
<SolvedRing />
|
||||
<SubmissionHeatmap />
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<SubmissionHeatmap />
|
||||
<SubmissionChart />
|
||||
</div>
|
||||
<TopicBubbles />
|
||||
<RecentSolves />
|
||||
</div>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
/**
|
||||
* The past year of attempts — LeetCode's calendar heatmap: one cell per day,
|
||||
* weeks as columns, each month its own block with a gap between, and the
|
||||
* year's attempt count, active days and longest streak above it. The colour
|
||||
* ramp is the README/email heatmap's (theme.css `--heat-0..4`, level =
|
||||
* attempts capped at 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 the
|
||||
* column instead of scrolling.
|
||||
* The last six months of attempts — LeetCode's calendar heatmap, halved to
|
||||
* share its row with the submissions chart: one cell per day, weeks as
|
||||
* columns, each month its own block, and the window's attempt count, active
|
||||
* days and longest streak above it. The figures are counted from exactly the
|
||||
* cells drawn, so they cannot disagree with the grid. The colour ramp is the
|
||||
* README/email heatmap's (theme.css `--heat-0..4`, level = attempts capped at
|
||||
* 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 { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { type Stats, useStats, utcDate } from "../lib/stats";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { type Day, useStats, utcDate } from "../lib/stats";
|
||||
|
||||
export const client = "load";
|
||||
|
||||
const MONTHS = 6;
|
||||
const CELL = 10;
|
||||
const STEP = 13; // cell + gap
|
||||
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 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 {
|
||||
label: string;
|
||||
x: 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 =
|
||||
* weekday (Mon = 0) and column = its week counted from the block's first day,
|
||||
* so a month starting on a Thursday opens with a short first column.
|
||||
* Month blocks laid left to right over the last MONTHS calendar months. Within
|
||||
* a block a day sits at row = weekday (Mon = 0) and column = its week counted
|
||||
* 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[] = [];
|
||||
let x = 0;
|
||||
let month = -1;
|
||||
let block: Block | undefined;
|
||||
let lead = 0;
|
||||
let index = 0;
|
||||
for (const day of days) {
|
||||
for (const day of year) {
|
||||
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;
|
||||
month = date.getUTCMonth();
|
||||
lead = (date.getUTCDay() + 6) % 7;
|
||||
index = 0;
|
||||
block = { label: MONTH.format(date), x, width: 0, cells: [] };
|
||||
blocks.push(block);
|
||||
}
|
||||
const row = (lead + index) % 7;
|
||||
const col = Math.floor((lead + index) / 7);
|
||||
block!.cells.push({ x: x + col * STEP, y: row * STEP, date, attempts: day.attempts, passes: day.passes });
|
||||
block!.width = (col + 1) * STEP - (STEP - CELL);
|
||||
index++;
|
||||
const first = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
|
||||
const index = (first.getUTCDay() + 6) % 7 + date.getUTCDate() - 1;
|
||||
const col = Math.floor(index / 7);
|
||||
block.cells.push({
|
||||
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() {
|
||||
@@ -68,33 +82,47 @@ export default function SubmissionHeatmap() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<StatsPending load={load} className="h-32 w-full" />
|
||||
<StatsPending load={load} className="h-40 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { year } = load.stats;
|
||||
const { blocks, width } = layout(year.days);
|
||||
const { blocks, width } = layout(load.stats.year);
|
||||
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;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-baseline gap-2">
|
||||
<span className="font-semibold text-2xl tabular-nums">{year.attempts}</span>
|
||||
<span className="font-normal text-muted-foreground">attempts in the past one year</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 text-sm">attempts in the past {MONTHS} months</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Total active days: <span className="text-foreground tabular-nums">{year.active_days}</span>
|
||||
<CardDescription className="flex gap-4">
|
||||
<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>
|
||||
<CardAction className="text-muted-foreground text-sm">
|
||||
Max streak: <span className="text-foreground tabular-nums">{year.max_streak}</span>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div ref={hover.ref} className="relative" 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">
|
||||
<CardContent className="flex flex-1 items-center">
|
||||
<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 ${MONTHS} months`}
|
||||
>
|
||||
{blocks.map((block) => (
|
||||
<g key={`${block.label}-${block.x}`}>
|
||||
{block.cells.map((cell) => (
|
||||
@@ -118,16 +146,14 @@ export default function SubmissionHeatmap() {
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{block.width >= 2 * STEP && (
|
||||
<text
|
||||
x={block.x + block.width / 2}
|
||||
y={height - 3}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[9px]"
|
||||
>
|
||||
{block.label}
|
||||
</text>
|
||||
)}
|
||||
<text
|
||||
x={block.x + block.width / 2}
|
||||
y={height - 3}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[9px]"
|
||||
>
|
||||
{block.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
+14
-11
@@ -29,27 +29,30 @@ export interface Stats {
|
||||
topics: { issue: number; name: string; total: number; done: number; taught: boolean }[];
|
||||
/** The ten most recently solved, newest first, dated by first pass (ET). */
|
||||
recent: { lc: number; title: string; difficulty: Difficulty; date: string }[];
|
||||
/** The 365 days ending today (ET), zeros included, plus LeetCode's three figures. */
|
||||
year: {
|
||||
days: { date: string; attempts: number; passes: number }[];
|
||||
attempts: number;
|
||||
active_days: number;
|
||||
max_streak: number;
|
||||
};
|
||||
/**
|
||||
* The 365 days ending today (ET), zeros included — always the current
|
||||
* calendar year to date. Per difficulty: every logged attempt, and every
|
||||
* first solve (the first-pass date `recent` uses).
|
||||
*/
|
||||
year: Day[];
|
||||
}
|
||||
|
||||
export interface Day {
|
||||
date: string;
|
||||
passes: number;
|
||||
attempts: Record<Difficulty, number>;
|
||||
solved: Record<Difficulty, number>;
|
||||
}
|
||||
|
||||
function isStats(value: unknown): value is Stats {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
const year = v.year as Record<string, unknown> | null | undefined;
|
||||
return (
|
||||
Array.isArray(v.difficulty) &&
|
||||
typeof v.attempting === "number" &&
|
||||
Array.isArray(v.topics) &&
|
||||
Array.isArray(v.recent) &&
|
||||
typeof year === "object" &&
|
||||
year !== null &&
|
||||
Array.isArray(year.days)
|
||||
Array.isArray(v.year)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user