From 58a34e9cf08f22ca59c7cb2011a0cdc4dfcfcaad Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 18 Jun 2026 06:25:08 -0700 Subject: [PATCH] FN-6631: fix Command Center completion gauges Fix Command Center completion analytics and add futuristic activity visualizations. - Compute SDLC completion rate as a triage cohort conversion capped between zero and one. - Add a radial completion gauge plus a live activity strip with compact throughput sparkline styling. - Cover the analytics and chart behavior with targeted tests, docs, and a patch changeset. Files changed: .changeset/fn-6631-command-center-rate-gauge.md | 5 + docs/dashboard-guide.md | 4 +- .../core/src/__tests__/activity-analytics.test.ts | 32 +++++- packages/core/src/activity-analytics.ts | 23 +++- .../components/command-center/CommandCenter.css | 128 ++++++++++++++++++++- .../components/command-center/CommandCenter.tsx | 45 +++++++- .../app/components/command-center/SdlcFunnel.tsx | 17 ++- .../__tests__/CommandCenter.test.tsx | 12 +- .../command-center/__tests__/SdlcFunnel.test.tsx | 2 + .../command-center/__tests__/charts.test.tsx | 26 ++++- .../command-center/charts/RadialGauge.tsx | 50 ++++++++ .../components/command-center/charts/charts.css | 83 +++++++++++++ .../vitest.config.ts | 2 + scripts/lib/test-quarantine.json | 8 +- 14 files changed, 402 insertions(+), 35 deletions(-) Fusion-Task-Id: FN-6631 Fusion-Task-Lineage: 0d09f18a-5f93-4dc8-8786-889f32eb63e8 --- .../fn-6631-command-center-rate-gauge.md | 5 + docs/dashboard-guide.md | 4 +- .../src/__tests__/activity-analytics.test.ts | 32 ++++- packages/core/src/activity-analytics.ts | 23 ++- .../command-center/CommandCenter.css | 132 ++++++++++++++++-- .../command-center/CommandCenter.tsx | 45 +++++- .../components/command-center/SdlcFunnel.tsx | 17 +-- .../__tests__/CommandCenter.test.tsx | 12 +- .../__tests__/SdlcFunnel.test.tsx | 2 + .../command-center/__tests__/charts.test.tsx | 26 +++- .../command-center/charts/RadialGauge.tsx | 50 +++++++ .../command-center/charts/charts.css | 83 +++++++++++ .../vitest.config.ts | 2 + scripts/lib/test-quarantine.json | 8 +- 14 files changed, 404 insertions(+), 37 deletions(-) create mode 100644 .changeset/fn-6631-command-center-rate-gauge.md create mode 100644 packages/dashboard/app/components/command-center/charts/RadialGauge.tsx diff --git a/.changeset/fn-6631-command-center-rate-gauge.md b/.changeset/fn-6631-command-center-rate-gauge.md new file mode 100644 index 0000000000..c7b2b27b8a --- /dev/null +++ b/.changeset/fn-6631-command-center-rate-gauge.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Clamp Command Center SDLC completion analytics to cohort-based conversion rates and add the radial completion gauge plus animated live activity signals. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index f9a6c4d678..ddc641d1a4 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -663,14 +663,14 @@ Navigation: Features: - Global date-range picker in the header scopes the analytics tabs; **Mission Control** remains live rather than historical. -- **Overview** summarizes token usage/cost, autonomy, active nodes, tasks done, model breadth, and open signals, and includes the SDLC throughput funnel for the selected range. +- **Overview** summarizes token usage/cost, autonomy, active nodes, tasks done, model breadth, and open signals, and includes the SDLC throughput funnel for the selected range. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range. - **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage. - **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories. - **Activity** tracks sessions, messages, active nodes, active agents, stickiness, and daily activity sparklines. - **Productivity** separates outcome counters (commits and pull requests) from volume proxies such as modified files, lines changed, and files by language. - **Ecosystem** shows active model breadth and per-model task activity; unavailable plugin-activation metrics render as unavailable rather than zero. - **Signals** shows external signal totals, open/resolved counts, MTTR, and source/severity breakdowns when signal sources are connected. -- **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, and a live SDLC funnel; when idle it reports that live updates resume when work starts. +- **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. Motion-heavy accents respect reduced-motion preferences. Data states: - Overview shows a loading state while core analytics settle, then shows `No usage data yet. Run some agents to populate the Command Center.` only after the selected range has settled with no core usage data. diff --git a/packages/core/src/__tests__/activity-analytics.test.ts b/packages/core/src/__tests__/activity-analytics.test.ts index 4e6ee7203c..f893bb0c83 100644 --- a/packages/core/src/__tests__/activity-analytics.test.ts +++ b/packages/core/src/__tests__/activity-analytics.test.ts @@ -237,8 +237,8 @@ describe("activity-analytics", () => { expect(map.get("icebox")).toBe("other"); }); - it("completion rate = done-in-range / entered-in-range (triage entrants)", () => { - // 4 tasks enter triage; 2 reach done. + it("completion rate is cohort completed triage entrants / entered-in-range", () => { + // 4 tasks enter triage; 2 of those in-range entrants reach done. insertMove(db, "t1", "todo", "triage", "2026-03-02T00:00:00.000Z"); insertMove(db, "t2", "todo", "triage", "2026-03-02T01:00:00.000Z"); insertMove(db, "t3", "todo", "triage", "2026-03-02T02:00:00.000Z"); @@ -252,6 +252,34 @@ describe("activity-analytics", () => { expect(result.completionRate).toBe(0.5); }); + it("keeps completion rate bounded when older triage entrants finish in range", () => { + insertMove(db, "old-1", "todo", "triage", "2026-02-20T00:00:00.000Z"); + insertMove(db, "old-2", "todo", "triage", "2026-02-21T00:00:00.000Z"); + insertMove(db, "new-1", "todo", "triage", "2026-03-02T00:00:00.000Z"); + insertMove(db, "old-1", "in-review", "done", "2026-03-03T00:00:00.000Z"); + insertMove(db, "old-2", "in-review", "done", "2026-03-03T01:00:00.000Z"); + insertMove(db, "new-1", "in-review", "done", "2026-03-03T02:00:00.000Z"); + + const result = aggregateSdlcFunnel(db, RANGE); + expect(result.enteredInRange).toBe(1); + expect(result.doneInRange).toBe(3); + expect(result.completionRate).toBe(1); + expect(result.completionRate).toBeLessThanOrEqual(1); + expect(result.completionRate).not.toBeGreaterThan(1); + }); + + it("reports exactly 100 percent when all in-range triage entrants reach done", () => { + insertMove(db, "t1", "todo", "triage", "2026-03-02T00:00:00.000Z"); + insertMove(db, "t2", "todo", "triage", "2026-03-02T01:00:00.000Z"); + insertMove(db, "t1", "in-review", "done", "2026-03-03T00:00:00.000Z"); + insertMove(db, "t2", "in-review", "done", "2026-03-03T01:00:00.000Z"); + + const result = aggregateSdlcFunnel(db, RANGE); + expect(result.enteredInRange).toBe(2); + expect(result.doneInRange).toBe(2); + expect(result.completionRate).toBe(1); + }); + it("handles the zero-denominator completion rate as null, not NaN", () => { // No triage entrants in range; one done move. insertMove(db, "t1", "in-review", "done", "2026-03-02T00:00:00.000Z"); diff --git a/packages/core/src/activity-analytics.ts b/packages/core/src/activity-analytics.ts index 2046fa77e5..ce8693ed34 100644 --- a/packages/core/src/activity-analytics.ts +++ b/packages/core/src/activity-analytics.ts @@ -351,8 +351,10 @@ export interface SdlcFunnel { /** Distinct tasks that reached `done` in range. */ doneInRange: number; /** - * Completion rate = doneInRange / enteredInRange, as a 0..1 ratio. `null` when - * the denominator is zero (documented zero-denominator case), never NaN/∞. + * Cohort completion rate for tasks that entered triage in range: count of + * those entrants that also reached `done`, divided by `enteredInRange`. + * Bounded to the 0..1 conversion ratio by set intersection; `null` when the + * denominator is zero (documented zero-denominator case), never NaN/∞. */ completionRate: number | null; /** Number of whole UTC days in the range (>= 1), used for throughput. */ @@ -460,11 +462,20 @@ export function aggregateSdlcFunnel( } // Entered-in-range = distinct tasks that entered the FIRST funnel stage - // (triage) in range. completion rate = done / entered. - const enteredInRange = perStage.get("triage")?.size ?? 0; - const doneInRange = perStage.get("done")?.size ?? 0; + // (triage) in range. doneInRange remains every task that reached done in range. + const triageEntrants = perStage.get("triage") ?? new Set(); + const doneEntrants = perStage.get("done") ?? new Set(); + const enteredInRange = triageEntrants.size; + const doneInRange = doneEntrants.size; + /* + FNXC:CommandCenter 2026-06-18-00:00: + Completion rate must be a cohort conversion, not done-in-range divided by triage-in-range. Tasks can finish inside a date range after entering triage before the range (or never entering triage), so intersecting the in-range triage cohort with done tasks keeps the dashboard and OTEL metric trustable at 0..1 or null. + */ + const completedTriageEntrants = Array.from(triageEntrants).filter((taskId) => + doneEntrants.has(taskId), + ).length; const completionRate = - enteredInRange === 0 ? null : doneInRange / enteredInRange; + enteredInRange === 0 ? null : completedTriageEntrants / enteredInRange; const rangeDays = countWholeDays(query.from, query.to); const throughputPerDay = doneInRange / rangeDays; diff --git a/packages/dashboard/app/components/command-center/CommandCenter.css b/packages/dashboard/app/components/command-center/CommandCenter.css index 9b9e9332df..e075569867 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.css +++ b/packages/dashboard/app/components/command-center/CommandCenter.css @@ -113,23 +113,139 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden . color: var(--text-primary, #ddd); } -.cc-live-strip { - display: flex; +.cc-stat-card--gauge { align-items: center; - gap: var(--space-2, 0.5rem); - padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); - border: 1px dashed var(--border-subtle, rgba(127, 127, 127, 0.25)); - border-radius: var(--radius-md, 8px); + text-align: center; +} + +.cc-live-strip { + position: relative; + display: grid; + grid-template-columns: minmax(10rem, 1fr) minmax(16rem, 2fr) minmax(10rem, 1fr); + align-items: center; + gap: var(--space-3, 0.75rem); + padding: var(--space-3, 0.75rem); + border: var(--border-width, 0.0625rem) solid color-mix(in srgb, var(--color-accent) 35%, var(--border-subtle)); + border-radius: var(--radius-md); + background: + linear-gradient(135deg, color-mix(in srgb, var(--color-accent) 14%, transparent), transparent), + var(--surface-1); + box-shadow: 0 0 var(--space-4) color-mix(in srgb, var(--color-accent) 18%, transparent); + overflow: hidden; font-size: var(--font-size-sm, 0.85rem); } +.cc-live-strip::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--color-accent) 24%, transparent), transparent); + opacity: 0.45; + transform: translateX(-100%); + animation: cc-live-signal-sweep calc(var(--duration-slow) * 8) linear infinite; + pointer-events: none; +} + +.cc-live-strip-heading, +.cc-live-strip-metrics, +.cc-live-trend { + position: relative; + z-index: 1; +} + +.cc-live-strip-heading { + display: flex; + align-items: center; + gap: var(--space-2, 0.5rem); +} + .cc-live-strip-label { - color: var(--text-primary, #ddd); + color: var(--text-primary); font-weight: 600; } -.cc-live-strip-placeholder { +.cc-live-strip-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2, 0.5rem); +} + +.cc-live-metric { + display: flex; + flex-direction: column; + gap: var(--space-1, 0.25rem); + min-width: 0; + padding: var(--space-2, 0.5rem); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--surface-2) 70%, transparent); + animation: cc-live-signal-pulse calc(var(--duration-slow) * 6) ease-in-out infinite; +} + +.cc-live-metric-value { + color: var(--text-primary); + font-size: var(--font-size-lg, 1.1rem); + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.cc-live-metric-label, +.cc-live-trend-label { color: var(--text-muted); + font-size: var(--font-size-xs, 0.75rem); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.cc-live-trend { + display: flex; + flex-direction: column; + gap: var(--space-2, 0.5rem); +} + +.cc-live-trend .cc-sparkline { + height: 2.5rem; +} + +.cc-live-trend .cc-sparkline-bar { + box-shadow: 0 0 var(--space-2) color-mix(in srgb, var(--color-accent) 28%, transparent); + animation: cc-live-signal-pulse calc(var(--duration-slow) * 5) ease-in-out infinite; +} + +@keyframes cc-live-signal-sweep { + from { + transform: translateX(-100%); + } + to { + transform: translateX(100%); + } +} + +@keyframes cc-live-signal-pulse { + 0%, + 100% { + opacity: 0.78; + } + 50% { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .cc-live-strip::before, + .cc-live-metric, + .cc-live-trend .cc-sparkline-bar { + animation: none; + } +} + +@media (max-width: 768px) { + .cc-live-strip { + grid-template-columns: 1fr; + } + + .cc-live-strip-metrics { + grid-template-columns: 1fr; + } } /* ---- States ---- */ diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index 5eae6b0bdf..cbb61a89b4 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -12,6 +12,7 @@ import { EcosystemArea } from "./areas/EcosystemArea"; import { SignalsArea } from "./areas/SignalsArea"; import { MissionControlPanel } from "./MissionControlPanel"; import { SdlcFunnel } from "./SdlcFunnel"; +import { Sparkline } from "./charts/Sparkline"; import { useAnalyticsArea } from "./areas/useAnalyticsArea"; import { formatCost, formatCount, isInvalidRange, rangeQuery } from "./areas/areaShared"; import type { SignalsAnalytics } from "./areas/SignalsArea"; @@ -100,13 +101,19 @@ function OverviewTab({ range }: { range: DateRange }) { const tokenTotal = tokens.data?.totals?.totalTokens ?? 0; const toolCalls = tools.data?.toolCalls ?? 0; const activeNodes = activity.data?.activeNodes ?? 0; + const activeAgents = activity.data?.activeAgents ?? 0; const tasksDone = activity.data?.funnel?.doneInRange ?? 0; + const inProgressTasks = activity.data?.funnel?.stages.find((stage) => stage.stage === "in-progress")?.entered ?? 0; const uniqueModels = tokens.data?.groups?.length ?? 0; + const activityTrendValues = + activity.data && activity.data.daily.length > 0 + ? activity.data.daily.map((day) => day.messages + day.activeAgents) + : [activity.data?.sessions ?? 0, activity.data?.messages ?? 0, activeAgents, activeNodes, tasksDone]; const hasActivityData = (activity.data?.sessions ?? 0) > 0 || (activity.data?.messages ?? 0) > 0 || activeNodes > 0 || - (activity.data?.activeAgents ?? 0) > 0 || + activeAgents > 0 || tasksDone > 0; const hasData = tokenTotal > 0 || toolCalls > 0 || hasActivityData; const hasAllCoreData = tokens.data !== null && tools.data !== null && activity.data !== null; @@ -196,11 +203,39 @@ function OverviewTab({ range }: { range: DateRange }) { ))} + {/* + FNXC:CommandCenter 2026-06-18-00:00: + The Overview should feel like a living software factory, so the live strip now surfaces animated pulses for tasks in progress, agents working, open signals, and a compact throughput trend from existing activity analytics without adding a new endpoint. + + FNXC:CommandCenter 2026-06-18-00:00: + Motion must be decorative and disabled for reduced-motion users; keep data-testid anchors and the mobile scroll owner unchanged so the cooler dashboard does not destabilize Command Center navigation. + */}
- {t("commandCenter.overview.liveStrip", "Live activity")} - - {t("commandCenter.overview.liveStripPending", "Live Mission Control loads with active sessions.")} - +
+
+
+ + {formatCount(inProgressTasks)} + {t("commandCenter.overview.tasksInProgress", "tasks in progress")} + + + {formatCount(activeAgents)} + {t("commandCenter.overview.agentsWorking", "agents working")} + + + {signalsLoading ? "—" : signals ? formatCount(signals.open ?? 0) : "—"} + {t("commandCenter.overview.openSignals", "open signals")} + +
+
+ {t("commandCenter.overview.throughputTrend", "throughput trend")} + +
{throughputSection} diff --git a/packages/dashboard/app/components/command-center/SdlcFunnel.tsx b/packages/dashboard/app/components/command-center/SdlcFunnel.tsx index 8c8013113a..6d396e2ba9 100644 --- a/packages/dashboard/app/components/command-center/SdlcFunnel.tsx +++ b/packages/dashboard/app/components/command-center/SdlcFunnel.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import type { ActivityAnalytics } from "@fusion/core"; import type { DateRange } from "./DateRangePicker"; import { Funnel, type FunnelStage } from "./charts/Funnel"; +import { RadialGauge } from "./charts/RadialGauge"; import { AreaShell } from "./areas/AreaShell"; import { useAnalyticsArea } from "./areas/useAnalyticsArea"; import { formatCount } from "./areas/areaShared"; @@ -34,11 +35,6 @@ function useStageLabels(): (stage: string) => string { }; } -function formatRate(rate: number | null): string { - if (rate === null) return "—"; - return `${Math.round(rate * 100)}%`; -} - function formatThroughput(value: number): string { if (!Number.isFinite(value)) return "—"; return value.toFixed(2); @@ -99,11 +95,12 @@ export function SdlcFunnel({ range }: { range: DateRange }) { {t("commandCenter.funnel.throughputTitle", "Throughput")}
-
-
- {t("commandCenter.funnel.completionRate", "Completion rate")} -
-
{formatRate(funnel?.completionRate ?? null)}
+
+ {t("commandCenter.funnel.completionRateHint", "Done ÷ entered (in range)")} diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx index f8b5a41ea7..182f2e5207 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -66,12 +66,13 @@ function toolsFixture(toolCalls = 30) { }; } -function activityFixture(overrides: Partial> = {}) { +function activityFixture(overrides: Partial> = {}) { const sessions = overrides.sessions ?? 4; const messages = overrides.messages ?? 18; const activeNodes = overrides.activeNodes ?? 3; const activeAgents = overrides.activeAgents ?? 2; const doneInRange = overrides.doneInRange ?? 7; + const inProgress = overrides.inProgress ?? 3; return { from: "2026-06-08", to: null, @@ -79,13 +80,14 @@ function activityFixture(overrides: Partial 0 ? [{ day: "2026-06-08", activeNodes, activeAgents, messages }] : [], stickiness: activeAgents > 0 ? 0.5 : 0, mttr: { value: null, unavailable: true }, monitor: { mttr: { value: null, unavailable: true }, incidents: 0, deployments: 0 }, funnel: { stages: [ { stage: "triage", entered: doneInRange, current: 0 }, + { stage: "in-progress", entered: inProgress, current: inProgress }, { stage: "done", entered: doneInRange, current: doneInRange }, ], enteredInRange: doneInRange, @@ -179,6 +181,12 @@ describe("CommandCenter shell", () => { expect(statValue("command-center-stat-models")).toBe("2"); expect(statValue("command-center-stat-signals")).toBe("2"); expect(screen.getByTestId("command-center-live-strip")).toBeTruthy(); + expect(screen.getByTestId("command-center-live-snapshot")).toBeTruthy(); + expect(screen.getByTestId("command-center-live-tasks-in-progress").textContent).toContain("3"); + expect(screen.getByTestId("command-center-live-agents-working").textContent).toContain("2"); + expect(screen.getByTestId("command-center-live-open-signals").textContent).toContain("2"); + expect(screen.getByTestId("command-center-throughput-trend")).toBeTruthy(); + expect(screen.getByRole("img", { name: "Recent activity throughput trend" })).toBeTruthy(); expect(screen.getByTestId("command-center-throughput")).toBeTruthy(); }); diff --git a/packages/dashboard/app/components/command-center/__tests__/SdlcFunnel.test.tsx b/packages/dashboard/app/components/command-center/__tests__/SdlcFunnel.test.tsx index 447261bdb0..5525f5923f 100644 --- a/packages/dashboard/app/components/command-center/__tests__/SdlcFunnel.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/SdlcFunnel.test.tsx @@ -73,6 +73,7 @@ describe("SdlcFunnel", () => { render(); await screen.findByTestId("cc-area-funnel"); + expect(screen.getByRole("img", { name: "Completion rate for in-range triage entrants" })).toBeTruthy(); expect(screen.getByTestId("cc-funnel-completion-rate").textContent).toContain("50%"); expect(screen.getByTestId("cc-funnel-done").textContent).toContain("2"); expect(screen.getByTestId("cc-funnel-entered").textContent).toContain("4"); @@ -101,6 +102,7 @@ describe("SdlcFunnel", () => { render(); await screen.findByTestId("cc-area-funnel"); + expect(screen.getByRole("img", { name: "Completion rate for in-range triage entrants" })).toBeTruthy(); expect(screen.getByTestId("cc-funnel-completion-rate").textContent).toContain("—"); }); 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 e491642112..ec4409de46 100644 --- a/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx @@ -6,6 +6,7 @@ import { Bar } from "../charts/Bar"; import { StackedBar } from "../charts/StackedBar"; import { Sparkline } from "../charts/Sparkline"; import { Funnel } from "../charts/Funnel"; +import { RadialGauge } from "../charts/RadialGauge"; function widthOf(el: HTMLElement): string { return el.style.width; @@ -99,6 +100,29 @@ describe("Sparkline", () => { }); }); +describe("RadialGauge", () => { + it("renders the percentage for a valid ratio with an accessible label", () => { + render(); + expect(screen.getByRole("img", { name: "Completion rate" })).toBeTruthy(); + expect(screen.getByText("73%")).toBeTruthy(); + expect(screen.getByText("Completion")).toBeTruthy(); + }); + + it("renders an em dash for a null ratio", () => { + render(); + expect(screen.getByRole("img", { name: "Completion rate" })).toBeTruthy(); + expect(screen.getByText("—")).toBeTruthy(); + }); + + it("renders safe 0% text for zero and non-finite numeric input", () => { + const { rerender } = render(); + expect(screen.getByText("0%")).toBeTruthy(); + rerender(); + expect(screen.getByText("0%")).toBeTruthy(); + expect(screen.getByText("0%").textContent).not.toContain("NaN"); + }); +}); + describe("Funnel", () => { it("renders stages with conversion from the prior stage", () => { render( @@ -134,7 +158,7 @@ describe("chart CSS animation tokens", () => { const css = readFileSync(cssPath, "utf8"); it("uses a --duration-* token in the loader animation, not --transition-*", () => { - const animationLines = css.split("\n").filter((line) => /animation\s*:/.test(line)); + const animationLines = css.split("\n").filter((line) => /animation\s*:/.test(line) && !/animation\s*:\s*none/.test(line)); expect(animationLines.length).toBeGreaterThan(0); for (const line of animationLines) { expect(line).not.toMatch(/var\(--transition-/); diff --git a/packages/dashboard/app/components/command-center/charts/RadialGauge.tsx b/packages/dashboard/app/components/command-center/charts/RadialGauge.tsx new file mode 100644 index 0000000000..a7617a688d --- /dev/null +++ b/packages/dashboard/app/components/command-center/charts/RadialGauge.tsx @@ -0,0 +1,50 @@ +import type { CSSProperties } from "react"; +import "./charts.css"; + +export interface RadialGaugeProps { + /** Ratio rendered by the gauge. Values outside 0..1 are clamped. */ + value: number | null; + /** Visible label below the gauge value. */ + label: string; + /** Accessible label for the complete gauge. */ + ariaLabel: string; +} + +function safeGaugeRatio(value: number | null): number { + if (value === null || !Number.isFinite(value) || value <= 0) { + return 0; + } + return Math.max(0, Math.min(1, value)); +} + +function formatGaugeValue(value: number | null): string { + if (value === null) { + return "—"; + } + const safeValue = safeGaugeRatio(value); + return `${Math.round(safeValue * 100)}%`; +} + +/** + * CSS conic-gradient radial gauge for Command Center ratios. Null renders as + * unavailable while non-finite numeric inputs collapse to a safe 0% display, + * matching the zero/NaN-safe behavior of the hand-rolled chart primitives. + */ +export function RadialGauge({ value, label, ariaLabel }: RadialGaugeProps) { + const safeValue = safeGaugeRatio(value); + const valueText = formatGaugeValue(value); + const style = { + "--cc-radial-value": `${safeValue * 100}%`, + } as CSSProperties; + + return ( +
+ + {label} +
+ ); +} diff --git a/packages/dashboard/app/components/command-center/charts/charts.css b/packages/dashboard/app/components/command-center/charts/charts.css index 91678dc61a..0e2f551b3c 100644 --- a/packages/dashboard/app/components/command-center/charts/charts.css +++ b/packages/dashboard/app/components/command-center/charts/charts.css @@ -118,6 +118,89 @@ Chart labels and legends must use --text-muted so command-center CSS stays align transition: height var(--transition-normal); } +/* ---- RadialGauge ---- */ +.cc-radial-gauge { + display: grid; + place-items: center; + gap: var(--space-2); + color: var(--text-primary); +} + +.cc-radial-gauge-ring { + position: relative; + display: grid; + place-items: center; + width: clamp(6rem, 32vw, 9rem); + aspect-ratio: 1; + border-radius: 50%; + background: + radial-gradient(circle at center, var(--surface-1) 0 54%, transparent 55%), + conic-gradient(var(--color-accent) var(--cc-radial-value), var(--surface-2) 0); + box-shadow: 0 0 var(--space-4) color-mix(in srgb, var(--color-accent) 35%, transparent); + isolation: isolate; + animation: cc-radial-gauge-pulse calc(var(--duration-slow) * 6) ease-in-out infinite; +} + +.cc-radial-gauge-ring::before { + content: ""; + position: absolute; + inset: var(--space-2); + border-radius: inherit; + border: var(--border-width, 0.0625rem) solid color-mix(in srgb, var(--color-accent) 45%, transparent); + box-shadow: inset 0 0 var(--space-3) color-mix(in srgb, var(--color-accent) 22%, transparent); + animation: cc-radial-gauge-sweep calc(var(--duration-slow) * 8) linear infinite; +} + +.cc-radial-gauge-core { + position: relative; + z-index: 1; + display: grid; + place-items: center; + width: 58%; + aspect-ratio: 1; + background: var(--surface-1); + border-radius: 50%; + box-shadow: inset 0 0 var(--space-3) var(--surface-2); +} + +.cc-radial-gauge-value { + font-size: var(--font-size-xl); + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.cc-radial-gauge-label { + color: var(--text-muted); + font-size: var(--font-size-sm); + text-align: center; +} + +@keyframes cc-radial-gauge-pulse { + 0%, + 100% { + filter: saturate(1); + } + 50% { + filter: saturate(1.35); + } +} + +@keyframes cc-radial-gauge-sweep { + from { + transform: rotate(0turn); + } + to { + transform: rotate(1turn); + } +} + +@media (prefers-reduced-motion: reduce) { + .cc-radial-gauge-ring, + .cc-radial-gauge-ring::before { + animation: none; + } +} + /* ---- Funnel ---- */ .cc-funnel { list-style: none; diff --git a/plugins/fusion-plugin-paperclip-runtime/vitest.config.ts b/plugins/fusion-plugin-paperclip-runtime/vitest.config.ts index caf5c70616..92fb277b11 100644 --- a/plugins/fusion-plugin-paperclip-runtime/vitest.config.ts +++ b/plugins/fusion-plugin-paperclip-runtime/vitest.config.ts @@ -13,6 +13,8 @@ export default defineConfig({ }, test: { include: ["src/**/*.test.ts"], + /* FNXC:PaperclipRuntimeTests 2026-06-18-06:11: The broad FN-6631 gate exposed unrelated Paperclip CLI spawn-mock timeouts; exclude the file under the deletion-ratchet quarantine until the runtime owner replaces the flaky mock seam. */ + exclude: ["src/__tests__/paperclip-client.test.ts"], setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))], globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))], pool: "threads", diff --git a/scripts/lib/test-quarantine.json b/scripts/lib/test-quarantine.json index 39eac9c428..47dc90f3e1 100644 --- a/scripts/lib/test-quarantine.json +++ b/scripts/lib/test-quarantine.json @@ -1,4 +1,10 @@ { "$comment": "Flaky-test quarantine ledger (deletion ratchet — see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date — the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config exclude is the mechanism, and the sweep is policy executed by whoever touches the suite.", - "entries": [] + "entries": [ + { + "file": "plugins/fusion-plugin-paperclip-runtime/src/__tests__/paperclip-client.test.ts", + "reason": "FN-6631 broad `pnpm test` run observed unrelated Paperclip CLI spawn-mock timeouts and unhandled ENOENT errors in mintAgentApiKeyViaCli coverage; targeted Command Center/core checks, lint, typecheck, build, and engine tests passed. Quarantined per deletion-ratchet policy pending Paperclip runtime owner rescue.", + "quarantinedAt": "2026-06-18" + } + ] }