/* Copyright (C) 2023-2026 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useId, type ReactNode } from 'react' import { type LucideIcon } from 'lucide-react' import { cn } from '@/lib/utils' import { Skeleton } from '@/components/ui/skeleton' type StatCardTone = 'rose' | 'teal' | 'gray' type StatCardSparklineVariant = 'bars' | 'line' type StatCardDetailTone = | 'default' | 'muted' | 'success' | 'warning' | 'destructive' export interface StatCardDetail { label: string value: string tone?: StatCardDetailTone } interface StatCardProps { title: string value: string | number description: string icon: LucideIcon sparkline?: number[] sparklineVariant?: StatCardSparklineVariant details?: StatCardDetail[] tone?: StatCardTone loading?: boolean error?: boolean action?: ReactNode } const TONE_CLASSES: Record = { rose: 'from-rose-500/80 via-rose-300/70 to-rose-200/20 dark:from-rose-400/70 dark:via-rose-500/30 dark:to-rose-500/5', teal: 'from-teal-500/80 via-teal-300/70 to-teal-200/20 dark:from-teal-400/70 dark:via-teal-500/30 dark:to-teal-500/5', gray: 'from-muted-foreground/50 via-muted-foreground/20 to-transparent dark:from-muted-foreground/40 dark:via-muted-foreground/20', } const LINE_TONE_CLASSES: Record = { rose: 'text-warning', teal: 'text-primary', gray: 'text-muted-foreground', } const DETAIL_TONE_CLASSES: Record = { default: 'text-foreground', muted: 'text-muted-foreground', success: 'text-success', warning: 'text-warning', destructive: 'text-destructive', } function normalizeSparkline(values?: number[]): number[] { if (!values?.length) return [] const sanitized = values.map((value) => Math.max(0, Number(value) || 0)) const max = Math.max(...sanitized) if (max <= 0) return sanitized.map(() => 0) return sanitized.map((value) => Math.max(8, (value / max) * 100)) } function buildLineSparkline(values?: number[]) { if (!values?.length) return null const sanitized = values.map((value) => Math.max(0, Number(value) || 0)) const width = 160 const height = 36 const padding = 3 const max = Math.max(...sanitized) const min = Math.min(...sanitized) const range = max - min const points = sanitized.map((value, index) => { const x = sanitized.length === 1 ? width / 2 : (index / (sanitized.length - 1)) * width const normalized = range > 0 ? (value - min) / range : max > 0 ? 0.5 : 0 const y = height - padding - normalized * (height - padding * 2) return { x, y } }) const linePath = points .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`) .join(' ') const firstPoint = points[0] const lastPoint = points[points.length - 1] const areaPath = `${linePath} L ${lastPoint.x} ${height} L ${firstPoint.x} ${height} Z` return { areaPath, linePath, } } function LineSparkline(props: { values?: number[]; tone: StatCardTone }) { const rawGradientId = useId() const gradientId = `stat-card-line-${rawGradientId.replace(/:/g, '')}` const paths = buildLineSparkline(props.values) if (!paths) return