import { ArrowDownRightIcon, ArrowUpRightIcon, MinusIcon } from "lucide-react"; import { useId, type ReactNode } from "react"; import { cn } from "../lib/cn"; /* ---------------------------------------------------------------------------- StatTile (design brief 7, "Also build") - one number, one style for Insights, Billing and Machines. Label (12px muted), a tabular value (20/28 semibold), an optional delta against the previous period and an optional sparkline. Tiles sit flat on the page inside one StatGroup with hairline dividers, never a card each. -------------------------------------------------------------------------- */ export type StatTrend = "up" | "down" | "flat"; /** Whether the change is good news. Money and volume are usually "neutral". */ export type StatSentiment = "positive" | "negative" | "neutral"; export interface StatDelta { /** Display text, "+12%" or "-2". */ value: string; trend: StatTrend; sentiment?: StatSentiment | undefined; /** "vs previous 7 days". */ comparison?: string | undefined; } const SENTIMENT_TEXT: Record = { positive: "text-status-idle", negative: "text-danger", neutral: "text-fg-muted", }; const TREND_ICON = { up: ArrowUpRightIcon, down: ArrowDownRightIcon, flat: MinusIcon, } as const; const TREND_WORD: Record = { up: "Up", down: "Down", flat: "No change", }; export interface StatTileProps { label: string; /** Preformatted: "1,284", "$312.40", "48.2M". Rendered with tabular numbers. */ value?: ReactNode | undefined; /** A small unit after the value: "GB", "sessions". */ unit?: ReactNode | undefined; delta?: StatDelta | undefined; /** One muted line under the value (and under the delta when both): "Of 16 GB". */ caption?: ReactNode | undefined; /** Values oldest to newest; drawn as a line under the number. */ sparkline?: number[] | undefined; loading?: boolean | undefined; /** Shown instead of the value when there's no data: "No sessions yet". */ empty?: ReactNode | undefined; /** Draw its own border. Off inside a StatGroup. */ framed?: boolean | undefined; className?: string | undefined; } /** Normalizes values into SVG points in a 100 x 32 box. Exported for tests. */ export function sparklinePoints(values: number[], width = 100, height = 32, inset = 2): string { if (values.length === 0) return ""; const min = Math.min(...values); const max = Math.max(...values); const span = max - min || 1; const step = values.length > 1 ? width / (values.length - 1) : 0; return values .map((value, index) => { const x = values.length > 1 ? index * step : width / 2; const y = max === min ? height / 2 : inset + (1 - (value - min) / span) * (height - inset * 2); return `${round(x)},${round(y)}`; }) .join(" "); } function round(value: number): number { return Math.round(value * 100) / 100; } function Sparkline({ values }: { values: number[] }) { const gradientId = useId(); const points = sparklinePoints(values); if (!points) return null; const area = `0,32 ${points} 100,32`; return (
); } export function StatTile({ label, value, unit, delta, caption, sparkline, loading = false, empty, framed = false, className, }: StatTileProps) { const isEmpty = !loading && (value === undefined || value === null) && empty; const TrendIcon = delta ? TREND_ICON[delta.trend] : null; return (

{label}

{loading ? ( <> Loading {label}
); } /** * Tiles side by side with hairline dividers, in one bordered frame. Columns * follow the frame's own width: one under 320px, two under 768px, then all. */ export function StatGroup({ children, columns = 4, label, className, }: { children: ReactNode; /** Columns at full width. */ columns?: 2 | 3 | 4 | undefined; /** Accessible name for the group, for example "Last 7 days". */ label?: string | undefined; className?: string | undefined; }) { return (
*]:-mt-px [&>*]:-ml-px [&>*]:border-t [&>*]:border-l [&>*]:border-border", "@xs:grid-cols-2", columns === 3 && "@3xl:grid-cols-3", columns === 4 && "@3xl:grid-cols-4", )} > {children}
); }