import { ReactNode } from 'react'; import { classify, formatTarget } from '../../lib/ranges'; import { useCountUp } from '../../lib/motion'; import BandBar from './BandBar'; import Sparkline from './Sparkline'; import './MetricCard.css'; /* Status is icon + word + colour, never colour alone — the light-surface status steps sit below 3:1 by design. */ const TONE_ICON: Record = { good: '✓', warning: '!', serious: '↓', critical: '!', }; interface MetricCardProps { /** Key into RANGES; when absent the card shows no verdict. */ metric?: string; label: string; value: number | null | undefined; unit?: string; decimals?: number; /** Recent history, drawn as an inline sparkline when supplied. */ trend?: Array; /** Extra line under the value. */ detail?: ReactNode; onClick?: () => void; } function MetricCard({ metric, label, value, unit, decimals = 0, trend, detail, onClick, }: MetricCardProps) { const numeric = typeof value === 'number' ? value : null; const animated = useCountUp(numeric); const verdict = metric ? classify(metric, numeric) : null; const display = numeric == null ? '—' : (animated ?? numeric).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: decimals, }); const Tag = onClick ? 'button' : 'div'; return (
{label} {trend && trend.some((v) => v != null) ? ( ) : ( onClick && )}
{display} {unit && numeric != null && {unit}}
{verdict && numeric != null ? ( <>
{verdict.band.label} 参考 {formatTarget(verdict.range)}
) : ( detail &&
{detail}
)}
); } export default MetricCard;