diff --git a/apps/docs/components/ui/toggle-group.tsx b/apps/docs/components/ui/toggle-group.tsx new file mode 100644 index 0000000..bf79c7a --- /dev/null +++ b/apps/docs/components/ui/toggle-group.tsx @@ -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 & { + spacing?: number + } +>({ + size: "default", + variant: "default", + spacing: 0, +}) + +function ToggleGroup({ + className, + variant, + size, + spacing = 0, + children, + ...props +}: React.ComponentProps & + VariantProps & { + spacing?: number + }) { + return ( + + + {children} + + + ) +} + +function ToggleGroupItem({ + className, + children, + variant, + size, + ...props +}: React.ComponentProps & + VariantProps) { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +} + +export { ToggleGroup, ToggleGroupItem } diff --git a/apps/docs/components/ui/toggle.tsx b/apps/docs/components/ui/toggle.tsx new file mode 100644 index 0000000..d01474d --- /dev/null +++ b/apps/docs/components/ui/toggle.tsx @@ -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 & + VariantProps) { + return ( + + ) +} + +export { Toggle, toggleVariants } diff --git a/apps/docs/content/progress.mdx b/apps/docs/content/progress.mdx index 1df213c..a5be3a3 100644 --- a/apps/docs/content/progress.mdx +++ b/apps/docs/content/progress.mdx @@ -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 ---
- +
+ + +
diff --git a/apps/docs/islands/SubmissionChart.tsx b/apps/docs/islands/SubmissionChart.tsx new file mode 100644 index 0000000..d247ed0 --- /dev/null +++ b/apps/docs/islands/SubmissionChart.tsx @@ -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 = { + 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 { + 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("solved"); + const [grain, setGrain] = useState("month"); + + if (load.phase !== "ready") { + return ( + + + + + + ); + } + + 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 ( + + + value && setMode(value as Mode)} + className="rounded-full bg-muted p-0.5" + aria-label="Count" + > + + Solved + + + Submissions + + +
+ value && setGrain(value as Grain)} + className="rounded-full bg-muted p-0.5" + aria-label="Bucket size" + > + + M + + + W + + +
+
+ + + + + ( + + {data[Number(payload.value)]?.tick} + + )} + /> + + ( + items[0]?.payload?.label} + indicator="dot" + /> + )} + /> + {(["hard", "medium", "easy"] as const).map((d) => ( + + ))} + + + + + {DIFFICULTIES.map((d) => ( + + {chartConfig[d].label} + {totals[d]} + + ))} + {year} + +
+ ); +} diff --git a/apps/docs/islands/SubmissionHeatmap.tsx b/apps/docs/islands/SubmissionHeatmap.tsx index 4064575..88a2880 100644 --- a/apps/docs/islands/SubmissionHeatmap.tsx +++ b/apps/docs/islands/SubmissionHeatmap.tsx @@ -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 ( - + ); } - 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 ( - {year.attempts} - attempts in the past one year + {cells.reduce((n, c) => n + c.attempts, 0)} + attempts in the past {MONTHS} months - - Total active days: {year.active_days} + + + Active days:{" "} + {cells.filter((c) => c.attempts > 0).length} + + + Max streak: {streak} + - - Max streak: {year.max_streak} - - -
- + +
+ {blocks.map((block) => ( {block.cells.map((cell) => ( @@ -118,16 +146,14 @@ export default function SubmissionHeatmap() { } /> ))} - {block.width >= 2 * STEP && ( - - {block.label} - - )} + + {block.label} + ))} diff --git a/apps/docs/lib/stats.ts b/apps/docs/lib/stats.ts index 58e7fec..fd9321e 100644 --- a/apps/docs/lib/stats.ts +++ b/apps/docs/lib/stats.ts @@ -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; + solved: Record; } function isStats(value: unknown): value is Stats { if (typeof value !== "object" || value === null) return false; const v = value as Record; - const year = v.year as Record | 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) ); }