From 63f0a81af3fc081df808e2df3875ee59eff20ee8 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Tue, 29 Sep 2026 17:28:59 -0400 Subject: [PATCH] feat(docs): add progress dashboard components and styling --- apps/docs/components.json | 21 ++ apps/docs/components/progress/shared.tsx | 67 ++++ apps/docs/components/ui/card.tsx | 91 +++++ apps/docs/components/ui/chart.tsx | 372 ++++++++++++++++++++ apps/docs/components/ui/skeleton.tsx | 13 + apps/docs/content/progress.mdx | 9 +- apps/docs/islands/ProgressDashboard.tsx | 428 ----------------------- apps/docs/islands/RecentSolves.tsx | 81 +++++ apps/docs/islands/SolvedRing.tsx | 145 ++++++++ apps/docs/islands/SubmissionHeatmap.tsx | 139 ++++++++ apps/docs/islands/TopicBubbles.tsx | 139 ++++++++ apps/docs/lib/env.d.ts | 14 + apps/docs/lib/stats.ts | 85 +++++ apps/docs/lib/utils.ts | 7 + apps/docs/theme.css | 71 ++++ apps/docs/tsconfig.json | 14 + 16 files changed, 1266 insertions(+), 430 deletions(-) create mode 100644 apps/docs/components.json create mode 100644 apps/docs/components/progress/shared.tsx create mode 100644 apps/docs/components/ui/card.tsx create mode 100644 apps/docs/components/ui/chart.tsx create mode 100644 apps/docs/components/ui/skeleton.tsx delete mode 100644 apps/docs/islands/ProgressDashboard.tsx create mode 100644 apps/docs/islands/RecentSolves.tsx create mode 100644 apps/docs/islands/SolvedRing.tsx create mode 100644 apps/docs/islands/SubmissionHeatmap.tsx create mode 100644 apps/docs/islands/TopicBubbles.tsx create mode 100644 apps/docs/lib/env.d.ts create mode 100644 apps/docs/lib/stats.ts create mode 100644 apps/docs/lib/utils.ts create mode 100644 apps/docs/theme.css create mode 100644 apps/docs/tsconfig.json diff --git a/apps/docs/components.json b/apps/docs/components.json new file mode 100644 index 0000000..a96b68e --- /dev/null +++ b/apps/docs/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "theme.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/apps/docs/components/progress/shared.tsx b/apps/docs/components/progress/shared.tsx new file mode 100644 index 0000000..b412d2b --- /dev/null +++ b/apps/docs/components/progress/shared.tsx @@ -0,0 +1,67 @@ +/** + * What the four progress islands share besides the data: the not-ready body + * (a skeleton while /api/stats loads, one muted line if it fails) and the + * hover tooltip for the two charts drawn as plain SVG (the heatmap calendar + * and the topic bubbles — Recharts has neither layout). The tooltip wears the + * exact classes of shadcn's ChartTooltipContent, so all four cards' tooltips + * look like one component. + */ +import { type PointerEvent, type ReactNode, useRef, useState } from "react"; +import { cn } from "../../lib/utils"; +import type { Load } from "../../lib/stats"; +import { Skeleton } from "../ui/skeleton"; + +export function StatsPending({ load, className }: { load: Exclude; className: string }) { + if (load.phase === "loading") return ; + return ( +

+ Stats unavailable — they are served live from the SRS Worker, and it did not respond. +

+ ); +} + +interface Tip { + x: number; + y: number; + content: ReactNode; +} + +/** + * A pointer-following tooltip for SVG marks. Put `ref` on a `relative` + * wrapper, call `show` from a mark's pointer events, render `tip` inside the + * wrapper. Positions are relative to the wrapper, so page scroll never skews it. + */ +export function useHoverTip() { + const ref = useRef(null); + const [tip, setTip] = useState(null); + const show = (event: PointerEvent, content: ReactNode) => { + const box = ref.current?.getBoundingClientRect(); + if (box) setTip({ x: event.clientX - box.left, y: event.clientY - box.top, content }); + }; + const hide = () => setTip(null); + const node = tip && ( +
+ {tip.content} +
+ ); + return { ref, show, hide, node }; +} + +/** One tooltip row, the shape ChartTooltipContent draws: colour dot, label, mono value. */ +export function TipRow({ color, label, value }: { color: string; label: ReactNode; value: ReactNode }) { + return ( +
+
+
+ {label} + {value} +
+
+ ); +} diff --git a/apps/docs/components/ui/card.tsx b/apps/docs/components/ui/card.tsx new file mode 100644 index 0000000..c2909e0 --- /dev/null +++ b/apps/docs/components/ui/card.tsx @@ -0,0 +1,91 @@ +import * as React from "react" +import { cn } from "../../lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/apps/docs/components/ui/chart.tsx b/apps/docs/components/ui/chart.tsx new file mode 100644 index 0000000..e834ffb --- /dev/null +++ b/apps/docs/components/ui/chart.tsx @@ -0,0 +1,372 @@ +import * as React from "react" +import { cn } from "../../lib/utils" +import * as RechartsPrimitive from "recharts" +import type { TooltipValueType } from "recharts" + +// Format: { THEME_NAME: CSS_SELECTOR }. Blume switches dark mode with +// [data-theme="dark"] on , not shadcn's .dark class. +const THEMES = { light: "", dark: ':root[data-theme="dark"]' } as const + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const +type TooltipNameType = number | string + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +> + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + initialDimension?: { + width: number + height: number + } +}) { + const uniqueId = React.useId() + const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme ?? config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +