feat: replace hand-rolled SparkAreaChart with evilcharts area chart on stats page

- Add `recharts` and `motion` deps; register `@evilcharts` registry in `components.json`
- Vendor evilcharts primitives (`AreaChart`, `Chart`, `Dot`, `EvilBrush`, `Legend`, `Tooltip`, `Background`) under `components/evilcharts/`
- Add `ActivityChart` wrapper in `components/stats/` that uses the evilcharts `AreaChart` for the daily-run sparkline on `/stats`; replaces the hand-rolled inline-SVG `SparkAreaChart` (deleted)
- Update `stats.tsx` to render `ActivityChart` instead of the removed component
- Tighten `.gitignore`: add `.vercel`, collapse `.env.local` / `.env.*.local` to `.env*` with a `!.env.example` carve-out
This commit is contained in:
2026-05-26 17:34:35 -04:00
parent 187375ee01
commit 2bde308229
19 changed files with 3357 additions and 227 deletions
+4 -4
View File
@@ -6,6 +6,7 @@ dist/
*.tsbuildinfo
.tanstack/
.source/
.vercel
# IDE
.vscode/*
@@ -22,10 +23,9 @@ pnpm-debug.log*
.DS_Store
Thumbs.db
# Env
.env
.env.local
.env.*.local
# Secrets
.env*
!.env.example
# Test artifacts
coverage/
+3 -1
View File
@@ -21,5 +21,7 @@
},
"menuColor": "default-translucent",
"menuAccent": "subtle",
"registries": {}
"registries": {
"@evilcharts": "https://evilcharts.com/r/{name}.json"
}
}
+2
View File
@@ -34,12 +34,14 @@
"fumadocs-mdx": "^15.0.9",
"fumadocs-ui": "npm:@fumadocs/base-ui@^16.9.1",
"lru-cache": "^11.5.0",
"motion": "^12.40.0",
"nitro": "^3.0.260522-beta",
"posthog-js": "^1.376.2",
"posthog-node": "^5.35.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-resizable-panels": "^4.11.2",
"recharts": "^3.8.1",
"shadcn": "^4.8.0",
"shiki": "^4.1.0",
"sonner": "^2.0.7",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,229 @@
"use client";
import { useId } from "react";
import { ZIndexLayer } from "recharts";
// ── Background Variant Types ─────────────────────────────────────────────────
// To add a new variant:
// 1. Add its name to the BackgroundVariant union type below
// 2. Create a pattern component with PatternProps
// 3. Register it in PATTERN_MAP
export type BackgroundVariant =
| "dots"
| "grid"
| "cross-hatch"
| "diagonal-lines"
| "plus"
| "falling-triangles"
| "4-pointed-star"
| "tiny-checkers"
| "overlapping-circles"
| "wiggle-lines"
| "bubbles";
// ── Pattern Components ───────────────────────────────────────────────────────
type PatternProps = { id: string };
const DotsPattern = ({ id }: PatternProps) => (
<pattern id={id} x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<circle className="text-border dark:text-border" cx="2" cy="2" r="1" fill="currentColor" />
</pattern>
);
const GridPattern = ({ id }: PatternProps) => (
<pattern id={id} x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<path
className="text-border dark:text-border"
d="M 20 0 L 0 0 0 20"
fill="none"
stroke="currentColor"
strokeWidth="0.5"
/>
</pattern>
);
const CrossHatchPattern = ({ id }: PatternProps) => (
<pattern id={id} x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<path
className="text-border/60 dark:text-border/50"
d="M 0 0 L 20 20 M 20 0 L 0 20"
fill="none"
stroke="currentColor"
strokeWidth="0.5"
/>
</pattern>
);
const DiagonalLinesPattern = ({ id }: PatternProps) => (
<pattern
id={id}
x="0"
y="0"
width="6"
height="6"
patternUnits="userSpaceOnUse"
patternTransform="rotate(45)"
>
<line
className="text-border dark:text-border"
x1="0"
y1="0"
x2="0"
y2="6"
stroke="currentColor"
strokeWidth="0.5"
/>
</pattern>
);
const PlusPattern = ({ id }: PatternProps) => (
<pattern id={id} x="0" y="0" width="16" height="16" patternUnits="userSpaceOnUse">
<path
className="text-border dark:text-border"
d="M 8 4 L 8 12 M 4 8 L 12 8"
fill="none"
stroke="currentColor"
strokeWidth="0.5"
strokeLinecap="round"
/>
</pattern>
);
const FallingTrianglesPattern = ({ id }: PatternProps) => (
<pattern id={id} x="0" y="0" width="18" height="36" patternUnits="userSpaceOnUse">
<path
className="text-border dark:text-border"
d="M2 6h12L8 18 2 6zm18 36h12l-6 12-6-12z"
transform="scale(0.5)"
fill="currentColor"
fillOpacity="0.4"
/>
</pattern>
);
const FourPointedStarPattern = ({ id }: PatternProps) => (
<pattern id={id} x="0" y="0" width="16" height="16" patternUnits="userSpaceOnUse">
<polygon
className="text-border dark:text-border"
fillRule="evenodd"
points="5 3 8 4 5 5 4 8 3 5 0 4 3 3 4 0 5 3"
fill="currentColor"
fillOpacity="0.4"
/>
</pattern>
);
const TinyCheckersPattern = ({ id }: PatternProps) => (
<pattern id={id} x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
<path
className="text-border dark:text-border"
fillRule="evenodd"
d="M0 0h4v4H0V0zm4 4h4v4H4V4z"
fill="currentColor"
fillOpacity="0.2"
/>
</pattern>
);
const OverlappingCirclesPattern = ({ id }: PatternProps) => (
<pattern id={id} x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
<path
className="text-border dark:text-border"
fillRule="evenodd"
d="M25 25c0-2.762 2.238-5 5-5s5 2.238 5 5-2.238 5-5 5c0 2.762-2.238 5-5 5s-5-2.238-5-5 2.238-5 5-5zM5 5c0-2.762 2.238-5 5-5s5 2.238 5 5-2.238 5-5 5c0 2.762-2.238 5-5 5S0 12.762 0 10s2.238-5 5-5zm5 4c2.209 0 4-1.791 4-4s-1.791-4-4-4-4 1.791-4 4 1.791 4 4 4zm20 20c2.209 0 4-1.791 4-4s-1.791-4-4-4-4 1.791-4 4 1.791 4 4 4z"
fill="currentColor"
fillOpacity="0.4"
/>
</pattern>
);
const WiggleLinesPattern = ({ id }: PatternProps) => (
<pattern
id={id}
x="0"
y="0"
width="52"
height="26"
patternUnits="userSpaceOnUse"
patternTransform="scale(0.6)"
>
<path
className="text-border dark:text-border"
d="M10 10c0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6h2c0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4v2c-3.314 0-6-2.686-6-6 0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6zm25.464-1.95l8.486 8.486-1.414 1.414-8.486-8.486 1.414-1.414z"
fill="currentColor"
fillOpacity="0.4"
/>
</pattern>
);
const BubblesPattern = ({ id }: PatternProps) => (
<pattern
id={id}
x="0"
y="0"
width="100"
height="100"
patternUnits="userSpaceOnUse"
patternTransform="scale(0.6667)"
>
<path
className="text-border dark:text-border"
d="M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM34 90c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm56-76c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM12 86c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm28-65c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm23-11c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-6 60c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm29 22c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM32 63c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm57-13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-9-21c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM60 91c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM35 41c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM12 60c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2z"
fill="currentColor"
fillOpacity="0.4"
fillRule="evenodd"
/>
</pattern>
);
// ── Pattern Registry ─────────────────────────────────────────────────────────
// Map variant names to pattern components
const PATTERN_MAP: Record<BackgroundVariant, React.FC<PatternProps>> = {
dots: DotsPattern,
grid: GridPattern,
plus: PlusPattern,
bubbles: BubblesPattern,
"cross-hatch": CrossHatchPattern,
"diagonal-lines": DiagonalLinesPattern,
"falling-triangles": FallingTrianglesPattern,
"4-pointed-star": FourPointedStarPattern,
"tiny-checkers": TinyCheckersPattern,
"overlapping-circles": OverlappingCirclesPattern,
"wiggle-lines": WiggleLinesPattern,
};
// ── Main Component ───────────────────────────────────────────────────────────
// Usage: Place <ChartBackground variant="dots" /> inside any Recharts chart component.
// ZIndexLayer with zIndex={-1} ensures the background renders behind all chart content.
interface ChartBackgroundProps {
variant: BackgroundVariant;
}
export function ChartBackground({ variant }: ChartBackgroundProps) {
const baseId = useId().replace(/:/g, "");
const patternId = `${baseId}-bg-${variant}`;
const maskId = `${baseId}-bg-edge-fade`;
const filterId = `${baseId}-bg-blur`;
const PatternComponent = PATTERN_MAP[variant];
return (
<ZIndexLayer zIndex={-1}>
<defs>
<PatternComponent id={patternId} />
{/* Gaussian blur filter for soft edge fade */}
<filter id={filterId}>
<feGaussianBlur stdDeviation="25" />
</filter>
{/* Mask: a slightly inset white rect with blur creates smooth transparent edges */}
<mask id={maskId} maskUnits="userSpaceOnUse">
<rect x="8%" y="20%" width="85%" height="60%" fill="white" filter={`url(#${filterId})`} />
</mask>
</defs>
<rect width="100%" height="100%" fill={`url(#${patternId})`} mask={`url(#${maskId})`} />
</ZIndexLayer>
);
}
@@ -0,0 +1,284 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
type ThemeKey = keyof typeof THEMES;
// All Keys are optional at first
type ThemeColorsBase = {
[K in ThemeKey]?: string[];
};
// Require at least one theme key
type AtLeastOneThemeColor = {
[K in ThemeKey]: Required<Pick<ThemeColorsBase, K>> & Partial<Omit<ThemeColorsBase, K>>;
}[ThemeKey];
// THEMES is a const literal so its keys are exactly ThemeKey at runtime,
// but Object.keys is typed as string[] regardless.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const VALID_THEME_KEYS = Object.keys(THEMES) as ThemeKey[];
// Validation for chart config colors at runtime
function validateChartConfigColors(config: ChartConfig): void {
for (const [key, value] of Object.entries(config)) {
if (value.colors) {
const hasValidThemeKey = VALID_THEME_KEYS.some(
(themeKey) => value.colors?.[themeKey] !== undefined,
);
if (!hasValidThemeKey) {
throw new Error(
`[EvilCharts] Invalid chart config for "${key}": colors object must have at least one theme key (${VALID_THEME_KEYS.join(", ")}). Received empty object or invalid keys.`,
);
}
}
}
}
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
icon?: React.ComponentType;
colors?: AtLeastOneThemeColor;
}
>;
interface ChartContextProps {
config: ChartConfig;
}
const ChartContext = React.createContext<ChartContextProps | null>(null);
export function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
interface ChartContainerProps
extends
Omit<React.ComponentProps<"div">, "children">,
Pick<
React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>,
| "initialDimension"
| "aspect"
| "debounce"
| "minHeight"
| "minWidth"
| "maxHeight"
| "height"
| "width"
| "onResize"
| "children"
> {
config: ChartConfig;
innerResponsiveContainerStyle?: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["style"];
/** Optional content rendered below the chart (e.g. EvilBrush) */
footer?: React.ReactNode;
}
function ChartContainer({
id,
config,
initialDimension = { width: 320, height: 200 },
className,
children,
footer,
...props
}: Readonly<ChartContainerProps>) {
const uniqueId = React.useId();
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
// Validate chart config at runtime
validateChartConfigColors(config);
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"min-h-0 w-full flex-1",
"relative flex flex-col justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
!footer && "aspect-video",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
className="min-h-0 w-full flex-1"
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
{footer}
</div>
</ChartContext.Provider>
);
}
function LoadingIndicator({ isLoading }: { isLoading: boolean }) {
if (!isLoading) {
return null;
}
return (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<div className="flex items-center justify-center gap-2 rounded-md border bg-background px-2 py-0.5 text-sm text-primary">
<div className="h-3 w-3 animate-spin rounded-full border border-border border-t-primary" />
<span>Loading</span>
</div>
</div>
);
}
// Distribute colors evenly across slots, extra slots go to last color(s)
// Example: 2 colors for 4 slots → [red, red, pink, pink]
// Example: 3 colors for 4 slots → [red, pink, blue, blue]
function distributeColors(colorsArray: string[], maxCount: number): string[] {
const availableCount = colorsArray.length;
if (availableCount >= maxCount) {
return colorsArray.slice(0, maxCount);
}
const result: string[] = [];
const baseSlots = Math.floor(maxCount / availableCount);
const extraSlots = maxCount % availableCount;
// First (availableCount - extraSlots) colors get baseSlots each
// Last extraSlots colors get (baseSlots + 1) each
for (let colorIdx = 0; colorIdx < availableCount; colorIdx++) {
const color = colorsArray[colorIdx];
if (color === undefined) continue;
const isExtraColor = colorIdx >= availableCount - extraSlots;
const slotsForThisColor = baseSlots + (isExtraColor ? 1 : 0);
for (let j = 0; j < slotsForThisColor; j++) {
result.push(color);
}
}
return result;
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, entry]) => entry.colors);
if (!colorConfig.length) {
return null;
}
const generateCssVars = (theme: keyof typeof THEMES) =>
colorConfig
.flatMap(([key, itemConfig]) => {
const colorsArray = itemConfig.colors?.[theme];
if (!colorsArray || !Array.isArray(colorsArray) || colorsArray.length === 0) {
return [];
}
// Get max count across all themes for this key
const maxCount = getColorsCount(itemConfig);
// Distribute colors evenly across all required slots
const distributedColors = distributeColors(colorsArray, maxCount);
return distributedColors.map((color, index) => ` --color-${key}-${index}: ${color};`);
})
.filter(Boolean)
.join("\n");
const css = Object.entries(THEMES)
.map(
([theme, prefix]) =>
// `Object.entries` widens keys to `string`; THEMES is a const literal so
// every `theme` here is a valid ThemeKey at runtime.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
`${prefix} [data-chart=${id}] {\n${generateCssVars(theme as keyof typeof THEMES)}\n}`,
)
.join("\n");
return <style dangerouslySetInnerHTML={{ __html: css }} />;
};
// Helper to extract item config from a payload.
export function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
// Recharts' tooltip/legend item payloads are typed loosely; once we've guarded
// them as non-null objects, we can safely index them by string key.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const outer = payload as Record<string, unknown>;
const innerCandidate = outer.payload;
const inner =
typeof innerCandidate === "object" && innerCandidate !== null
? // oxlint-disable-next-line typescript/no-unsafe-type-assertion
(innerCandidate as Record<string, unknown>)
: undefined;
let configLabelKey: string = key;
if (typeof outer[key] === "string") {
configLabelKey = outer[key];
} else if (inner && typeof inner[key] === "string") {
configLabelKey = inner[key];
}
return configLabelKey in config ? config[configLabelKey] : config[key];
}
// Format values to percent for expanded charts
function axisValueToPercentFormatter(value: number) {
return `${Math.round(value * 100).toFixed(0)}%`;
}
// Pick the first string/number candidate as a series key, falling back to "value".
// Recharts types dataKey as `string | number | (data) => unknown`, so the function
// form needs to be filtered out before being used as an object index.
function pickKey(...candidates: unknown[]): string {
for (const c of candidates) {
if (typeof c === "string") return c;
if (typeof c === "number") return String(c);
}
return "value";
}
// Get max colors count across all themes for a config entry
function getColorsCount(config: ChartConfig[string]): number {
if (!config.colors) return 1;
const counts = VALID_THEME_KEYS.map((theme) => config.colors?.[theme]?.length ?? 0);
return Math.max(...counts, 1);
}
// Generate random loading data for skeleton/loading state
// min/max represent percentage of the range (0-100), defaults to 20-80 for realistic look
export const getLoadingData = (points: number = 10, min: number = 0, max: number = 70) => {
const range = max - min;
return Array.from({ length: points }, () => ({
loading: Math.floor(Math.random() * range) + min,
}));
};
export {
ChartContainer,
ChartStyle,
axisValueToPercentFormatter,
LoadingIndicator,
getColorsCount,
pickKey,
};
@@ -0,0 +1,176 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type DotVariant = "default" | "border" | "colored-border";
type ChartDotProps = {
cx?: number;
cy?: number;
dataKey: string;
chartId: string;
className?: string;
fillOpacity?: number;
type?: DotVariant;
/** Optional SVG <mask> id — lets the dot share an area's intro reveal wipe. */
maskId?: string;
};
const ChartDot = React.memo(function ChartDot({
cx,
cy,
dataKey,
chartId,
className,
fillOpacity = 1,
type = "default",
maskId,
}: ChartDotProps) {
const dotId = React.useId().replace(/:/g, "");
const gradientUrl = `url(#${chartId}-colors-${dataKey})`;
if (cx === undefined || cy === undefined) return null;
switch (type) {
case "border":
return (
<PrimaryBorderDot
cx={cx}
cy={cy}
dotId={dotId}
fillOpacity={fillOpacity}
gradientUrl={gradientUrl}
className={className}
maskId={maskId}
/>
);
case "colored-border":
return (
<ColoredBorderDot
cx={cx}
cy={cy}
dotId={dotId}
fillOpacity={fillOpacity}
gradientUrl={gradientUrl}
className={className}
maskId={maskId}
/>
);
default:
return (
<DefaultDot
cx={cx}
cy={cy}
dotId={dotId}
fillOpacity={fillOpacity}
gradientUrl={gradientUrl}
className={className}
maskId={maskId}
/>
);
}
});
type DotVariantProps = {
cx: number;
cy: number;
dotId: string;
fillOpacity: number;
gradientUrl: string;
className?: string;
maskId?: string;
};
const DefaultDot = React.memo(
({ cx, cy, dotId, fillOpacity, gradientUrl, className, maskId }: DotVariantProps) => {
const r = 3;
return (
<g className={className} mask={maskId ? `url(#${maskId})` : undefined}>
<defs>
<clipPath id={`dot-clip-${dotId}`}>
<circle cx={cx} cy={cy} r={r} />
</clipPath>
</defs>
{/* Full-width gradient rectangle clipped to dot shape */}
<rect
x="0"
y={cy - r}
width="100%"
height={r * 2}
fill={gradientUrl}
fillOpacity={fillOpacity}
clipPath={`url(#dot-clip-${dotId})`}
/>
</g>
);
},
);
DefaultDot.displayName = "DefaultDot";
const PrimaryBorderDot = React.memo(
({ cx, cy, dotId, fillOpacity, gradientUrl, className, maskId }: DotVariantProps) => {
const r = 6;
const strokeWidth = 5;
return (
<g className={cn(className, "text-background")} mask={maskId ? `url(#${maskId})` : undefined}>
<defs>
<clipPath id={`dot-clip-${dotId}`}>
<circle cx={cx} cy={cy} r={r} />
</clipPath>
</defs>
{/* Background stroke (border) */}
<circle cx={cx} cy={cy} r={r} fill="currentColor" />
{/* Inner gradient circle clipped */}
<rect
x="0"
y={cy - (r - strokeWidth / 2)}
width="100%"
height={(r - strokeWidth / 2) * 2}
fill={gradientUrl}
fillOpacity={fillOpacity}
clipPath={`url(#dot-clip-inner-${dotId})`}
/>
<defs>
<clipPath id={`dot-clip-inner-${dotId}`}>
<circle cx={cx} cy={cy} r={r - strokeWidth / 2} />
</clipPath>
</defs>
</g>
);
},
);
PrimaryBorderDot.displayName = "PrimaryBorderDot";
const ColoredBorderDot = React.memo(
({ cx, cy, dotId, fillOpacity, gradientUrl, className, maskId }: DotVariantProps) => {
const r = 3;
const strokeWidth = 1;
return (
<g className={cn(className, "text-background")} mask={maskId ? `url(#${maskId})` : undefined}>
<defs>
<clipPath id={`dot-clip-${dotId}`}>
<circle cx={cx} cy={cy} r={r + strokeWidth / 2} />
</clipPath>
</defs>
{/* Gradient stroke (border) via clipped rect */}
<rect
x="0"
y={cy - r - strokeWidth / 2}
width="100%"
height={(r + strokeWidth / 2) * 2}
fill={gradientUrl}
fillOpacity={fillOpacity}
clipPath={`url(#dot-clip-${dotId})`}
/>
{/* Inner solid fill */}
<circle cx={cx} cy={cy} r={r - strokeWidth / 2} fill="currentColor" />
</g>
);
},
);
ColoredBorderDot.displayName = "ColoredBorderDot";
export { ChartDot };
@@ -0,0 +1,689 @@
"use client";
import { motion, useMotionValue, useMotionValueEvent, useSpring, useTransform } from "motion/react";
import type { MotionValue } from "motion/react";
import { useCallback, useEffect, type ComponentProps } from "react";
import * as React from "react";
import { ResponsiveContainer, AreaChart, Area, LineChart, Line, BarChart, Bar } from "recharts";
import { ChartStyle, getColorsCount, type ChartConfig } from "@/components/evilcharts/ui/chart";
import { cn } from "@/lib/utils";
// ─── Types ──────────────────────────────────────────────────────────────────
type EvilBrushVariant = "line" | "area" | "bar";
type CurveType = ComponentProps<typeof Area>["type"];
interface EvilBrushRange {
startIndex: number;
endIndex: number;
}
interface EvilBrushProps {
/** Full dataset always rendered in the miniature chart */
data: Record<string, unknown>[];
/** Chart config with colour definitions */
chartConfig: ChartConfig;
/** Data keys to plot (default: all keys from chartConfig) */
dataKeys?: string[];
/** X-axis data key used for handle labels */
xDataKey?: string;
/** Visual variant of the mini chart */
variant?: EvilBrushVariant;
/** Pixel height of the brush */
height?: number;
/** Extra className */
className?: string;
/** Whether areas/bars should be stacked in the mini chart */
stacked?: boolean;
/** Stroke variant for line / area strokes in the mini chart */
strokeVariant?: "solid" | "dashed" | "animated-dashed";
/** Whether to connect null data points in line / area variants */
connectNulls?: boolean;
/** Radius for bar corners in the bar variant */
barRadius?: number;
// ── Controlled mode ──────────────────────────────────────────────────
/** Controlled start index */
startIndex?: number;
/** Controlled end index */
endIndex?: number;
// ── Uncontrolled mode ────────────────────────────────────────────────
/** Initial start index (uncontrolled) */
defaultStartIndex?: number;
/** Initial end index (uncontrolled) */
defaultEndIndex?: number;
/** Fired whenever the visible range changes */
onChange?: (range: EvilBrushRange) => void;
/** Format the handle label from the xDataKey value */
formatLabel?: (value: unknown, index: number) => string;
/** Curve type for line / area variants */
curveType?: CurveType;
/** Minimum number of data points that must remain selected */
minSpan?: number;
/** Whether to render labels on the handles */
showLabels?: boolean;
/** Skip rendering own ChartStyle (when inside a ChartContainer that already provides CSS vars) */
skipStyle?: boolean;
}
// ─── Spring config ──────────────────────────────────────────────────────────
const SPRING_CONFIG = { stiffness: 300, damping: 35, mass: 0.8 };
// ─── Pointer-capture drag hook ──────────────────────────────────────────────
// Replaces raw addEventListener with the modern Pointer Events API.
// setPointerCapture routes all pointer events to the originating element,
// so we get mouse + touch + pen support with zero global listeners.
type DragType = "left" | "right" | "middle";
interface DragState {
type: DragType;
originX: number;
originRange: EvilBrushRange;
}
function useBrushDrag({
range,
totalPoints,
containerRef,
commit,
}: {
range: EvilBrushRange;
totalPoints: number;
containerRef: React.RefObject<HTMLDivElement | null>;
commit: (next: EvilBrushRange, mode?: DragType) => void;
}) {
const dragRef = React.useRef<DragState | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
const toIndexDelta = useCallback(
(px: number) => {
if (!containerRef.current || totalPoints <= 1) return 0;
return Math.round(
(px / containerRef.current.getBoundingClientRect().width) * (totalPoints - 1),
);
},
[totalPoints, containerRef],
);
const onPointerDown = useCallback(
(e: React.PointerEvent, type: DragType) => {
e.preventDefault();
if (e.target instanceof Element) e.target.setPointerCapture(e.pointerId);
dragRef.current = { type, originX: e.clientX, originRange: { ...range } };
setIsDragging(true);
},
[range],
);
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
const d = dragRef.current;
if (!d) return;
const delta = toIndexDelta(e.clientX - d.originX);
const { type, originRange: o } = d;
if (type === "left") {
commit({ startIndex: o.startIndex + delta, endIndex: o.endIndex }, "left");
} else if (type === "right") {
commit({ startIndex: o.startIndex, endIndex: o.endIndex + delta }, "right");
} else {
const span = o.endIndex - o.startIndex;
let s = o.startIndex + delta;
let e2 = s + span;
if (s < 0) {
s = 0;
e2 = span;
}
if (e2 > totalPoints - 1) {
e2 = totalPoints - 1;
s = Math.max(0, e2 - span);
}
commit({ startIndex: s, endIndex: e2 }, "middle");
}
},
[toIndexDelta, totalPoints, commit],
);
const onPointerUp = useCallback((e: React.PointerEvent) => {
if (e.target instanceof Element) e.target.releasePointerCapture(e.pointerId);
dragRef.current = null;
setIsDragging(false);
}, []);
// Helper to bind all three pointer handlers for a given drag type
const bind = useCallback(
(type: DragType) => ({
onPointerDown: (e: React.PointerEvent) => onPointerDown(e, type),
onPointerMove,
onPointerUp,
}),
[onPointerDown, onPointerMove, onPointerUp],
);
return { isDragging, bind };
}
// ─── EvilBrush ────────────────────────────────────────────────────────────
function EvilBrush({
data,
chartConfig,
dataKeys,
xDataKey,
variant = "area",
height = 56,
className,
stacked = false,
strokeVariant = "solid",
connectNulls = false,
barRadius,
startIndex: controlledStart,
endIndex: controlledEnd,
defaultStartIndex = 0,
defaultEndIndex,
onChange,
formatLabel,
curveType = "monotone",
minSpan = 2,
showLabels = true,
skipStyle = false,
}: EvilBrushProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const keys = React.useMemo(() => dataKeys ?? Object.keys(chartConfig), [dataKeys, chartConfig]);
const totalPoints = data.length;
const chartId = React.useId().replace(/:/g, "");
// ── Controlled vs uncontrolled ──────────────────────────────────────────
const isControlled = controlledStart !== undefined && controlledEnd !== undefined;
const [internalRange, setInternalRange] = React.useState<EvilBrushRange>(() => ({
startIndex: Math.max(0, Math.min(defaultStartIndex, totalPoints - 1)),
endIndex: Math.max(0, Math.min(defaultEndIndex ?? totalPoints - 1, totalPoints - 1)),
}));
// Track the last committed range to avoid duplicate updates when small
// mouse movements don't produce index changes (e.g., at boundaries)
const lastCommittedRef = React.useRef<EvilBrushRange>(internalRange);
useEffect(() => {
if (!isControlled) {
setInternalRange((prev) => {
const adjusted = {
startIndex: Math.min(prev.startIndex, Math.max(0, totalPoints - 1)),
endIndex: Math.min(prev.endIndex, Math.max(0, totalPoints - 1)),
};
lastCommittedRef.current = adjusted;
return adjusted;
});
}
}, [totalPoints, isControlled]);
// ── Clamping & committing ───────────────────────────────────────────────
const clampRange = useCallback(
(range: EvilBrushRange, mode?: DragType): EvilBrushRange => {
let { startIndex, endIndex } = range;
const maxIndex = Math.max(0, totalPoints - 1);
startIndex = Math.max(0, Math.min(startIndex, maxIndex));
endIndex = Math.max(0, Math.min(endIndex, maxIndex));
if (mode === "left") {
const maxStart = Math.max(0, endIndex - minSpan);
startIndex = Math.min(startIndex, maxStart);
return { startIndex, endIndex };
}
if (mode === "right") {
const minEnd = Math.min(maxIndex, startIndex + minSpan);
endIndex = Math.max(endIndex, minEnd);
return { startIndex, endIndex };
}
if (endIndex - startIndex < minSpan) {
endIndex = Math.min(startIndex + minSpan, maxIndex);
if (endIndex - startIndex < minSpan) {
startIndex = Math.max(0, endIndex - minSpan);
}
}
return { startIndex, endIndex };
},
[totalPoints, minSpan],
);
const commit = useCallback(
(next: EvilBrushRange, mode?: DragType) => {
const clamped = clampRange(next, mode);
const last = lastCommittedRef.current;
// Only update if the range has actually changed — avoids unnecessary
// re-renders when the brush is at a boundary and small mouse movements
// don't produce index changes
if (last.startIndex === clamped.startIndex && last.endIndex === clamped.endIndex) {
return;
}
lastCommittedRef.current = clamped;
setInternalRange(clamped);
// Defer the parent callback — chart re-render happens at lower priority,
// React can skip intermediate frames during fast drags
React.startTransition(() => {
onChange?.(clamped);
});
},
[clampRange, onChange],
);
// ── Drag ────────────────────────────────────────────────────────────────
const { isDragging, bind } = useBrushDrag({
range: internalRange,
totalPoints,
containerRef,
commit,
});
// Position always driven by internalRange (never lags behind controlled props)
const range = internalRange;
// Sync internalRange with controlled props when not dragging
useEffect(() => {
if (isControlled && !isDragging) {
const syncedRange = { startIndex: controlledStart, endIndex: controlledEnd };
// eslint-disable-next-line react-hooks/set-state-in-effect
setInternalRange(syncedRange);
lastCommittedRef.current = syncedRange;
}
}, [isControlled, controlledStart, controlledEnd, isDragging]);
// ── Computed positions (%) ──────────────────────────────────────────────
const leftPct = totalPoints > 1 ? (range.startIndex / (totalPoints - 1)) * 100 : 0;
const rightPct = totalPoints > 1 ? (range.endIndex / (totalPoints - 1)) * 100 : 100;
// Drive all moving brush UI from the same springed edge values.
const leftTarget = useMotionValue(leftPct);
const rightTarget = useMotionValue(rightPct);
if (leftTarget.get() !== leftPct) leftTarget.set(leftPct);
if (rightTarget.get() !== rightPct) rightTarget.set(rightPct);
const leftSpring = useSpring(leftTarget, SPRING_CONFIG);
const rightSpring = useSpring(rightTarget, SPRING_CONFIG);
const leftPosition = useTransform(leftSpring, (v) => `${v}%`);
const rightPosition = useTransform(rightSpring, (v) => `${v}%`);
const leftOverlayWidth = useTransform(leftSpring, (v) => `${v}%`);
const rightOverlayWidth = useTransform(rightSpring, (v) => `${Math.max(0, 100 - v)}%`);
const selectedWidth = useMotionValue(`${Math.max(0, rightPct - leftPct)}%`);
const updateSelectedWidth = useCallback(() => {
selectedWidth.set(`${Math.max(0, rightSpring.get() - leftSpring.get())}%`);
}, [leftSpring, rightSpring, selectedWidth]);
useMotionValueEvent(leftSpring, "change", updateSelectedWidth);
useMotionValueEvent(rightSpring, "change", updateSelectedWidth);
const getLabel = useCallback(
(idx: number) => {
if (!xDataKey) return String(idx);
const v = data[idx]?.[xDataKey];
if (formatLabel) return formatLabel(v, idx);
return typeof v === "string" || typeof v === "number" ? String(v) : String(idx);
},
[data, xDataKey, formatLabel],
);
// ── Render ──────────────────────────────────────────────────────────────
if (totalPoints === 0) return null;
return (
<div
ref={containerRef}
data-chart={skipStyle ? undefined : chartId}
className={cn("group relative select-none", className)}
style={{ height }}
>
{!skipStyle && <ChartStyle id={chartId} config={chartConfig} />}
{/* Mini chart always shows all data */}
<div className="absolute inset-0 overflow-hidden rounded-md">
<MiniChart
data={data}
keys={keys}
chartConfig={chartConfig}
variant={variant}
curveType={curveType}
chartId={chartId}
stacked={stacked}
strokeVariant={strokeVariant === "animated-dashed" ? "dashed" : strokeVariant}
connectNulls={connectNulls}
barRadius={barRadius}
/>
</div>
{/* Dim overlay left */}
<motion.div
className="pointer-events-none absolute inset-y-0 left-0 rounded-l-md bg-background/70 backdrop-blur-[2px]"
style={{ width: leftOverlayWidth }}
/>
{/* Dim overlay right */}
<motion.div
className="pointer-events-none absolute inset-y-0 right-0 rounded-r-md bg-background/70 backdrop-blur-[2px]"
style={{ width: rightOverlayWidth }}
/>
{/* Selected region draggable to pan */}
<motion.div
className="absolute inset-y-0 cursor-grab touch-none rounded-sm border active:cursor-grabbing"
style={{ left: leftPosition, width: selectedWidth }}
{...bind("middle")}
/>
{/* Left handle */}
<BrushHandle
side="left"
position={leftPosition}
label={showLabels ? getLabel(range.startIndex) : undefined}
bind={bind("left")}
/>
{/* Right handle */}
<BrushHandle
side="right"
position={rightPosition}
label={showLabels ? getLabel(range.endIndex) : undefined}
bind={bind("right")}
/>
</div>
);
}
// ─── Brush Handle ───────────────────────────────────────────────────────────
function BrushHandle({
side,
position,
label,
bind,
}: {
side: "left" | "right";
position: MotionValue<string>;
label?: string;
bind: {
onPointerDown: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerUp: (e: React.PointerEvent) => void;
};
}) {
const isLeft = side === "left";
return (
<motion.div className="absolute inset-y-0 z-10" style={{ left: position }}>
<div
className={cn(
"group absolute inset-y-0 flex w-3 cursor-ew-resize touch-none items-center justify-center after:absolute after:inset-y-0 after:-left-4 after:w-11 after:content-['']",
isLeft ? "" : "-translate-x-full",
)}
{...bind}
>
<div
className={cn(
"relative flex h-4 w-1.5 items-center justify-center rounded-md bg-muted-foreground transition-colors group-hover:bg-foreground",
isLeft ? "-left-[5.5px]" : "-right-[5.5px]",
)}
>
<div className="flex flex-col gap-[2px]">
<div className="h-[2px] w-[2px] rounded-full bg-background/70" />
<div className="h-[2px] w-[2px] rounded-full bg-background/70" />
<div className="h-[2px] w-[2px] rounded-full bg-background/70" />
</div>
</div>
</div>
{label && (
<div
className={cn(
"pointer-events-none absolute -bottom-3 -translate-y-1/2 rounded-[3px] bg-foreground px-1 py-px text-[8px] leading-tight font-medium whitespace-nowrap text-background opacity-0 group-hover:opacity-100",
isLeft ? "left-1.5" : "right-1.5",
)}
>
{label}
</div>
)}
</motion.div>
);
}
// ─── Mini Chart ─────────────────────────────────────────────────────────────
function MiniChart({
data,
keys,
chartConfig,
variant,
curveType,
chartId,
stacked,
strokeVariant = "solid",
connectNulls = false,
barRadius,
}: {
data: Record<string, unknown>[];
keys: string[];
chartConfig: ChartConfig;
variant: EvilBrushVariant;
curveType: CurveType;
chartId: string;
stacked: boolean;
strokeVariant?: "solid" | "dashed" | "animated-dashed";
connectNulls?: boolean;
barRadius?: number;
}) {
const gradients = React.useMemo(
() =>
Object.entries(chartConfig)
.filter(([key]) => keys.includes(key))
.map(([dataKey, config]) => ({
dataKey,
colorsCount: getColorsCount(config),
})),
[chartConfig, keys],
);
const dashArray =
strokeVariant === "dashed" || strokeVariant === "animated-dashed" ? "4 4" : undefined;
const defsContent = (
<>
{/* Vertical fade gradient for area fill mask */}
{variant === "area" && (
<linearGradient id={`${chartId}-zm-vertical-fade`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="white" stopOpacity={0.15} />
<stop offset="100%" stopColor="white" stopOpacity={0} />
</linearGradient>
)}
{gradients.map(({ dataKey, colorsCount }) => {
const colorStops =
colorsCount === 1 ? (
<>
<stop offset="0%" stopColor={`var(--color-${dataKey}-0)`} />
<stop offset="100%" stopColor={`var(--color-${dataKey}-0)`} />
</>
) : (
Array.from({ length: colorsCount }, (_, i) => (
<stop
key={i}
offset={`${(i / (colorsCount - 1)) * 100}%`}
stopColor={`var(--color-${dataKey}-${i}, var(--color-${dataKey}-0))`}
/>
))
);
return (
<React.Fragment key={dataKey}>
{/* Vertical color gradient (stroke + bar fill) */}
<linearGradient id={`${chartId}-zm-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
{colorStops}
</linearGradient>
{/* Area fill: color gradient masked with vertical fade */}
{variant === "area" && (
<>
<mask id={`${chartId}-zm-fill-mask-${dataKey}`}>
<rect width="100%" height="100%" fill={`url(#${chartId}-zm-vertical-fade)`} />
</mask>
<pattern
id={`${chartId}-zm-fill-${dataKey}`}
patternUnits="userSpaceOnUse"
width="100%"
height="100%"
>
<rect
width="100%"
height="100%"
fill={`url(#${chartId}-zm-${dataKey})`}
mask={`url(#${chartId}-zm-fill-mask-${dataKey})`}
/>
</pattern>
</>
)}
</React.Fragment>
);
})}
</>
);
if (variant === "line") {
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 4, right: 0, bottom: 0, left: 0 }}>
<defs>{defsContent}</defs>
{keys.map((dk) => (
<Line
key={dk}
type={curveType}
dataKey={dk}
stroke={`url(#${chartId}-zm-${dk})`}
strokeWidth={1}
strokeOpacity={0.5}
strokeDasharray={dashArray}
connectNulls={connectNulls}
dot={false}
activeDot={false}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}
if (variant === "bar") {
const r = barRadius ?? 3;
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{ top: 2, right: 0, bottom: 0, left: 0 }}
barGap={2}
barSize={14}
>
<defs>{defsContent}</defs>
{keys.map((dk) => (
<Bar
key={dk}
dataKey={dk}
fill={`url(#${chartId}-zm-${dk})`}
fillOpacity={0.35}
stackId={stacked ? "zm-stack" : undefined}
isAnimationActive={false}
radius={[r, r, r, r]}
/>
))}
</BarChart>
</ResponsiveContainer>
);
}
// Default: area
return (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 4, right: 0, bottom: 0, left: 0 }}>
<defs>{defsContent}</defs>
{keys.map((dk) => (
<Area
key={dk}
type={curveType}
dataKey={dk}
stroke={`url(#${chartId}-zm-${dk})`}
fill={`url(#${chartId}-zm-fill-${dk})`}
strokeWidth={1}
strokeOpacity={0.5}
strokeDasharray={dashArray}
connectNulls={connectNulls}
fillOpacity={1}
stackId={stacked ? "zm-stack" : undefined}
dot={false}
activeDot={false}
isAnimationActive={false}
/>
))}
</AreaChart>
</ResponsiveContainer>
);
}
// ─── useEvilBrush Hook ──────────────────────────────────────────────────────
function useEvilBrush<TData extends Record<string, unknown>>({
data,
defaultStartIndex = 0,
defaultEndIndex,
}: {
data: TData[];
defaultStartIndex?: number;
defaultEndIndex?: number;
}) {
const [range, setRange] = React.useState<EvilBrushRange>({
startIndex: defaultStartIndex,
endIndex: defaultEndIndex ?? Math.max(0, data.length - 1),
});
// Defer the range used for data slicing — the brush handles move at the
// immediate `range` cadence while the expensive chart re-render uses the
// deferred value. React can skip intermediate slices during fast drags.
const deferredRange = React.useDeferredValue(range);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setRange({
startIndex: 0,
endIndex: Math.max(0, data.length - 1),
});
}, [data.length]);
const visibleData = React.useMemo(
() => data.slice(deferredRange.startIndex, deferredRange.endIndex + 1),
[data, deferredRange.startIndex, deferredRange.endIndex],
);
return {
range,
visibleData,
brushProps: {
startIndex: range.startIndex,
endIndex: range.endIndex,
onChange: setRange,
} satisfies Pick<EvilBrushProps, "startIndex" | "endIndex" | "onChange">,
};
}
export { EvilBrush, useEvilBrush, type EvilBrushProps, type EvilBrushRange, type EvilBrushVariant };
@@ -0,0 +1,198 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import {
getPayloadConfigFromPayload,
getColorsCount,
pickKey,
useChart,
} from "@/components/evilcharts/ui/chart";
import { cn } from "@/lib/utils";
type ChartLegendVariant =
| "square"
| "circle"
| "circle-outline"
| "rounded-square"
| "rounded-square-outline"
| "vertical-bar"
| "horizontal-bar";
function ChartLegendContent({
className,
hideIcon = false,
nameKey,
payload,
verticalAlign,
align = "right",
selected,
onSelectChange,
isClickable,
variant = "rounded-square",
}: React.ComponentProps<"div"> & {
hideIcon?: boolean;
nameKey?: string;
selected?: string | null;
isClickable?: boolean;
onSelectChange?: (selected: string | null) => void;
variant?: ChartLegendVariant;
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center gap-4 select-none",
align === "left" && "justify-start",
align === "center" && "justify-center",
align === "right" && "justify-end",
verticalAlign === "top" ? "pb-4" : "pt-4",
className,
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
// For pie charts, item.value contains the sector name (e.g., "chrome")
// For radial charts, the name is in item.payload[nameKey]
// For other charts, item.dataKey contains the series name (e.g., "desktop")
const payloadName =
nameKey && item.payload
? // Recharts types `item.payload` as `any`; narrowing it here
// to read a string-indexed value.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
(item.payload as Record<string, unknown>)[nameKey]
: undefined;
const key = pickKey(payloadName, item.value, item.dataKey);
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const isSelected = selected === null || selected === key;
// Get colors count for this item to determine gradient vs solid
const colorsCount = itemConfig ? getColorsCount(itemConfig) : 1;
return (
<div
key={key}
className={cn(
"flex items-center gap-1.5 transition-opacity [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
!isSelected && "opacity-30",
isClickable && "cursor-pointer",
)}
onClick={() => {
if (!isClickable) return;
onSelectChange?.(selected === key ? null : key);
}}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<LegendIndicator variant={variant} dataKey={key} colorsCount={colorsCount} />
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// Legend indicator — each variant gets its own branch so future variants
// can diverge freely in markup & style.
// ---------------------------------------------------------------------------
function LegendIndicator({
variant,
dataKey,
colorsCount,
}: {
variant: ChartLegendVariant;
dataKey: string;
colorsCount: number;
}) {
const fillStyle = getLegendFillStyle(dataKey, colorsCount);
const outlineStyle = getLegendOutlineStyle(dataKey, colorsCount);
switch (variant) {
case "square":
return <div className="h-2 w-2 shrink-0" style={fillStyle} />;
case "circle":
return <div className="h-2 w-2 shrink-0 rounded-full" style={fillStyle} />;
case "circle-outline":
return <div className="h-2.5 w-2.5 shrink-0 rounded-full p-[1.5px]" style={outlineStyle} />;
case "vertical-bar":
return <div className="h-3 w-1 shrink-0 rounded-[2px]" style={fillStyle} />;
case "horizontal-bar":
return <div className="h-1 w-3 shrink-0 rounded-[2px]" style={fillStyle} />;
case "rounded-square-outline":
return <div className="h-2.5 w-2.5 shrink-0 rounded-[3px] p-[1.5px]" style={outlineStyle} />;
case "rounded-square":
default:
return <div className="h-2 w-2 shrink-0 rounded-[2px]" style={fillStyle} />;
}
}
// ---------------------------------------------------------------------------
// Style helpers
// ---------------------------------------------------------------------------
/** Solid fill / gradient background for filled variants. */
function getLegendFillStyle(dataKey: string, colorsCount: number): React.CSSProperties {
if (colorsCount <= 1) {
return { backgroundColor: `var(--color-${dataKey}-0)` };
}
const stops = Array.from({ length: colorsCount }, (_, i) => {
const offset = (i / (colorsCount - 1)) * 100;
return `var(--color-${dataKey}-${i}) ${offset}%`;
}).join(", ");
return { background: `linear-gradient(to right, ${stops})` };
}
/**
* Outline style for stroke variants.
* Uses background + mask-composite to punch out the center, leaving only the
* "border" visible. Works with both solid colors and gradients, and respects
* border-radius — unlike plain `border-color`.
*/
function getLegendOutlineStyle(dataKey: string, colorsCount: number): React.CSSProperties {
const maskStyle: React.CSSProperties = {
WebkitMask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
WebkitMaskComposite: "xor",
mask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
maskComposite: "exclude",
};
if (colorsCount <= 1) {
return {
backgroundColor: `var(--color-${dataKey}-0)`,
...maskStyle,
};
}
const stops = Array.from({ length: colorsCount }, (_, i) => {
const offset = (i / (colorsCount - 1)) * 100;
return `var(--color-${dataKey}-${i}) ${offset}%`;
}).join(", ");
return {
background: `linear-gradient(to right, ${stops})`,
...maskStyle,
};
}
const ChartLegend = RechartsPrimitive.Legend;
export { ChartLegend, ChartLegendContent, type ChartLegendVariant };
@@ -0,0 +1,198 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import {
getPayloadConfigFromPayload,
getColorsCount,
pickKey,
useChart,
} from "@/components/evilcharts/ui/chart";
import { cn } from "@/lib/utils";
type TooltipRoundness = "sm" | "md" | "lg" | "xl";
type TooltipVariant = "default" | "frosted-glass";
const roundnessMap: Record<TooltipRoundness, string> = {
sm: "rounded-sm",
md: "rounded-md",
lg: "rounded-lg",
xl: "rounded-xl",
};
const variantMap: Record<TooltipVariant, string> = {
default: "bg-background",
"frosted-glass": "bg-background/70 backdrop-blur-sm",
};
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
nameKey,
labelKey,
selected,
roundness = "lg",
variant = "default",
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
selected?: string | null;
roundness?: TooltipRoundness;
variant?: TooltipVariant;
} & Omit<RechartsPrimitive.DefaultTooltipContentProps, "accessibilityLayer">) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = pickKey(labelKey, item?.dataKey, item?.name);
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string" ? (config[label]?.label ?? label) : itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
// Empty tooltip - to prevent position getting 0.0 so it doesnt animate tooltip every time from 0.0 origin
return <span className="p-4" />;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 border border-border/50 px-2.5 py-1.5 text-xs shadow-xl",
roundnessMap[roundness],
variantMap[variant],
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
// For pie charts, item.name contains the sector name (e.g., "chrome")
// For radial charts, the name is in item.payload[nameKey]
// For other charts, item.name or item.dataKey contains the series name
const payloadName =
nameKey && item.payload
? // Recharts types `item.payload` as `any`; narrowing it here
// to read a string-indexed value.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
(item.payload as Record<string, unknown>)[nameKey]
: undefined;
const key = pickKey(payloadName, item.name, item.dataKey);
const itemConfig = getPayloadConfigFromPayload(config, item, key);
// Get colors count for this item to determine gradient vs solid
const colorsCount = itemConfig ? getColorsCount(itemConfig) : 1;
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
selected != null && selected !== item.dataKey && "opacity-30",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent!":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={getIndicatorColorStyle(key, colorsCount)}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between gap-4 leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
function getIndicatorColorStyle(dataKey: string, colorsCount: number): React.CSSProperties {
if (colorsCount <= 1) {
return { background: `var(--color-${dataKey}-0)` };
}
// Multiple colors: create linear gradient with evenly distributed stops
const stops = Array.from({ length: colorsCount }, (_, index) => {
const offset = (index / (colorsCount - 1)) * 100;
return `var(--color-${dataKey}-${index}) ${offset}%`;
}).join(", ");
return { background: `linear-gradient(to right, ${stops})` };
}
const ChartTooltip = ({
animationDuration = 200,
...props
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip>) => (
<RechartsPrimitive.Tooltip animationDuration={animationDuration} {...props} />
);
export { ChartTooltip, ChartTooltipContent };
export type { TooltipRoundness, TooltipVariant };
@@ -0,0 +1,67 @@
import {
ActiveDot,
Area,
EvilAreaChart,
Tooltip,
XAxis,
} from "@/components/evilcharts/charts/area-chart";
import { type ChartConfig } from "@/components/evilcharts/ui/chart";
type ActivityPoint = { date: string; count: number };
type ActivityChartProps = {
activity30d: ReadonlyArray<ActivityPoint>;
isLoading: boolean;
};
const dayLabelFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
});
function formatDayLabel(isoDate: string): string {
const date = new Date(`${isoDate}T00:00:00Z`);
if (Number.isNaN(date.getTime())) return isoDate;
return dayLabelFormatter.format(date);
}
const activityConfig = {
count: {
label: "Runs",
colors: {
light: ["var(--color-chart-4)"],
dark: ["var(--color-chart-1)"],
},
},
} satisfies ChartConfig;
/**
* Pulled into its own module so the route can lazy-import it. Recharts
* (~140kb gz) lives in this chunk only and never loads until the chart
* actually renders — direct-nav to /stats paints the chrome and KPIs
* without paying for it.
*/
export default function ActivityChart({ activity30d, isLoading }: ActivityChartProps) {
const data = activity30d.map((day) => ({
count: day.count,
day: formatDayLabel(day.date),
}));
return (
<EvilAreaChart
data={data}
config={activityConfig}
className="aspect-auto h-24 w-full"
curveType="monotone"
animationType="left-to-right"
isLoading={isLoading}
>
<XAxis dataKey="day" hide />
<Tooltip cursor={false} />
<Area dataKey="count" variant="gradient" strokeVariant="solid">
<ActiveDot variant="colored-border" />
</Area>
</EvilAreaChart>
);
}
+30 -6
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
@@ -28,8 +28,21 @@ type BarListProps = {
* outside the bar for readability against the muted backdrop. Rows are
* interactive when `href` is set: hover deepens the fill so the affordance is
* felt across the whole bar, not just the link text.
*
* Bars carry an EvilCharts-style diagonal hatch laid over a horizontal gradient,
* and grow in from 0% on mount with a small per-row stagger so the leaderboard
* settles into place instead of flashing fully-formed.
*/
export function BarList({ data, emptyMessage = "No data yet.", className }: BarListProps) {
// Bars render at width 0 on first commit, then transition to their real
// width once `mounted` flips after the initial paint. Without the deferred
// flip, the transition would have nothing to interpolate from.
const [mounted, setMounted] = useState(false);
useEffect(() => {
const raf = requestAnimationFrame(() => setMounted(true));
return () => cancelAnimationFrame(raf);
}, []);
if (data.length === 0) {
return (
<p className={cn("py-2 text-xs/relaxed text-muted-foreground/70", className)}>
@@ -40,15 +53,26 @@ export function BarList({ data, emptyMessage = "No data yet.", className }: BarL
const max = data.reduce((acc, entry) => Math.max(acc, entry.value), 0) || 1;
return (
<ol className={cn("flex w-full flex-col gap-1", className)}>
{data.map((entry) => {
{data.map((entry, idx) => {
const pct = (entry.value / max) * 100;
return (
<li key={entry.name} className="group/row relative h-7">
<li key={entry.name} className="group/row relative h-7 overflow-hidden">
<div
aria-hidden
className="absolute inset-y-0 left-0 bg-muted transition-colors group-hover/row:bg-foreground/15"
style={{ width: `${pct}%` }}
/>
className={cn(
"absolute inset-y-0 left-0 bg-gradient-to-r from-muted via-muted/70 to-muted/30 transition-[width] duration-700 ease-out",
"group-hover/row:from-foreground/20 group-hover/row:via-foreground/12 group-hover/row:to-foreground/5",
)}
style={{
width: mounted ? `${pct}%` : 0,
transitionDelay: `${Math.min(idx * 40, 240)}ms`,
}}
>
<div
aria-hidden
className="absolute inset-0 bg-[image:repeating-linear-gradient(135deg,currentColor_0_1px,transparent_1px_5px)] text-foreground opacity-[0.07] transition-opacity duration-300 group-hover/row:opacity-[0.14]"
/>
</div>
<div className="relative flex h-full items-center justify-between gap-3 px-2 text-xs">
<div className="flex min-w-0 items-center gap-2">
{entry.leading}
-169
View File
@@ -1,169 +0,0 @@
import { cn } from "@/lib/utils";
export type SparkPoint = {
/** Tooltip label / accessible description (e.g. "2026-04-12"). */
label: string;
value: number;
};
type SparkAreaChartProps = {
data: ReadonlyArray<SparkPoint>;
/** Aria summary, e.g. "CLI runs per day over the last 30 days". */
ariaLabel: string;
/** Render height in pixels. Width is responsive via viewBox. */
height?: number;
className?: string;
};
const VIEW_WIDTH = 600;
/**
* Inspired by Tremor Raw's SparkAreaChart but rendered as a single inline SVG
* with no charting deps. Monotone-cubic-smoothed line with a soft fill, a
* baseline ruler, and a "you-are-here" dot on the latest point. Each datapoint
* has an invisible 4px-wide tooltip target via `<title>` for native hover.
*
* Uses `--color-chart-1`/`--color-chart-2` so it tracks the existing greyscale
* palette and dark-mode switch automatically.
*/
export function SparkAreaChart({ data, ariaLabel, height = 80, className }: SparkAreaChartProps) {
if (data.length < 2) {
return (
<div
className={cn("flex items-center justify-center text-xs text-muted-foreground", className)}
style={{ height }}
>
Not enough data yet.
</div>
);
}
const max = data.reduce((acc, point) => Math.max(acc, point.value), 0);
const safeMax = max === 0 ? 1 : max;
const stepX = VIEW_WIDTH / (data.length - 1);
const baselineY = height - 1;
const points = data.map((point, i) => ({
x: i * stepX,
y: height - (point.value / safeMax) * (height - 4) - 2,
point,
}));
const linePath = monotonePath(points);
const fillPath = `${linePath} L${VIEW_WIDTH.toFixed(2)},${baselineY.toFixed(2)} L0,${baselineY.toFixed(2)} Z`;
const last = points[points.length - 1];
return (
<div className={cn("relative w-full", className)} style={{ height }}>
<svg
role="img"
aria-label={ariaLabel}
viewBox={`0 0 ${VIEW_WIDTH} ${height}`}
preserveAspectRatio="none"
className="block size-full overflow-visible"
>
<line
x1={0}
x2={VIEW_WIDTH}
y1={baselineY}
y2={baselineY}
stroke="var(--color-border)"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
<path d={fillPath} fill="var(--color-chart-1)" fillOpacity={0.35} />
<path
d={linePath}
fill="none"
stroke="var(--color-chart-2)"
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{points.map(({ x, point }) => (
<rect key={point.label} x={x - 2} y={0} width={4} height={height} fill="transparent">
<title>{`${point.label}: ${point.value}`}</title>
</rect>
))}
</svg>
{/*
* The chart SVG uses `preserveAspectRatio="none"` so the curve stretches
* to the container width. That stretches any inline <circle> into a
* horizontal oval, so the last-point dot lives in the DOM instead and
* stays perfectly round at any width.
*/}
{last ? (
<span
aria-hidden
className="pointer-events-none absolute size-[9px] rounded-full border-[1.5px] border-foreground bg-background"
style={{
right: 0,
top: `${(last.y / height) * 100}%`,
transform: "translate(50%, -50%)",
}}
/>
) : null}
</div>
);
}
/**
* Monotone cubic interpolation (FritschCarlson). Produces a smooth curve that
* never overshoots between samples — important for data viz, where straight
* Catmull-Rom can wiggle above peaks and misrepresent the dataset.
*/
function monotonePath(points: ReadonlyArray<{ x: number; y: number }>): string {
const n = points.length;
if (n === 0) return "";
if (n === 1) {
const p = points[0]!;
return `M${p.x.toFixed(2)},${p.y.toFixed(2)}`;
}
const slopes: number[] = [];
for (let i = 0; i < n - 1; i++) {
const a = points[i]!;
const b = points[i + 1]!;
slopes.push((b.y - a.y) / (b.x - a.x));
}
const tangents: number[] = Array.from({ length: n }, () => 0);
tangents[0] = slopes[0]!;
tangents[n - 1] = slopes[n - 2]!;
for (let i = 1; i < n - 1; i++) {
const prev = slopes[i - 1]!;
const next = slopes[i]!;
tangents[i] = prev * next <= 0 ? 0 : (prev + next) / 2;
}
// Enforce monotonicity: clamp tangents per FritschCarlson.
for (let i = 0; i < n - 1; i++) {
const s = slopes[i]!;
if (s === 0) {
tangents[i] = 0;
tangents[i + 1] = 0;
continue;
}
const a = tangents[i]! / s;
const b = tangents[i + 1]! / s;
const h = Math.hypot(a, b);
if (h > 3) {
const t = 3 / h;
tangents[i] = t * a * s;
tangents[i + 1] = t * b * s;
}
}
const first = points[0]!;
let path = `M${first.x.toFixed(2)},${first.y.toFixed(2)}`;
for (let i = 0; i < n - 1; i++) {
const a = points[i]!;
const b = points[i + 1]!;
const dx = (b.x - a.x) / 3;
const c1x = a.x + dx;
const c1y = a.y + tangents[i]! * dx;
const c2x = b.x - dx;
const c2y = b.y - tangents[i + 1]! * dx;
path += ` C${c1x.toFixed(2)},${c1y.toFixed(2)} ${c2x.toFixed(2)},${c2y.toFixed(2)} ${b.x.toFixed(2)},${b.y.toFixed(2)}`;
}
return path;
}
+16 -10
View File
@@ -13,23 +13,29 @@ type TooltipContextValue = {
const TooltipContext = React.createContext<TooltipContextValue | null>(null);
function Tooltip({ ...props }: PopoverPrimitive.Root.Props & TooltipPrimitive.Root.Props) {
function TooltipProvider({ delay = 0, children, ...props }: TooltipPrimitive.Provider.Props) {
const { isTouchDevice } = usePointerCapability();
const contextValue = React.useMemo(() => ({ isTouchDevice }), [isTouchDevice]);
return (
<TooltipContext.Provider value={contextValue}>
{isTouchDevice ? (
<PopoverPrimitive.Root data-slot="tooltip" {...props} />
) : (
<TooltipPrimitive.Provider delay={0}>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipPrimitive.Provider>
)}
<TooltipPrimitive.Provider delay={delay} {...props}>
{children}
</TooltipPrimitive.Provider>
</TooltipContext.Provider>
);
}
function Tooltip({ ...props }: PopoverPrimitive.Root.Props & TooltipPrimitive.Root.Props) {
const { isTouchDevice } = useTooltipContext("Tooltip");
return isTouchDevice ? (
<PopoverPrimitive.Root data-slot="tooltip" {...props} />
) : (
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
);
}
function TooltipTrigger({
nativeButton,
closeDelay,
@@ -95,10 +101,10 @@ function useTooltipContext(componentName: string) {
const ctx = React.use(TooltipContext);
if (!ctx) {
throw new Error(`${componentName} must be used within <Tooltip>.`);
throw new Error(`${componentName} must be used within <TooltipProvider>.`);
}
return ctx;
}
export { Tooltip, TooltipTrigger, TooltipContent };
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
+18 -15
View File
@@ -9,6 +9,7 @@ import { PostHogProvider } from "@/components/posthog-provider";
import { RouteErrorBoundary, RouteNotFoundBoundary } from "@/components/route-boundaries";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { getDocsIndex } from "@/server/docs-index.functions";
import { getRegistryIndex } from "@/server/registry-index.functions";
@@ -45,21 +46,23 @@ function RootComponent() {
<body className="min-h-svh bg-background font-sans text-foreground tabular-nums antialiased">
<PostHogProvider>
<ThemeProvider defaultTheme="system" storageKey="stanza-theme">
<a
href="#main"
className="sr-only focus-visible:not-sr-only focus-visible:fixed focus-visible:top-4 focus-visible:left-4 focus-visible:z-50 focus-visible:rounded-none focus-visible:border focus-visible:border-border focus-visible:bg-background focus-visible:px-3 focus-visible:py-2 focus-visible:text-sm focus-visible:ring-2 focus-visible:ring-ring"
>
Skip to content
</a>
<NavigationProgress />
<div className="flex min-h-svh flex-col">
<Header />
<main id="main" className="flex-1">
<Outlet />
</main>
<Footer />
</div>
<Toaster />
<TooltipProvider>
<a
href="#main"
className="sr-only focus-visible:not-sr-only focus-visible:fixed focus-visible:top-4 focus-visible:left-4 focus-visible:z-50 focus-visible:rounded-none focus-visible:border focus-visible:border-border focus-visible:bg-background focus-visible:px-3 focus-visible:py-2 focus-visible:text-sm focus-visible:ring-2 focus-visible:ring-ring"
>
Skip to content
</a>
<NavigationProgress />
<div className="flex min-h-svh flex-col">
<Header />
<main id="main" className="flex-1">
<Outlet />
</main>
<Footer />
</div>
<Toaster />
</TooltipProvider>
</ThemeProvider>
</PostHogProvider>
<TanStackDevtools
+43 -22
View File
@@ -1,19 +1,17 @@
import type { CategoryId, ModuleSummary } from "@stanza/registry";
import { categoryLabel, KNOWN_CATEGORIES } from "@stanza/registry";
import { createFileRoute, Link, useLoaderData } from "@tanstack/react-router";
import { lazy, Suspense, useEffect, useState } from "react";
import { ModuleLogo } from "@/components/module-logo";
import { BarList } from "@/components/ui/bar-list";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SparkAreaChart } from "@/components/ui/spark-chart";
import { buildHead } from "@/lib/seo";
import { getStats } from "@/server/stats.functions";
import { getStats, type Stats } from "@/server/stats.functions";
const ActivityChart = lazy(() => import("@/components/stats/activity-chart"));
export const Route = createFileRoute("/stats")({
loader: () => getStats(),
// Stats are revalidated server-side by the cache TTL; client doesn't need
// to refetch on every navigation.
staleTime: 60_000,
head: () =>
buildHead({
title: "Stats",
@@ -49,13 +47,33 @@ function moduleHref(category: CategoryId, id: string): string {
}
function StatsPage() {
const stats = Route.useLoaderData();
const [stats, setStats] = useState<Stats | null>(null);
const { registry } = useLoaderData({ from: "__root__" });
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const result = await getStats();
if (!cancelled) setStats(result);
} catch {
// Swallow: the shell already shows the em-dash / "no data yet"
// empty state, which is the right UX for a public dashboard that
// happens to hit a transient PostHog blip.
}
};
void load();
return () => {
cancelled = true;
};
}, []);
const isLoading = stats === null;
const findModule = (category: CategoryId, id: string): ModuleSummary | undefined =>
registry.modules.find((m: ModuleSummary) => m.category === category && m.id === id);
const activitySum = stats.activity30d.reduce((acc, day) => acc + day.count, 0);
const activitySum = stats?.activity30d.reduce((acc, day) => acc + day.count, 0) ?? 0;
return (
<div className="mx-auto max-w-7xl px-4 py-10 sm:px-6">
@@ -68,9 +86,11 @@ function StatsPage() {
</a>
.
</p>
<p className="mt-4 font-mono text-xs text-muted-foreground/70 tabular-nums">
Last refreshed {formatGeneratedAt(stats.generatedAt)} UTC
</p>
{stats ? (
<p className="mt-4 font-mono text-xs text-muted-foreground/70 tabular-nums">
Last refreshed {formatGeneratedAt(stats.generatedAt)} UTC
</p>
) : null}
</header>
<section className="mb-4 grid gap-4 sm:grid-cols-2">
@@ -82,7 +102,7 @@ function StatsPage() {
</CardHeader>
<CardContent>
<p className="font-mono text-4xl font-medium tracking-tight tabular-nums">
{formatCount(stats.projectsScaffolded)}
{formatCount(stats?.projectsScaffolded ?? 0)}
</p>
</CardContent>
</Card>
@@ -94,7 +114,7 @@ function StatsPage() {
</CardHeader>
<CardContent>
<p className="font-mono text-4xl font-medium tracking-tight tabular-nums">
{formatCount(stats.modulesInstalled)}
{formatCount(stats?.modulesInstalled ?? 0)}
</p>
</CardContent>
</Card>
@@ -107,17 +127,17 @@ function StatsPage() {
<CardTitle className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
CLI runs &middot; last 30 days
</CardTitle>
<span className="font-mono text-xs text-muted-foreground tabular-nums">
{activitySum > 0 ? `${numberFormatter.format(activitySum)} total` : "no runs yet"}
</span>
{!isLoading ? (
<span className="font-mono text-xs text-muted-foreground tabular-nums">
{activitySum > 0 ? `${numberFormatter.format(activitySum)} total` : "no runs yet"}
</span>
) : null}
</div>
</CardHeader>
<CardContent className="pb-1">
<SparkAreaChart
data={stats.activity30d.map((day) => ({ label: day.date, value: day.count }))}
ariaLabel="CLI runs per day over the last 30 days"
height={96}
/>
<Suspense fallback={<div aria-hidden className="h-24 w-full" />}>
<ActivityChart activity30d={stats?.activity30d ?? []} isLoading={isLoading} />
</Suspense>
</CardContent>
</Card>
</section>
@@ -126,7 +146,7 @@ function StatsPage() {
<h2 className="mb-4 text-lg font-semibold tracking-tight">Popular modules by category</h2>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{KNOWN_CATEGORIES.map((category) => {
const entries = stats.perCategory[category] ?? [];
const entries = stats?.perCategory[category] ?? [];
const totalInCategory = entries.reduce((acc, entry) => acc + entry.count, 0);
return (
<Card key={category}>
@@ -144,6 +164,7 @@ function StatsPage() {
</CardHeader>
<CardContent>
<BarList
emptyMessage={isLoading ? "Loading…" : "No data yet."}
data={entries.map((entry) => {
const summary = findModule(category, entry.id);
const label = summary?.label ?? entry.id;
+1
View File
@@ -66,6 +66,7 @@ export default defineConfig({
"@base-ui/react/tooltip",
"@base-ui/react/merge-props",
"@base-ui/react/use-render",
"recharts",
],
},
});
+292
View File
@@ -11,6 +11,7 @@ catalogs:
version: 0.1.22
overrides:
es-toolkit: 1.46.1
vite: npm:@voidzero-dev/vite-plus-core@^0.1.22
vitest: npm:@voidzero-dev/vite-plus-test@^0.1.22
@@ -154,6 +155,9 @@ importers:
lru-cache:
specifier: ^11.5.0
version: 11.5.0
motion:
specifier: ^12.40.0
version: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
nitro:
specifier: ^3.0.260522-beta
version: 3.0.260522-beta(@vercel/functions@3.6.0)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(typescript@6.0.3))(chokidar@5.0.0)(dotenv@17.4.2)(jiti@2.7.0)(lru-cache@11.5.0)
@@ -172,6 +176,9 @@ importers:
react-resizable-panels:
specifier: ^4.11.2
version: 4.11.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react-is@17.0.2)(react@19.2.6)(redux@5.0.1)
shadcn:
specifier: ^4.8.0
version: 4.8.0(@types/node@25.9.1)(typescript@6.0.3)
@@ -1639,6 +1646,17 @@ packages:
'@protobufjs/utf8@1.1.1':
resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
'@reduxjs/toolkit@2.12.0':
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/binding-android-arm64@1.0.2':
resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1811,6 +1829,9 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@tabler/icons-react@3.44.0':
resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==}
peerDependencies:
@@ -2253,6 +2274,33 @@ packages:
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
@@ -2309,6 +2357,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/validate-npm-package-name@4.0.2':
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
@@ -2809,6 +2860,50 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
@@ -2855,6 +2950,9 @@ packages:
supports-color:
optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -3026,6 +3124,9 @@ packages:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
es-toolkit@1.46.1:
resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==}
esast-util-from-estree@2.0.0:
resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==}
@@ -3081,6 +3182,9 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
eventsource-parser@3.0.8:
resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==}
engines: {node: '>=18.0.0'}
@@ -3483,6 +3587,12 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.1.8:
resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -3493,6 +3603,10 @@ packages:
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
ip-address@10.2.0:
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
engines: {node: '>= 12'}
@@ -4421,6 +4535,18 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-redux@9.3.0:
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@@ -4473,6 +4599,14 @@ packages:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
recharts@3.8.1:
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
recma-build-jsx@1.0.0:
resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
@@ -4487,6 +4621,14 @@ packages:
recma-stringify@1.0.0:
resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==}
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
@@ -4528,6 +4670,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
reselect@5.2.0:
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
@@ -5075,6 +5220,9 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite-plus@0.1.22:
resolution: {integrity: sha512-fCCmEKjI+Hv74PdL/MKcrBkdYPHFNcqD5568KxwN0sa4SGxtcbs55i/577LxKs0w5zIjuLRZZ0zQPu9MO+9itg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -6368,6 +6516,18 @@ snapshots:
'@protobufjs/utf8@1.1.1': {}
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1))(react@19.2.6)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.8
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.2.0
optionalDependencies:
react: 19.2.6
react-redux: 9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1)
'@rolldown/binding-android-arm64@1.0.2':
optional: true
@@ -6501,6 +6661,8 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@tabler/icons-react@3.44.0(react@19.2.6)':
dependencies:
'@tabler/icons': 3.44.0
@@ -7006,6 +7168,30 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
@@ -7059,6 +7245,8 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/use-sync-external-store@0.0.6': {}
'@types/validate-npm-package-name@4.0.2': {}
'@ungap/structured-clone@1.3.1': {}
@@ -7472,6 +7660,44 @@ snapshots:
csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
data-uri-to-buffer@4.0.1: {}
data-urls@7.0.0(@noble/hashes@1.8.0):
@@ -7491,6 +7717,8 @@ snapshots:
dependencies:
ms: 2.1.3
decimal.js-light@2.5.1: {}
decimal.js@10.6.0: {}
decode-named-character-reference@1.3.0:
@@ -7622,6 +7850,8 @@ snapshots:
dependencies:
es-errors: 1.3.0
es-toolkit@1.46.1: {}
esast-util-from-estree@2.0.0:
dependencies:
'@types/estree-jsx': 1.0.5
@@ -7712,6 +7942,8 @@ snapshots:
etag@1.8.1: {}
eventemitter3@5.0.4: {}
eventsource-parser@3.0.8: {}
eventsource@3.0.7:
@@ -8199,6 +8431,10 @@ snapshots:
ignore@5.3.2: {}
immer@10.2.0: {}
immer@11.1.8: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -8208,6 +8444,8 @@ snapshots:
inline-style-parser@0.2.7: {}
internmap@2.0.3: {}
ip-address@10.2.0: {}
ipaddr.js@1.9.1: {}
@@ -9388,6 +9626,15 @@ snapshots:
react-is@17.0.2: {}
react-redux@9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.6
use-sync-external-store: 1.6.0(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.15
redux: 5.0.1
react-remove-scroll-bar@2.3.8(@types/react@19.2.15)(react@19.2.6):
dependencies:
react: 19.2.6
@@ -9439,6 +9686,26 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.8.1
recharts@3.8.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react-is@17.0.2)(react@19.2.6)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.46.1
eventemitter3: 5.0.4
immer: 10.2.0
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-is: 17.0.2
react-redux: 9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.6)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
recma-build-jsx@1.0.0:
dependencies:
'@types/estree': 1.0.9
@@ -9468,6 +9735,12 @@ snapshots:
unified: 11.0.5
vfile: 6.0.3
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
regex-recursion@6.0.2:
dependencies:
regex-utilities: 2.3.0
@@ -9546,6 +9819,8 @@ snapshots:
require-from-string@2.0.2: {}
reselect@5.1.1: {}
reselect@5.2.0: {}
resolve-from@4.0.0: {}
@@ -10075,6 +10350,23 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite-plus@0.1.22(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(typescript@6.0.3))(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(typescript@6.0.3):
dependencies:
'@oxc-project/types': 0.129.0
+1
View File
@@ -15,6 +15,7 @@ onlyBuiltDependencies:
- msw
- protobufjs
overrides:
es-toolkit: 1.46.1
vite: "catalog:"
vitest: "catalog:"
peerDependencyRules: