stat-card.tsx194 lines · main
1'use client';
2
3import { animate, useMotionValue, useTransform, motion } from 'motion/react';
4import { useEffect } from 'react';
5
6/**
7 * Command-deck stat card: a big number that counts up on mount (and glides
8 * to new values as live polling updates it), with an optional delta chip
9 * and an optional sparkline.
10 *
11 * HARD honesty rule (house-wide): `value === null` renders "—" plus what
12 * the number is waiting on — never a fabricated 0. The count-up only ever
13 * animates REAL numbers the api returned.
14 */
15
16/** Animated number — springs from its current displayed value to `value`. */
17export function CountUp({
18 value,
19 decimals = 0,
20 prefix = '',
21 suffix = '',
22 className,
23}: {
24 value: number;
25 decimals?: number;
26 prefix?: string;
27 suffix?: string;
28 className?: string;
29}) {
30 const mv = useMotionValue(0);
31 const text = useTransform(mv, (v) =>
32 `${prefix}${v.toLocaleString('en-US', {
33 minimumFractionDigits: decimals,
34 maximumFractionDigits: decimals,
35 })}${suffix}`,
36 );
37
38 useEffect(() => {
39 const controls = animate(mv, value, { duration: 1.1, ease: [0.16, 1, 0.3, 1] });
40 return () => controls.stop();
41 }, [mv, value]);
42
43 return <motion.span className={className}>{text}</motion.span>;
44}
45
46/** Tiny inline sparkline that draws itself in. Renders nothing below 2 points. */
47export function Sparkline({
48 points,
49 className,
50}: {
51 points: readonly number[];
52 className?: string;
53}) {
54 if (points.length < 2) return null;
55 let min = Infinity;
56 let max = -Infinity;
57 for (const p of points) {
58 if (p < min) min = p;
59 if (p > max) max = p;
60 }
61 const range = max - min || 1;
62 const step = 100 / (points.length - 1);
63 const d = points
64 .map((p, i) => {
65 const x = i * step;
66 // 2px vertical padding inside the 28-unit-tall viewBox.
67 const y = 26 - ((p - min) / range) * 24;
68 return `${i === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`;
69 })
70 .join(' ');
71
72 return (
73 <svg
74 viewBox="0 0 100 28"
75 preserveAspectRatio="none"
76 className={className ?? 'h-7 w-full'}
77 aria-hidden
78 >
79 <motion.path
80 d={d}
81 fill="none"
82 stroke="var(--color-primary)"
83 strokeWidth={1.5}
84 strokeLinecap="round"
85 strokeLinejoin="round"
86 vectorEffect="non-scaling-stroke"
87 initial={{ pathLength: 0, opacity: 0.4 }}
88 animate={{ pathLength: 1, opacity: 1 }}
89 transition={{ duration: 1.2, ease: 'easeOut' }}
90 />
91 </svg>
92 );
93}
94
95export interface StatCardProps {
96 label: string;
97 /** null = data not available yet — renders the honest "—". */
98 value: number | null;
99 prefix?: string;
100 suffix?: string;
101 decimals?: number;
102 /** e.g. { value: +4, label: 'last 24h' } — tone follows the sign. */
103 delta?: { value: number; label: string } | null;
104 /** small line under the number when a value IS shown. */
105 hint?: string;
106 /** small line under the "—" explaining what the number waits on. */
107 waitingOn?: string;
108 /** raw series for the sparkline; omit (or < 2 points) to hide it. */
109 sparkline?: readonly number[];
110 tone?: 'default' | 'primary' | 'warning';
111 icon?: React.ReactNode;
112}
113
114const TONE_CLASS: Record<NonNullable<StatCardProps['tone']>, string> = {
115 default: 'text-[var(--color-text)]',
116 primary: 'text-[var(--color-primary)]',
117 warning: 'text-[var(--color-warning)]',
118};
119
120export function StatCard({
121 label,
122 value,
123 prefix = '',
124 suffix = '',
125 decimals = 0,
126 delta,
127 hint,
128 waitingOn,
129 sparkline,
130 tone = 'default',
131 icon,
132}: StatCardProps) {
133 return (
134 <motion.div
135 initial={{ opacity: 0, y: 8 }}
136 animate={{ opacity: 1, y: 0 }}
137 transition={{ duration: 0.4, ease: 'easeOut' }}
138 className="flex h-full flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"
139 >
140 <p className="flex items-center gap-2 font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
141 {icon ? <span className="text-[var(--color-text-muted)]">{icon}</span> : null}
142 {label}
143 </p>
144
145 {value === null ? (
146 <div className="flex flex-col gap-1.5">
147 <p className="font-mono text-4xl tracking-tight text-[var(--color-text-subtle)]">—</p>
148 {waitingOn ? (
149 <p className="font-mono text-[11px] text-[var(--color-text-subtle)]">{waitingOn}</p>
150 ) : null}
151 </div>
152 ) : (
153 <div className="flex flex-col gap-1.5">
154 <div className="flex flex-wrap items-baseline gap-3">
155 <CountUp
156 value={value}
157 decimals={decimals}
158 prefix={prefix}
159 suffix={suffix}
160 className={`font-mono text-4xl tracking-tight ${TONE_CLASS[tone]}`}
161 />
162 {delta ? <DeltaChip delta={delta} /> : null}
163 </div>
164 {hint ? (
165 <p className="font-mono text-[11px] text-[var(--color-text-subtle)]">{hint}</p>
166 ) : null}
167 </div>
168 )}
169
170 {sparkline && sparkline.length >= 2 ? (
171 <div className="mt-auto">
172 <Sparkline points={sparkline} />
173 </div>
174 ) : null}
175 </motion.div>
176 );
177}
178
179function DeltaChip({ delta }: { delta: { value: number; label: string } }) {
180 const positive = delta.value > 0;
181 const flat = delta.value === 0;
182 const toneClass = flat
183 ? 'text-[var(--color-text-subtle)]'
184 : positive
185 ? 'text-[var(--color-primary)]'
186 : 'text-[var(--color-error)]';
187 const sign = positive ? '+' : '';
188 return (
189 <span className={`font-mono text-xs ${toneClass}`}>
190 {sign}
191 {delta.value.toLocaleString('en-US')} {delta.label}
192 </span>
193 );
194}