{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "insight-cards", "type": "registry:component", "title": "Insight Cards", "description": "Paged agent insights with scrub-ready live charts.", "dependencies": [ "liveline" ], "registryDependencies": [ "https://www.beautifului.dev/r/foundation.json" ], "files": [ { "path": "components/primitives/InsightCards.tsx", "content": "\"use client\";\n\nimport { Liveline, type LivelinePoint, type LivelineSeries } from \"liveline\";\nimport { useEffect, useMemo, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * INSIGHT CARDS\n * Embedded mini-visualizations in an \"Insights N ‹ ›\"\n * carousel. Autoplay yields as soon as a person uses it.\n * ───────────────────────────────────────────────────────── */\n\nconst EASE = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\nconst formatPercent = (v: number) => `${v > 0 ? \"+\" : \"\"}${v.toFixed(2)}%`;\nconst formatMoney = (v: number) => `$${Math.round(v).toLocaleString(\"en-US\")}`;\n/* anchor the snapshot to *call* time (inside each card's mount-time memo) —\n * a module-load constant goes stale, and once the points age past the chart\n * window the canvas renders empty */\nfunction makePoints(values: number[], gap = 6): LivelinePoint[] {\n const end = Math.floor(Date.now() / 1000);\n return values.map((value, index) => ({\n time: end - (values.length - 1 - index) * gap,\n value,\n }));\n}\n\n/* Catmull-Rom resample — turn a sparse series into a dense, smoothly curved\n * one so both the line and the hover cursor glide instead of stepping between\n * a handful of points. */\nfunction smooth(values: number[], perSegment = 9): number[] {\n if (values.length < 3) return values.slice();\n const out: number[] = [];\n const n = values.length;\n for (let i = 0; i < n - 1; i += 1) {\n const p0 = values[Math.max(0, i - 1)];\n const p1 = values[i];\n const p2 = values[i + 1];\n const p3 = values[Math.min(n - 1, i + 2)];\n for (let s = 0; s < perSegment; s += 1) {\n const t = s / perSegment;\n const t2 = t * t;\n const t3 = t2 * t;\n out.push(\n 0.5 *\n (2 * p1 +\n (-p0 + p2) * t +\n (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 +\n (-p0 + 3 * p1 - 3 * p2 + p3) * t3),\n );\n }\n }\n out.push(values[n - 1]);\n return out;\n}\n\n/* dense, smoothed points spanning exactly `spanSecs` — keeps the chart window\n * unchanged while multiplying the resolution. */\nfunction smoothPoints(values: number[], spanSecs: number): LivelinePoint[] {\n const dense = smooth(values);\n return makePoints(dense, spanSecs / (dense.length - 1));\n}\n\nfunction useDarkMode() {\n const [dark, setDark] = useState(false);\n\n useEffect(() => {\n const root = document.documentElement;\n const update = () => setDark(root.classList.contains(\"dark\"));\n update();\n const observer = new MutationObserver(update);\n observer.observe(root, { attributes: true, attributeFilter: [\"class\"] });\n return () => observer.disconnect();\n }, []);\n\n return dark;\n}\n\n/* inline @entity mention */\nfunction Entity({ name, tone }: { name: string; tone: string }) {\n return (\n \n \n @{name}\n \n );\n}\n\nfunction Mono({ children, tone }: { children: React.ReactNode; tone: \"red\" | \"green\" }) {\n return (\n \n {children}\n \n );\n}\n\nfunction chartIndexFromPointer(event: React.PointerEvent, pointCount: number) {\n const rect = event.currentTarget.getBoundingClientRect();\n const progress = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));\n return Math.round(progress * (pointCount - 1));\n}\n\nfunction ChartTooltip({ rows }: { rows: { label: string; value: string; color: string }[] }) {\n return (\n
\n {rows.map((row) => (\n \n \n {row.value}\n \n ))}\n
\n );\n}\n\n/* content shape for the return-comparison card's two plotted series */\nexport type CompareSeries = {\n name: string;\n values: number[];\n sub: string;\n tone: \"red\" | \"green\";\n dot: string;\n color: string;\n tooltipColor: string;\n};\n\nconst COMPARE_SERIES: CompareSeries[] = [\n {\n name: \"Mint Chip\",\n values: [-2.9, -3.4, -3.05, -3.86, -3.52, -4.1, -3.82, -4.41],\n sub: \"-$2,377.66\",\n tone: \"red\",\n dot: \"bg-orange\",\n color: \"#f68f3c\",\n tooltipColor: \"var(--orange)\",\n },\n {\n name: \"Pistachio\",\n values: [0.22, 0.58, 0.42, 0.91, 0.76, 1.08, 0.96, 1.15],\n sub: \"+$617.22\",\n tone: \"green\",\n dot: \"bg-accent\",\n color: \"#3d9aff\",\n tooltipColor: \"var(--accent)\",\n },\n];\n\n/* 1 — return comparison: 2 series, legend + big deltas + line chart */\nfunction CompareCard({ series = COMPARE_SERIES }: { series?: CompareSeries[] }) {\n const dark = useDarkMode();\n const [hoverIndex, setHoverIndex] = useState(null);\n const points = useMemo(\n () => series.map((s) => smoothPoints(s.values, 42)),\n [series],\n );\n const pointCount = points[0]?.length ?? 0;\n\n const chartSeries: LivelineSeries[] = useMemo(\n () =>\n series.map((s, i) => ({\n id: s.name,\n label: \"\",\n data: points[i],\n value: points[i].at(-1)?.value ?? (s.values.at(-1) ?? 0),\n color: s.color,\n })),\n [series, points],\n );\n\n return (\n
\n
\n {series.map((s, i) => (\n
\n \n \n {s.name}\n \n \n {formatPercent(points[i].at(-1)?.value ?? (s.values.at(-1) ?? 0))}\n \n {s.sub}\n
\n ))}\n
\n
\n
\n \n Trend snapshot\n \n \n Snapshot\n \n
\n setHoverIndex(chartIndexFromPointer(event, pointCount))}\n onPointerMove={(event) => setHoverIndex(chartIndexFromPointer(event, pointCount))}\n onPointerLeave={() => setHoverIndex(null)}\n onPointerCancel={() => setHoverIndex(null)}\n onPointerUp={() => setHoverIndex(null)}\n >\n \n {hoverIndex !== null && <>\n \n \n ({ label: s.name, value: formatPercent(points[i][hoverIndex].value), color: s.tooltipColor }))} />\n \n }\n
\n
\n \n );\n}\n\n/* content shape for the anomaly card's two toggled metric series */\nexport type AnomalyData = {\n spend: number[];\n usage: number[];\n};\n\nconst ANOMALY_DATA: AnomalyData = {\n spend: [274, 289, 264, 307, 331, 1210, 1718, 2112],\n usage: [18, 19, 17, 21, 22, 58, 81, 96],\n};\n\n/* 2 — anomaly: bars with threshold + big spent value */\nfunction AnomalyCard({ data: anomaly = ANOMALY_DATA }: { data?: AnomalyData }) {\n const dark = useDarkMode();\n const [metric, setMetric] = useState<\"spend\" | \"usage\">(\"spend\");\n const [hoverIndex, setHoverIndex] = useState(null);\n const spend = useMemo(\n () => makePoints(anomaly.spend, 7),\n [anomaly],\n );\n const usage = useMemo(\n () => makePoints(anomaly.usage, 7),\n [anomaly],\n );\n\n const data = metric === \"spend\" ? spend : usage;\n const value = data.at(-1)?.value ?? (metric === \"spend\" ? 2112 : 96);\n const threshold = metric === \"spend\" ? \"$2,112\" : \"82 kWh\";\n const moneyLabel = formatMoney(spend.at(-1)?.value ?? 2112);\n\n return (\n
\n
\n \n \n High freezer spend\n \n \n Snapshot\n \n
\n
\n
\n \n {hoverIndex !== null\n ? metric === \"spend\"\n ? formatMoney(data[hoverIndex].value)\n : `${Math.round(data[hoverIndex].value)} kWh`\n : `${threshold} threshold`}\n \n \n {([\"spend\", \"usage\"] as const).map((item) => (\n setMetric(item)}\n className={`rounded-full px-2 py-0.5 text-[10.5px] font-medium transition-[background-color,color,box-shadow,transform] duration-150 active:scale-[0.96] ${\n metric === item ? \"bg-surface text-ink shadow-btn\" : \"text-ink-3 hover:text-ink-2\"\n }`}\n >\n {item === \"spend\" ? \"Spend\" : \"Usage\"}\n \n ))}\n \n
\n setHoverIndex(chartIndexFromPointer(event, data.length))}\n onPointerMove={(event) => setHoverIndex(chartIndexFromPointer(event, data.length))}\n onPointerLeave={() => setHoverIndex(null)}\n onPointerCancel={() => setHoverIndex(null)}\n onPointerUp={() => setHoverIndex(null)}\n >\n (metric === \"spend\" ? formatMoney(v) : `${Math.round(v)} kWh`)}\n />\n {hoverIndex !== null && <>\n \n \n \n \n }\n
\n
\n
\n \n {moneyLabel} spent\n \n +$1,834.66\n vs 3 months\n
\n \n );\n}\n\n/* content shape for one allocation segment */\nexport type AllocationSegment = {\n name: string;\n label: string;\n pct: number;\n amount: string;\n cls: string;\n tone: string;\n};\n\nconst ALLOCATION_SEGMENTS: AllocationSegment[] = [\n { name: \"VAN\", label: \"Vanilla\", pct: 72.5, amount: \"$51,785\", cls: \"bg-orange\", tone: \"text-orange\" },\n { name: \"CHOC\", label: \"Chocolate\", pct: 22.8, amount: \"$16,278\", cls: \"bg-line-strong\", tone: \"text-ink-2\" },\n { name: \"MINT\", label: \"Mint\", pct: 4.7, amount: \"$3,357\", cls: \"bg-line\", tone: \"text-ink-3\" },\n];\n\n/* 3 — allocation: hero number + segmented bar + legend */\nfunction AllocationCard({ segments = ALLOCATION_SEGMENTS }: { segments?: AllocationSegment[] }) {\n const [selected, setSelected] = useState(segments[0].name);\n const active = segments.find((segment) => segment.name === selected) ?? segments[0];\n\n return (\n
\n \n \n V\n \n Vanilla allocation\n \n \n {active.amount}\n \n \n {segments.map((s) => (\n setSelected(s.name)}\n className={`relative h-full overflow-hidden rounded-full ${s.cls} transition-[opacity,transform,box-shadow] duration-300 active:scale-[0.98]`}\n style={{\n width: `${s.pct}%`,\n opacity: selected === s.name ? 1 : 0.58,\n boxShadow: selected === s.name ? \"inset 0 0 0 1px rgba(255,255,255,0.22)\" : undefined,\n transitionTimingFunction: EASE,\n }}\n >\n \n \n ))}\n
\n
\n {segments.map((s) => (\n setSelected(s.name)}\n className={`flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] transition-[background-color,color,transform] duration-150 active:scale-[0.96] ${\n selected === s.name ? \"bg-field text-ink\" : \"text-ink-2 hover:bg-hover hover:text-ink\"\n }`}\n >\n \n {s.name} {s.pct}%\n \n ))}\n
\n
\n {active.label}\n \n Contribution snapshot across current inventory value. Segment selection changes the inspected group without moving the card.\n \n
\n \n );\n}\n\n/* content shape for one insight page in the carousel */\nexport type InsightPage = {\n key: string;\n prose: React.ReactNode;\n Card: React.ComponentType;\n pill: string;\n};\n\nconst PAGES: InsightPage[] = [\n {\n key: \"compare\",\n prose: (\n <>\n The worst performer in your is\n Rocky Road — down -6% or -$2,453.44.\n \n ),\n Card: CompareCard,\n pill: \"Should I rebalance flavors?\",\n },\n {\n key: \"anomaly\",\n prose: (\n <>\n Unusually high freezer bill on Dec 13 —{\" \"}\n +$1,834.66 above your average.\n \n ),\n Card: AnomalyCard,\n pill: \"Get tips on cutting freezer costs\",\n },\n {\n key: \"allocation\",\n prose: (\n <>\n You’re heavily invested in — it’s{\" \"}\n 72.5% of your case.\n \n ),\n Card: AllocationCard,\n pill: \"If we look at seasonals, what changes?\",\n },\n];\n\nexport type InsightCardsLabels = {\n /** carousel heading shown before the page count */\n title: string;\n};\n\nconst DEFAULT_INSIGHT_LABELS: InsightCardsLabels = {\n title: \"Insights\",\n};\n\nexport default function InsightCards({\n pages = PAGES,\n labels,\n}: {\n variant?: string;\n pages?: InsightPage[];\n labels?: Partial;\n} = {}) {\n const l = { ...DEFAULT_INSIGHT_LABELS, ...labels };\n const [page, setPage] = useState(0);\n\n const move = (direction: -1 | 1) => {\n setPage((current) => (current + direction + pages.length) % pages.length);\n };\n\n const { prose, Card, pill } = pages[page];\n\n return (\n
\n {/* pager header */}\n
\n \n {l.title}\n {pages.length}\n \n \n {([\"M15 18l-6-6 6-6\", \"M9 6l6 6-6 6\"] as const).map((d, i) => (\n move(i === 0 ? -1 : 1)}\n className=\"flex size-6 items-center justify-center rounded-[6px] text-ink-3\n transition-[background-color,color,transform] duration-100 hover:bg-hover\n hover:text-ink active:scale-[0.96]\"\n >\n \n \n \n \n ))}\n \n
\n\n {/* page content — blurred crossfade */}\n \n

{prose}

\n
\n \n
\n \n {pill}\n \n
\n \n );\n}\n", "type": "registry:component", "target": "components/primitives/InsightCards.tsx" } ] }