diff --git a/.changeset/fn-6699-command-center-charts.md b/.changeset/fn-6699-command-center-charts.md new file mode 100644 index 0000000000..c4e74ccd7c --- /dev/null +++ b/.changeset/fn-6699-command-center-charts.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix Command Center activity chart rendering so plotted extrema stay visible and chart wrappers keep a measurable default height. diff --git a/packages/dashboard/app/components/MailboxModal.css b/packages/dashboard/app/components/MailboxModal.css index 6afac364ef..61c4cee806 100644 --- a/packages/dashboard/app/components/MailboxModal.css +++ b/packages/dashboard/app/components/MailboxModal.css @@ -789,7 +789,7 @@ .mailbox-modal .mailbox-tab { flex-shrink: 0; padding: var(--space-sm) var(--space-md); - font-size: var(--font-size-xs, 0.8rem); + font-size: 0.8rem; } .mailbox-modal .mailbox-content { @@ -881,7 +881,7 @@ .mailbox-view .mailbox-tab { flex-shrink: 0; padding: var(--space-sm) var(--space-md); - font-size: var(--font-size-xs, 0.8rem); + font-size: 0.8rem; } .mailbox-view .mailbox-content { diff --git a/packages/dashboard/app/components/__tests__/MailboxModal.test.tsx b/packages/dashboard/app/components/__tests__/MailboxModal.test.tsx index 4264f7fa97..5d87ca4ff6 100644 --- a/packages/dashboard/app/components/__tests__/MailboxModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxModal.test.tsx @@ -1180,7 +1180,7 @@ describe("MailboxModal", () => { expect(mailboxMobileSection).toContain("display: none;"); expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-tab"); expect(mailboxMobileSection).toContain("padding: var(--space-sm) var(--space-md);"); - expect(mailboxMobileSection).toContain("font-size: var(--font-size-xs, 0.8rem);"); + expect(mailboxMobileSection).toContain("font-size: 0.8rem;"); expect(mailboxMobileSection).toContain("max-height: calc(100dvh - var(--header-height) - var(--space-2xl) - var(--space-xl));"); expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-message-detail-header"); expect(mailboxMobileSection).toContain("flex-direction: column;"); diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-chart-layout.test.ts b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-chart-layout.test.ts index 477ffc2faf..cdab1961dc 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-chart-layout.test.ts +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-chart-layout.test.ts @@ -65,6 +65,7 @@ describe("CommandCenter.mobile-chart-layout.css", () => { }); it("keeps recharts ResponsiveContainer parents sized without becoming scroll owners", () => { + expect(cssContent).toMatch(/\.cc-recharts-chart,[\s\S]*\.cc-recharts-empty\s*\{[\s\S]*inline-size:\s*100%;[\s\S]*block-size:\s*calc\(var\(--space-2xl\)\s*\*\s*7\s*\+\s*var\(--space-lg\)\);[\s\S]*min-inline-size:\s*0/); expect(cssContent).toMatch(/\.cc-overview-chart-card \.cc-recharts-chart,[\s\S]*\.cc-overview-chart-card \.cc-recharts-empty\s*\{[\s\S]*inline-size:\s*100%;[\s\S]*block-size:\s*calc\(var\(--space-2xl\)\s*\*\s*7\s*\+\s*var\(--space-lg\)\);[\s\S]*min-inline-size:\s*0/); expect(cssContent).toMatch(/\.cc-area \.cc-recharts-chart,[\s\S]*\.cc-area \.cc-recharts-empty\s*\{[\s\S]*inline-size:\s*100%;[\s\S]*block-size:\s*calc\(var\(--space-2xl\)\s*\*\s*7\s*\+\s*var\(--space-lg\)\);[\s\S]*min-inline-size:\s*0/); expect(cssContent).not.toMatch(/\.cc-recharts-(?:chart|empty)[^{]*\{[^}]*overflow-y:\s*(?:auto|scroll)/); diff --git a/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx b/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx index 6b542330be..263b6fa43c 100644 --- a/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx @@ -145,6 +145,22 @@ describe("TokenSeriesChart", () => { }); }); +function numericAttribute(element: Element, name: string): number { + return Number(element.getAttribute(name)); +} + +function expectLineChartPointsInsideViewBox(chart: Element): void { + for (const point of Array.from(chart.querySelectorAll(".cc-line-chart-point"))) { + const cx = numericAttribute(point, "cx"); + const cy = numericAttribute(point, "cy"); + const r = numericAttribute(point, "r"); + expect(cx).toBeGreaterThanOrEqual(r); + expect(cx).toBeLessThanOrEqual(100 - r); + expect(cy).toBeGreaterThanOrEqual(r); + expect(cy).toBeLessThanOrEqual(100 - r); + } +} + describe("LineChart", () => { it("renders a populated finite SVG line with an accessible label", () => { render(); @@ -156,14 +172,16 @@ describe("LineChart", () => { expect(line).toBeTruthy(); expect(points).not.toBe(""); expect(points).not.toMatch(/NaN|Infinity/); + expectLineChartPointsInsideViewBox(chart); }); - it("renders all-zero values as valid baseline geometry without NaN", () => { + it("renders all-zero values as valid baseline geometry without NaN or edge clipping", () => { render(); - const points = screen.getByRole("img", { name: "zero trend" }).querySelector(".cc-line-chart-path")?.getAttribute("points") ?? ""; - expect(points).toBe("0,100 100,100"); + const chart = screen.getByRole("img", { name: "zero trend" }); + const points = chart.querySelector(".cc-line-chart-path")?.getAttribute("points") ?? ""; expect(points).not.toMatch(/NaN|Infinity/); + expectLineChartPointsInsideViewBox(chart); }); it("renders a single-point series as a visible point without a malformed line", () => { @@ -174,6 +192,17 @@ describe("LineChart", () => { const point = chart.querySelector(".cc-line-chart-point"); expect(point?.getAttribute("cx")).toBe("50"); expect(point?.getAttribute("cy")).not.toMatch(/NaN|Infinity/); + expectLineChartPointsInsideViewBox(chart); + }); + + it("keeps max-value endpoints inside the SVG viewBox instead of clipping them", () => { + render(); + + const chart = screen.getByRole("img", { name: "edge trend" }); + const points = chart.querySelector(".cc-line-chart-path")?.getAttribute("points") ?? ""; + expect(points).not.toBe("0,100 100,0"); + expect(points).not.toMatch(/NaN|Infinity/); + expectLineChartPointsInsideViewBox(chart); }); it("renders an empty series as an empty valid SVG without throwing", () => { diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx index cdf8551cf2..8d54a55bee 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx @@ -194,6 +194,27 @@ afterEach(() => { vi.useRealTimers(); }); +function expectRechartsWrapperWithin(testId: string, label: string): void { + const section = screen.getByTestId(testId); + const chart = within(section).getByRole("img", { name: label }); + expect(chart.classList.contains("cc-recharts-chart") || chart.classList.contains("cc-recharts-empty")).toBe(true); + expect(chart.outerHTML).not.toMatch(/NaN|Infinity/); +} + +function expectSvgLinePointsInsideViewBox(testId: string, label: string): void { + const section = screen.getByTestId(testId); + const chart = within(section).getByRole("img", { name: label }); + for (const point of Array.from(chart.querySelectorAll(".cc-line-chart-point"))) { + const cx = Number(point.getAttribute("cx")); + const cy = Number(point.getAttribute("cy")); + const r = Number(point.getAttribute("r")); + expect(cx).toBeGreaterThanOrEqual(r); + expect(cx).toBeLessThanOrEqual(100 - r); + expect(cy).toBeGreaterThanOrEqual(r); + expect(cy).toBeLessThanOrEqual(100 - r); + } +} + describe("useAnalyticsArea", () => { it("polls only when pollMs is provided and clears the interval on unmount", async () => { vi.useFakeTimers(); @@ -284,6 +305,13 @@ describe("ActivityArea", () => { expect(screen.getByTestId("cc-activity-agent-runs-sparkline")).toBeTruthy(); expect(screen.getByRole("img", { name: "Agent runs / day" })).toBeTruthy(); expect(screen.getByTestId("cc-activity-line-throughput")).toBeTruthy(); + expectRechartsWrapperWithin("cc-activity-line", "Activity trend"); + expectRechartsWrapperWithin("cc-activity-pie", "Agent run outcome share"); + expectSvgLinePointsInsideViewBox("cc-activity-line-messages", "Messages / day"); + expectSvgLinePointsInsideViewBox("cc-activity-line-agents", "Active agents / day"); + expectSvgLinePointsInsideViewBox("cc-activity-line-nodes", "Active nodes / day"); + expectSvgLinePointsInsideViewBox("cc-activity-line-throughput", "Throughput / day"); + expect(within(screen.getByTestId("cc-activity-agent-runs-sparkline")).getByRole("img", { name: "Agent runs / day" }).classList).toContain("cc-sparkline"); }); it("renders zero agent-run cards when counts are zero and other activity exists", async () => { @@ -781,6 +809,10 @@ describe("TeamArea", () => { expect(screen.getByTestId("cc-team-tokens-chart")).toBeTruthy(); expect(screen.getByTestId("cc-team-completed-chart")).toBeTruthy(); expect(screen.getByTestId("cc-team-pie").textContent).not.toContain("NaN"); + expectRechartsWrapperWithin("cc-team-pie", "Token share by agent"); + expect(within(screen.getByTestId("cc-team-tokens-chart")).getByRole("list", { name: "Tokens by agent" }).classList).toContain("cc-bar-chart"); + expect(within(screen.getByTestId("cc-team-completed-chart")).getByRole("list", { name: "Tasks done by agent" }).classList).toContain("cc-bar-chart"); + expect(within(screen.getByTestId("cc-team-spread-chart")).getByRole("img", { name: "Team spread" }).classList).toContain("cc-sparkline"); }); it("keeps the team pie safe for single-item and non-finite data", async () => { diff --git a/packages/dashboard/app/components/command-center/charts/LineChart.tsx b/packages/dashboard/app/components/command-center/charts/LineChart.tsx index 2837f26fc9..cb1ba5c908 100644 --- a/packages/dashboard/app/components/command-center/charts/LineChart.tsx +++ b/packages/dashboard/app/components/command-center/charts/LineChart.tsx @@ -17,6 +17,8 @@ export interface LineChartProps { const VIEWBOX_SIZE = 100; const SINGLE_POINT_X = VIEWBOX_SIZE / 2; const POINT_RADIUS = 1.8; +const PLOT_PADDING = 3; +const PLOT_SIZE = VIEWBOX_SIZE - PLOT_PADDING * 2; function safeHeightPercent(value: number, max: number): number { if (!Number.isFinite(value) || value <= 0) { @@ -31,11 +33,11 @@ function safeCoord(value: number): number { } function pointFor(value: number, index: number, count: number, max: number): { x: number; y: number } { - const x = count <= 1 ? SINGLE_POINT_X : (index / (count - 1)) * VIEWBOX_SIZE; + const x = count <= 1 ? SINGLE_POINT_X : PLOT_PADDING + (index / (count - 1)) * PLOT_SIZE; const height = safeHeightPercent(value, max); return { x: safeCoord(x), - y: safeCoord(VIEWBOX_SIZE - height), + y: safeCoord(PLOT_PADDING + PLOT_SIZE * (1 - height / VIEWBOX_SIZE)), }; } @@ -63,6 +65,9 @@ function computedMaxFor(series: LineChartSeries[], max?: number): number { /** * FNXC:CommandCenterCharts 2026-06-18-14:29: * Command Center needed a true, zero/NaN-safe, reduced-motion-aware animated line chart for time-series metrics; reuse the Bar/Sparkline safe-height convention so malformed analytics values never leak NaN or Infinity into SVG geometry. + * + * FNXC:CommandCenterCharts 2026-06-19-05:24: + * Activity line charts were clipping max/min points because the data domain mapped to the full SVG viewBox edge. Reserve plot padding equal to the rendered point/stroke margin so populated, single-point, zero, and max-value series stay inside the viewBox on desktop and mobile. */ export function LineChart({ series, ariaLabel, max }: LineChartProps) { const computedMax = computedMaxFor(series, max); diff --git a/packages/dashboard/app/components/command-center/charts/charts.css b/packages/dashboard/app/components/command-center/charts/charts.css index 3b242512d0..699438c22e 100644 --- a/packages/dashboard/app/components/command-center/charts/charts.css +++ b/packages/dashboard/app/components/command-center/charts/charts.css @@ -298,6 +298,18 @@ Line-chart motion is decorative, token-timed, and disabled for reduced-motion us } } +/* ---- Recharts wrappers ---- */ +/* +FNXC:CommandCenterCharts 2026-06-19-05:24: +ResponsiveContainer measures its direct parent. Keep the shared recharts wrapper non-zero by default so Activity, Team, Overview, and any future Command Center chart surface render populated data instead of a blank zero-height box; scoped area/overview CSS may restate this size but must not remove the measurable block axis. +*/ +.cc-recharts-chart, +.cc-recharts-empty { + inline-size: 100%; + block-size: calc(var(--space-2xl) * 7 + var(--space-lg)); + min-inline-size: 0; +} + /* ---- RadialGauge ---- */ .cc-radial-gauge { min-inline-size: 0; diff --git a/packages/dashboard/app/components/command-center/charts/recharts/LineChart.tsx b/packages/dashboard/app/components/command-center/charts/recharts/LineChart.tsx index e10f84b707..4d15438ce4 100644 --- a/packages/dashboard/app/components/command-center/charts/recharts/LineChart.tsx +++ b/packages/dashboard/app/components/command-center/charts/recharts/LineChart.tsx @@ -9,6 +9,7 @@ import { YAxis, } from "recharts"; import { getCommandCenterChartColor, getCommandCenterChartTheme } from "./theme"; +import "../charts.css"; export interface LineChartSeries { label: string; @@ -80,6 +81,9 @@ function responsiveDimension(value: number | string | undefined): ResponsiveDime /** * FNXC:CommandCenterCharts 2026-06-18-21:52: * User requested real graphical pie + line charts on every Command Center surface using a proper chart library (recharts); this shared line wrapper preserves the existing series shape while coercing zero/NaN/Infinity inputs into safe responsive, token-themed, reduced-motion-aware recharts data. + * + * FNXC:CommandCenterCharts 2026-06-19-05:24: + * Recharts ResponsiveContainer renders blank when its parent has no measurable block-size. Import the shared chart CSS here so every Command Center surface using this wrapper gets the token-sized default parent height even if it does not also render a hand-rolled chart primitive. */ export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No chart data" }: LineChartProps) { const theme = getCommandCenterChartTheme(); diff --git a/packages/dashboard/app/components/command-center/charts/recharts/PieChart.tsx b/packages/dashboard/app/components/command-center/charts/recharts/PieChart.tsx index 17fc4c2cc3..60462ad1ce 100644 --- a/packages/dashboard/app/components/command-center/charts/recharts/PieChart.tsx +++ b/packages/dashboard/app/components/command-center/charts/recharts/PieChart.tsx @@ -7,6 +7,7 @@ import { Tooltip, } from "recharts"; import { getCommandCenterChartColor, getCommandCenterChartTheme } from "./theme"; +import "../charts.css"; export interface PieChartDatum { label: string; @@ -60,6 +61,9 @@ function responsiveDimension(value: number | string | undefined): ResponsiveDime /** * FNXC:CommandCenterCharts 2026-06-18-21:47: * User requested real graphical pie + line charts on every Command Center surface using a proper chart library (recharts); this shared pie wrapper is token-themed, responsive, reduced-motion aware, and filters zero/NaN/negative values before recharts can receive invalid geometry. + * + * FNXC:CommandCenterCharts 2026-06-19-05:24: + * Recharts ResponsiveContainer requires a measurable parent height. Import the shared chart CSS here so pie charts keep the same non-zero token-sized wrapper and empty fallback on Activity, Team, Overview, and other Command Center surfaces. */ export function PieChart({ data, ariaLabel, width, height, emptyLabel = "No chart data" }: PieChartProps) { const theme = getCommandCenterChartTheme();