"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowDownRight, ArrowUpRight, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
export type KpiPeriod = {
id: string;
/** Short selector label, e.g. "7D". */
label: string;
/** Longer comparison text, e.g. "vs previous 7 days". */
comparison?: string;
value: number;
previous: number;
series: number[];
/** Point labels for the sparkline tooltip (same length as series). */
pointLabels?: string[];
};
export type SparklineKpiProps = {
title: string;
periods: KpiPeriod[];
defaultPeriod?: string;
onPeriodChange?: (id: string) => void;
valueFormatter?: (v: number) => string;
/** Lower is better (e.g. churn, latency): a decrease is shown as positive. */
invert?: boolean;
/** Line colour; defaults to the theme primary. */
color?: string;
icon?: React.ReactNode;
className?: string;
};
const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 });
function useElementWidth<T extends HTMLElement>() {
const ref = React.useRef<T>(null);
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => setWidth(Math.floor(entries[0].contentRect.width)));
ro.observe(el);
return () => ro.disconnect();
}, []);
return [ref, width] as const;
}
/** Eased count toward `target` whenever it changes. */
function useCountTo(target: number, ms: number, skip: boolean) {
const [v, setV] = React.useState(skip ? target : 0);
const cur = React.useRef(skip ? target : 0);
React.useEffect(() => {
if (skip) {
cur.current = target;
return;
}
const from = cur.current;
let raf = 0;
const t0 = performance.now();
const tick = (t: number) => {
const k = Math.min(1, (t - t0) / ms);
const next = from + (target - from) * (1 - Math.pow(1 - k, 4));
cur.current = next;
setV(next);
if (k < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [target, ms, skip]);
return skip ? target : v;
}
function smooth(pts: [number, number][]) {
if (pts.length < 2) return "";
let d = `M${pts[0][0]},${pts[0][1]}`;
for (let i = 1; i < pts.length; i++) {
const [x0, y0] = pts[i - 1];
const [x1, y1] = pts[i];
const cx = (x0 + x1) / 2;
d += `C${cx},${y0},${cx},${y1},${x1},${y1}`;
}
return d;
}
const H = 64;
export function SparklineKpi({
title,
periods,
defaultPeriod,
onPeriodChange,
valueFormatter = (v) => compact.format(v),
invert = false,
color = "var(--primary)",
icon,
className,
}: SparklineKpiProps) {
const reduce = !!useReducedMotion();
const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
const [periodId, setPeriodId] = React.useState(defaultPeriod ?? periods[0]?.id);
const period = periods.find((p) => p.id === periodId) ?? periods[0];
const [ref, width] = useElementWidth<HTMLDivElement>();
const [active, setActive] = React.useState<number | null>(null);
const shown = useCountTo(period.value, 700, reduce);
const change = period.previous === 0 ? 0 : (period.value - period.previous) / Math.abs(period.previous);
const good = change === 0 ? null : invert ? change < 0 : change > 0;
const DeltaIcon = change === 0 ? Minus : change > 0 ? ArrowUpRight : ArrowDownRight;
const s = period.series;
const min = Math.min(...s);
const max = Math.max(...s);
const x = (i: number) => (s.length <= 1 ? width / 2 : (i / (s.length - 1)) * (width - 8) + 4);
const y = (v: number) => 6 + (1 - (v - min) / (max - min || 1)) * (H - 12);
const pts = s.map((v, i) => [x(i), y(v)] as [number, number]);
const line = smooth(pts);
const area = pts.length ? `${line}L${pts[pts.length - 1][0]},${H}L${pts[0][0]},${H}Z` : "";
const select = (id: string) => {
setPeriodId(id);
setActive(null);
onPeriodChange?.(id);
};
const onKeyDown = (e: React.KeyboardEvent) => {
const cur = active ?? s.length - 1;
let next: number | null = cur;
if (e.key === "ArrowRight") next = Math.min(s.length - 1, cur + 1);
else if (e.key === "ArrowLeft") next = Math.max(0, cur - 1);
else if (e.key === "Escape") next = null;
else return;
e.preventDefault();
setActive(next);
};
const pointLabel = (i: number) => period.pointLabels?.[i] ?? `Point ${i + 1}`;
return (
<section aria-label={title} className={cn("w-full rounded-xl border bg-card p-4 text-card-foreground shadow-xs sm:p-5", className)}>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
{icon && <span className="grid size-7 shrink-0 place-items-center rounded-md border bg-muted/50 text-muted-foreground [&>svg]:size-3.5">{icon}</span>}
<h3 className="truncate text-sm font-medium text-muted-foreground">{title}</h3>
</div>
{periods.length > 1 && (
<div role="radiogroup" aria-label="Period" className="flex shrink-0 rounded-md bg-muted p-0.5 text-[11px] font-medium">
{periods.map((p) => (
<button
key={p.id}
type="button"
role="radio"
aria-checked={p.id === period.id}
onClick={() => select(p.id)}
className="relative rounded px-1.5 py-0.5 outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{p.id === period.id && (
<motion.span
layoutId={`${uid}-pill`}
className="absolute inset-0 rounded bg-background shadow-xs"
transition={{ type: "spring", stiffness: 500, damping: 38 }}
/>
)}
<span className={cn("relative", p.id === period.id ? "text-foreground" : "text-muted-foreground")}>{p.label}</span>
</button>
))}
</div>
)}
</div>
<div className="mt-3 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<p className="text-3xl font-semibold tracking-tight tabular-nums">
{valueFormatter(shown)}
</p>
<span
className={cn(
"inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums",
good === null && "bg-muted text-muted-foreground",
good === true && "bg-emerald-500/12 text-emerald-700 dark:text-emerald-400",
good === false && "bg-rose-500/12 text-rose-700 dark:text-rose-400",
)}
>
<DeltaIcon className="size-3" aria-hidden />
<span className="sr-only">{change > 0 ? "Up" : change < 0 ? "Down" : "Unchanged"}</span>
{Math.abs(change * 100).toFixed(1)}%
</span>
</div>
<p className="mt-0.5 h-4 truncate text-xs text-muted-foreground" aria-hidden={active !== null}>
{active === null ? (
<>
{period.comparison ?? "vs previous period"} · {valueFormatter(period.previous)}
</>
) : (
<>
{pointLabel(active)} · <span className="font-medium text-foreground tabular-nums">{valueFormatter(s[active])}</span>
</>
)}
</p>
<div
ref={ref}
tabIndex={0}
role="group"
aria-label={`${title} trend. Use arrow keys to inspect.`}
onKeyDown={onKeyDown}
onBlur={() => setActive(null)}
className="relative mt-4 w-full rounded outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
style={{ height: H }}
>
{width > 0 && (
<svg
width={width}
height={H}
aria-hidden
className="block overflow-visible"
onPointerMove={(e) => {
const r = e.currentTarget.getBoundingClientRect();
const i = Math.round(((e.clientX - r.left - 4) / Math.max(1, width - 8)) * (s.length - 1));
setActive(Math.max(0, Math.min(s.length - 1, i)));
}}
onPointerLeave={() => setActive(null)}
>
<defs>
<linearGradient id={`${uid}-fill`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" style={{ stopColor: color, stopOpacity: 0.24 }} />
<stop offset="100%" style={{ stopColor: color, stopOpacity: 0 }} />
</linearGradient>
</defs>
<motion.path
key={`a-${period.id}-${width}`}
d={area}
fill={`url(#${uid}-fill)`}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, delay: 0.35 }}
/>
<motion.path
key={`l-${period.id}-${width}`}
d={line}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1] }}
/>
{active !== null && (
<g pointerEvents="none">
<line x1={x(active)} x2={x(active)} y1={0} y2={H} className="stroke-muted-foreground/40" strokeDasharray="2 3" />
<circle cx={x(active)} cy={y(s[active])} r={4} fill={color} className="stroke-card" strokeWidth={2} />
</g>
)}
{active === null && pts.length > 0 && (
<circle cx={pts[pts.length - 1][0]} cy={pts[pts.length - 1][1]} r={3} fill={color} className="stroke-card" strokeWidth={2} />
)}
</svg>
)}
<p className="sr-only" aria-live="polite">
{active !== null ? `${pointLabel(active)}: ${valueFormatter(s[active])}` : ""}
</p>
</div>
</section>
);
}