From 697a7be5a9190a25bf28b0624a09659377c8b212 Mon Sep 17 00:00:00 2001 From: Peter Vachon Date: Thu, 21 May 2026 10:31:11 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(apollo-vertex):=20dashboard=20card=20r?= =?UTF-8?q?enderers=20=E2=80=94=20InsightCardBody=20and=20typed=20card=20c?= =?UTF-8?q?ontent=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../dashboard/insight-card-renderers.tsx | 445 ++++++++++++++++++ 1 file changed, 445 insertions(+) create mode 100644 apps/apollo-vertex/templates/dashboard/insight-card-renderers.tsx diff --git a/apps/apollo-vertex/templates/dashboard/insight-card-renderers.tsx b/apps/apollo-vertex/templates/dashboard/insight-card-renderers.tsx new file mode 100644 index 000000000..2fcd9158d --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/insight-card-renderers.tsx @@ -0,0 +1,445 @@ +"use client"; + +import { useRef, useState, useEffect } from "react"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/registry/tooltip/tooltip"; +import type { InsightCardContent } from "./glow-config"; +import { useDashboardData } from "./dashboard-data-context"; +import type { InsightCardData } from "./dashboard-data"; + +type ViewMode = "desktop" | "compact" | "stacked"; + +// --- Truncated text with conditional tooltip --- + +function TruncatedText({ + children, + className, +}: { + children: string | undefined; + className?: string; +}) { + const textRef = useRef(null); + const [isTruncated, setIsTruncated] = useState(false); + + useEffect(() => { + const el = textRef.current; + if (!el) return; + const check = () => setIsTruncated(el.scrollHeight > el.clientHeight); + check(); + const observer = new ResizeObserver(check); + observer.observe(el); + return () => observer.disconnect(); + }, [children]); + + const textEl = ( +

+ {children} +

+ ); + + if (!isTruncated) return textEl; + + return ( + + {textEl} + + {children} + + + ); +} + +// --- Sample data per card type --- + +const sparklinePoints = [4, 7, 5, 9, 6, 8, 12, 10, 14, 11, 15, 13]; +const areaPoints = [3, 5, 4, 8, 6, 9, 7, 11, 10, 14, 12, 16]; + +// --- Renderers --- + +function KpiContent({ + cardData, + viewMode, +}: { + cardData: InsightCardData; + viewMode: ViewMode; +}) { + if (viewMode === "compact") { + return ( + <> +
+
+ {cardData.kpiNumber} +
+ + {cardData.kpiBadge} + +
+ + {cardData.kpiDescription} + + + ); + } + + return ( + <> +
+ {cardData.kpiNumber} +
+
+ + {cardData.kpiBadge} + +

+ {cardData.kpiDescription} +

+
+ + ); +} + +function DonutContent() { + return ( +
+
+ + + + +
+ + 47% + + + funded + +
+
+
+ ); +} + +function HorizontalBarsContent({ + cardData, + viewMode, + isExpanded = false, +}: { + cardData: InsightCardData; + viewMode: ViewMode; + isExpanded?: boolean; +}) { + const bars = cardData.bars ?? []; + const chartColors = [ + "bg-chart-1", + "bg-chart-2", + "bg-chart-3", + "bg-chart-4", + "bg-chart-5", + ]; + const barsWithColor = bars.map((b, i) => ({ + ...b, + color: chartColors[i % chartColors.length], + })); + + if (viewMode === "compact" && !isExpanded) { + const total = barsWithColor.reduce((sum, s) => sum + s.value, 0); + return ( +
+
+ {barsWithColor.map((issue) => ( +
+
+
+ ))} +
+
+ {barsWithColor.map((issue) => { + const pct = Math.round((issue.value / total) * 100); + return ( +
+
+ + {issue.label} {pct}% + +
+ ); + })} +
+
+ ); + } + + return ( +
+ {barsWithColor.map((issue) => ( +
+
+ {issue.label} + {issue.value}% +
+
+
+
+
+
+
+ ))} +
+ ); +} + +function SparklineContent() { + const max = Math.max(...sparklinePoints); + const h = 40; + const w = 120; + const step = w / (sparklinePoints.length - 1); + const points = sparklinePoints + .map((v, i) => `${i * step},${h - (v / max) * h}`) + .join(" "); + + return ( +
+ + + +
+ ); +} + +function AreaContent() { + const max = Math.max(...areaPoints); + const h = 40; + const w = 120; + const step = w / (areaPoints.length - 1); + const linePoints = areaPoints + .map((v, i) => `${i * step},${h - (v / max) * h}`) + .join(" "); + const areaPath = `0,${h} ${linePoints} ${w},${h}`; + + return ( +
+ + + + +
+ ); +} + +function StackedBarContent({ + cardData, + viewMode, + isExpanded = false, +}: { + cardData: InsightCardData; + viewMode: ViewMode; + isExpanded?: boolean; +}) { + const chartColors = [ + "bg-chart-1", + "bg-chart-2", + "bg-chart-3", + "bg-chart-4", + "bg-chart-5", + ]; + const rawBars = cardData.stackedBars ?? []; + const legend = (cardData.stackedLegend ?? []).map((label, i) => ({ + label, + color: chartColors[i % chartColors.length], + })); + const barData = rawBars.map((bar) => ({ + label: bar.label, + segments: bar.segments.map((value, i) => ({ + value, + color: chartColors[i % chartColors.length], + })), + })); + const maxTotal = Math.max( + ...barData.map((d) => d.segments.reduce((sum, s) => sum + s.value, 0)), + ); + + if (viewMode === "compact" && !isExpanded) { + // Summary: aggregate all days into one horizontal stacked bar + const totals = barData.reduce( + (acc, day) => { + for (const seg of day.segments) { + const key = seg.color; + acc[key] = (acc[key] ?? 0) + seg.value; + } + return acc; + }, + {} as Record, + ); + const grandTotal = Object.values(totals).reduce((a, b) => a + b, 0); + + return ( +
+
+ {legend.map((item) => ( +
+
+
+ ))} +
+
+ {legend.map((item) => { + const val = totals[item.color] ?? 0; + const pct = Math.round((val / grandTotal) * 100); + return ( +
+
+ + {item.label} {pct}% + +
+ ); + })} +
+
+ ); + } + + return ( +
+
+ {barData.map((bar) => { + const total = bar.segments.reduce((sum, s) => sum + s.value, 0); + const pct = (total / maxTotal) * 100; + return ( +
+
+
+ {bar.segments.map((seg) => ( +
+ ))} +
+
+ {bar.segments.map((seg) => ( +
+ ))} +
+
+ + {bar.label} + +
+ ); + })} +
+
+ {legend.map((item) => ( +
+
+ + {item.label} + +
+ ))} +
+
+ ); +} + +export function InsightCardBody({ + content, + cardIndex, + viewMode = "desktop", + isExpanded = false, +}: { + content: InsightCardContent; + cardIndex: number; + viewMode?: ViewMode; + isExpanded?: boolean; +}) { + const { data } = useDashboardData(); + const cardData = data.insightCards[cardIndex] ?? data.insightCards[0]; + + if (content.type === "kpi") { + return ; + } + if (content.chartType === "horizontal-bars") + return ( + + ); + if (content.chartType === "donut") return ; + if (content.chartType === "sparkline") return ; + if (content.chartType === "stacked-bar") + return ( + + ); + return ; +} From b775aa4d6d1790629560f157010e4409b8b6d20b Mon Sep 17 00:00:00 2001 From: frankkluijtmans Date: Mon, 15 Jun 2026 17:32:58 +0200 Subject: [PATCH 2/3] fix(apollo-vertex): address Copilot review on dashboard card renderers - Wire donut, sparkline, and area renderers to dataset values (donutPercent/donutLabel/points) with sample fallbacks - Guard percentage math against divide-by-zero and empty data so legends and bar heights never render NaN% - Key stacked-bar totals and segments by category index instead of CSS color, which repeats once there are more than 5 categories - Make the TruncatedText tooltip trigger keyboard-focusable when clamped Co-Authored-By: Claude Opus 4.8 --- .../dashboard/insight-card-renderers.tsx | 100 +++++++++++------- 1 file changed, 64 insertions(+), 36 deletions(-) diff --git a/apps/apollo-vertex/templates/dashboard/insight-card-renderers.tsx b/apps/apollo-vertex/templates/dashboard/insight-card-renderers.tsx index 2fcd9158d..f9679db6d 100644 --- a/apps/apollo-vertex/templates/dashboard/insight-card-renderers.tsx +++ b/apps/apollo-vertex/templates/dashboard/insight-card-renderers.tsx @@ -36,7 +36,13 @@ function TruncatedText({ }, [children]); const textEl = ( -

+

{children}

); @@ -102,7 +108,11 @@ function KpiContent({ ); } -function DonutContent() { +function DonutContent({ cardData }: { cardData: InsightCardData }) { + const circumference = 97.39; + // donutPercent is a 0–1 fraction; fall back to the sample value. + const fraction = cardData.donutPercent ?? 0.47; + const label = cardData.donutLabel ?? "funded"; return (
@@ -122,17 +132,17 @@ function DonutContent() { fill="none" className="stroke-chart-1" strokeWidth="2" - strokeDasharray="97.39" - strokeDashoffset={97.39 * (1 - 0.47)} + strokeDasharray={circumference} + strokeDashoffset={circumference * (1 - fraction)} strokeLinecap="round" />
- 47% + {Math.round(fraction * 100)}% - funded + {label}
@@ -181,7 +191,7 @@ function HorizontalBarsContent({
{barsWithColor.map((issue) => { - const pct = Math.round((issue.value / total) * 100); + const pct = total > 0 ? Math.round((issue.value / total) * 100) : 0; return (
@@ -220,12 +230,16 @@ function HorizontalBarsContent({ ); } -function SparklineContent() { - const max = Math.max(...sparklinePoints); +function SparklineContent({ cardData }: { cardData: InsightCardData }) { + const data = + cardData.points && cardData.points.length > 1 + ? cardData.points + : sparklinePoints; + const max = Math.max(...data) || 1; const h = 40; const w = 120; - const step = w / (sparklinePoints.length - 1); - const points = sparklinePoints + const step = w / (data.length - 1); + const points = data .map((v, i) => `${i * step},${h - (v / max) * h}`) .join(" "); @@ -245,12 +259,16 @@ function SparklineContent() { ); } -function AreaContent() { - const max = Math.max(...areaPoints); +function AreaContent({ cardData }: { cardData: InsightCardData }) { + const data = + cardData.points && cardData.points.length > 1 + ? cardData.points + : areaPoints; + const max = Math.max(...data) || 1; const h = 40; const w = 120; - const step = w / (areaPoints.length - 1); - const linePoints = areaPoints + const step = w / (data.length - 1); + const linePoints = data .map((v, i) => `${i * step},${h - (v / max) * h}`) .join(" "); const areaPath = `0,${h} ${linePoints} ${w},${h}`; @@ -297,35 +315,42 @@ function StackedBarContent({ label: bar.label, segments: bar.segments.map((value, i) => ({ value, + category: i, color: chartColors[i % chartColors.length], })), })); - const maxTotal = Math.max( - ...barData.map((d) => d.segments.reduce((sum, s) => sum + s.value, 0)), - ); + const maxTotal = + barData.length > 0 + ? Math.max( + ...barData.map((d) => + d.segments.reduce((sum, s) => sum + s.value, 0), + ), + ) + : 0; if (viewMode === "compact" && !isExpanded) { - // Summary: aggregate all days into one horizontal stacked bar + // Summary: aggregate all bars into one horizontal stacked bar. Key totals + // by category (segment position), not CSS color — colors repeat via modulo + // once there are more than 5 categories, which would merge distinct ones. const totals = barData.reduce( (acc, day) => { - for (const seg of day.segments) { - const key = seg.color; - acc[key] = (acc[key] ?? 0) + seg.value; - } + day.segments.forEach((seg) => { + acc[seg.category] = (acc[seg.category] ?? 0) + seg.value; + }); return acc; }, - {} as Record, + {} as Record, ); const grandTotal = Object.values(totals).reduce((a, b) => a + b, 0); return (
- {legend.map((item) => ( + {legend.map((item, i) => (
- {legend.map((item) => { - const val = totals[item.color] ?? 0; - const pct = Math.round((val / grandTotal) * 100); + {legend.map((item, i) => { + const val = totals[i] ?? 0; + const pct = + grandTotal > 0 ? Math.round((val / grandTotal) * 100) : 0; return (
@@ -356,7 +382,7 @@ function StackedBarContent({
{barData.map((bar) => { const total = bar.segments.reduce((sum, s) => sum + s.value, 0); - const pct = (total / maxTotal) * 100; + const pct = maxTotal > 0 ? (total / maxTotal) * 100 : 0; return (
{bar.segments.map((seg) => (
@@ -378,7 +404,7 @@ function StackedBarContent({
{bar.segments.map((seg) => (
@@ -431,8 +457,10 @@ export function InsightCardBody({ isExpanded={isExpanded} /> ); - if (content.chartType === "donut") return ; - if (content.chartType === "sparkline") return ; + if (content.chartType === "donut") + return ; + if (content.chartType === "sparkline") + return ; if (content.chartType === "stacked-bar") return ( ); - return ; + return ; } From e9b2d9ba39e18c7dd6a1a860dec1fe2687d438c4 Mon Sep 17 00:00:00 2001 From: frankkluijtmans Date: Tue, 16 Jun 2026 12:02:56 +0200 Subject: [PATCH 3/3] fix(apollo-vertex): address Copilot review on insight card renderers - Clamp donut fraction to [0,1] so out-of-range data can't produce a negative/oversized arc or a wrong label. - Normalize expanded horizontal-bar percentages to value/total so they match the compact view (bar values are raw counts, not percentages). - Return null from InsightCardBody when cardIndex is out of range instead of silently rendering insightCards[0]. - Replace the chart renderer if-chain with an exhaustive switch and a never guard so a new ChartType fails to compile rather than defaulting to Area. Co-Authored-By: Claude Opus 4.8 --- .../dashboard/insight-card-renderers.tsx | 102 +++++++++++------- 1 file changed, 63 insertions(+), 39 deletions(-) diff --git a/apps/apollo-vertex/templates/dashboard/insight-card-renderers.tsx b/apps/apollo-vertex/templates/dashboard/insight-card-renderers.tsx index f9679db6d..fd5b1e6d0 100644 --- a/apps/apollo-vertex/templates/dashboard/insight-card-renderers.tsx +++ b/apps/apollo-vertex/templates/dashboard/insight-card-renderers.tsx @@ -110,8 +110,9 @@ function KpiContent({ function DonutContent({ cardData }: { cardData: InsightCardData }) { const circumference = 97.39; - // donutPercent is a 0–1 fraction; fall back to the sample value. - const fraction = cardData.donutPercent ?? 0.47; + // donutPercent is a 0–1 fraction; fall back to the sample value and clamp so + // out-of-range data can't produce a negative/oversized arc or a wrong label. + const fraction = Math.min(1, Math.max(0, cardData.donutPercent ?? 0.47)); const label = cardData.donutLabel ?? "funded"; return (
@@ -206,26 +207,36 @@ function HorizontalBarsContent({ ); } + // Normalize bar values to a share of the total so the percentages match the + // compact view — bar values are raw counts, not percentages, so rendering + // them as `value%` would disagree with the compact legend for the same data. + const expandedTotal = barsWithColor.reduce((sum, s) => sum + s.value, 0); return (
- {barsWithColor.map((issue) => ( -
-
- {issue.label} - {issue.value}% -
-
-
+ {barsWithColor.map((issue) => { + const pct = + expandedTotal > 0 + ? Math.round((issue.value / expandedTotal) * 100) + : 0; + return ( +
+
+ {issue.label} + {pct}% +
+
+ className={`h-full rounded-full ${issue.color} relative`} + style={{ width: `${pct}%` }} + > +
+
-
- ))} + ); + })}
); } @@ -444,30 +455,43 @@ export function InsightCardBody({ isExpanded?: boolean; }) { const { data } = useDashboardData(); - const cardData = data.insightCards[cardIndex] ?? data.insightCards[0]; + // Bail out rather than fall back to insightCards[0]: rendering a different + // card's data for the given content would be silently misleading. + const cardData = data.insightCards[cardIndex]; + if (!cardData) return null; if (content.type === "kpi") { return ; } - if (content.chartType === "horizontal-bars") - return ( - - ); - if (content.chartType === "donut") - return ; - if (content.chartType === "sparkline") - return ; - if (content.chartType === "stacked-bar") - return ( - - ); - return ; + + switch (content.chartType) { + case "horizontal-bars": + return ( + + ); + case "donut": + return ; + case "sparkline": + return ; + case "stacked-bar": + return ( + + ); + case "area": + return ; + default: { + // Exhaustiveness guard: a new ChartType must add a case above rather than + // silently falling through to a default renderer. + const _exhaustive: never = content.chartType; + return _exhaustive; + } + } }