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
This commit is contained in:
gsxdsm
2026-06-18 06:25:08 -07:00
parent 673a8a64c3
commit 58a34e9cf0
14 changed files with 404 additions and 37 deletions

View File

@@ -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.

View File

@@ -663,14 +663,14 @@ Navigation:
Features: Features:
- Global date-range picker in the header scopes the analytics tabs; **Mission Control** remains live rather than historical. - 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. - **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. - **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. - **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. - **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. - **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. - **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: 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. - 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.

View File

@@ -237,8 +237,8 @@ describe("activity-analytics", () => {
expect(map.get("icebox")).toBe("other"); expect(map.get("icebox")).toBe("other");
}); });
it("completion rate = done-in-range / entered-in-range (triage entrants)", () => { it("completion rate is cohort completed triage entrants / entered-in-range", () => {
// 4 tasks enter triage; 2 reach done. // 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, "t1", "todo", "triage", "2026-03-02T00:00:00.000Z");
insertMove(db, "t2", "todo", "triage", "2026-03-02T01: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"); insertMove(db, "t3", "todo", "triage", "2026-03-02T02:00:00.000Z");
@@ -252,6 +252,34 @@ describe("activity-analytics", () => {
expect(result.completionRate).toBe(0.5); 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", () => { it("handles the zero-denominator completion rate as null, not NaN", () => {
// No triage entrants in range; one done move. // No triage entrants in range; one done move.
insertMove(db, "t1", "in-review", "done", "2026-03-02T00:00:00.000Z"); insertMove(db, "t1", "in-review", "done", "2026-03-02T00:00:00.000Z");

View File

@@ -351,8 +351,10 @@ export interface SdlcFunnel {
/** Distinct tasks that reached `done` in range. */ /** Distinct tasks that reached `done` in range. */
doneInRange: number; doneInRange: number;
/** /**
* Completion rate = doneInRange / enteredInRange, as a 0..1 ratio. `null` when * Cohort completion rate for tasks that entered triage in range: count of
* the denominator is zero (documented zero-denominator case), never NaN/∞. * 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; completionRate: number | null;
/** Number of whole UTC days in the range (>= 1), used for throughput. */ /** 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 // Entered-in-range = distinct tasks that entered the FIRST funnel stage
// (triage) in range. completion rate = done / entered. // (triage) in range. doneInRange remains every task that reached done in range.
const enteredInRange = perStage.get("triage")?.size ?? 0; const triageEntrants = perStage.get("triage") ?? new Set<string>();
const doneInRange = perStage.get("done")?.size ?? 0; const doneEntrants = perStage.get("done") ?? new Set<string>();
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 = const completionRate =
enteredInRange === 0 ? null : doneInRange / enteredInRange; enteredInRange === 0 ? null : completedTriageEntrants / enteredInRange;
const rangeDays = countWholeDays(query.from, query.to); const rangeDays = countWholeDays(query.from, query.to);
const throughputPerDay = doneInRange / rangeDays; const throughputPerDay = doneInRange / rangeDays;

View File

@@ -113,23 +113,139 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden .
color: var(--text-primary, #ddd); color: var(--text-primary, #ddd);
} }
.cc-live-strip { .cc-stat-card--gauge {
display: flex;
align-items: center; align-items: center;
gap: var(--space-2, 0.5rem); text-align: center;
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); .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); 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 { .cc-live-strip-label {
color: var(--text-primary, #ddd); color: var(--text-primary);
font-weight: 600; 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); 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 ---- */ /* ---- States ---- */

View File

@@ -12,6 +12,7 @@ import { EcosystemArea } from "./areas/EcosystemArea";
import { SignalsArea } from "./areas/SignalsArea"; import { SignalsArea } from "./areas/SignalsArea";
import { MissionControlPanel } from "./MissionControlPanel"; import { MissionControlPanel } from "./MissionControlPanel";
import { SdlcFunnel } from "./SdlcFunnel"; import { SdlcFunnel } from "./SdlcFunnel";
import { Sparkline } from "./charts/Sparkline";
import { useAnalyticsArea } from "./areas/useAnalyticsArea"; import { useAnalyticsArea } from "./areas/useAnalyticsArea";
import { formatCost, formatCount, isInvalidRange, rangeQuery } from "./areas/areaShared"; import { formatCost, formatCount, isInvalidRange, rangeQuery } from "./areas/areaShared";
import type { SignalsAnalytics } from "./areas/SignalsArea"; import type { SignalsAnalytics } from "./areas/SignalsArea";
@@ -100,13 +101,19 @@ function OverviewTab({ range }: { range: DateRange }) {
const tokenTotal = tokens.data?.totals?.totalTokens ?? 0; const tokenTotal = tokens.data?.totals?.totalTokens ?? 0;
const toolCalls = tools.data?.toolCalls ?? 0; const toolCalls = tools.data?.toolCalls ?? 0;
const activeNodes = activity.data?.activeNodes ?? 0; const activeNodes = activity.data?.activeNodes ?? 0;
const activeAgents = activity.data?.activeAgents ?? 0;
const tasksDone = activity.data?.funnel?.doneInRange ?? 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 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 = const hasActivityData =
(activity.data?.sessions ?? 0) > 0 || (activity.data?.sessions ?? 0) > 0 ||
(activity.data?.messages ?? 0) > 0 || (activity.data?.messages ?? 0) > 0 ||
activeNodes > 0 || activeNodes > 0 ||
(activity.data?.activeAgents ?? 0) > 0 || activeAgents > 0 ||
tasksDone > 0; tasksDone > 0;
const hasData = tokenTotal > 0 || toolCalls > 0 || hasActivityData; const hasData = tokenTotal > 0 || toolCalls > 0 || hasActivityData;
const hasAllCoreData = tokens.data !== null && tools.data !== null && activity.data !== null; const hasAllCoreData = tokens.data !== null && tools.data !== null && activity.data !== null;
@@ -196,11 +203,39 @@ function OverviewTab({ range }: { range: DateRange }) {
</div> </div>
))} ))}
</div> </div>
{/*
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.
*/}
<div className="cc-live-strip" data-testid="command-center-live-strip"> <div className="cc-live-strip" data-testid="command-center-live-strip">
<span className="cc-live-strip-label">{t("commandCenter.overview.liveStrip", "Live activity")}</span> <div className="cc-live-strip-heading">
<span className="cc-live-strip-placeholder"> <span className="status-dot status-dot--connecting" aria-hidden="true" />
{t("commandCenter.overview.liveStripPending", "Live Mission Control loads with active sessions.")} <span className="cc-live-strip-label">{t("commandCenter.overview.liveStrip", "Live activity snapshot")}</span>
</span> </div>
<div className="cc-live-strip-metrics" data-testid="command-center-live-snapshot">
<span className="cc-live-metric" data-testid="command-center-live-tasks-in-progress">
<span className="cc-live-metric-value">{formatCount(inProgressTasks)}</span>
<span className="cc-live-metric-label">{t("commandCenter.overview.tasksInProgress", "tasks in progress")}</span>
</span>
<span className="cc-live-metric" data-testid="command-center-live-agents-working">
<span className="cc-live-metric-value">{formatCount(activeAgents)}</span>
<span className="cc-live-metric-label">{t("commandCenter.overview.agentsWorking", "agents working")}</span>
</span>
<span className="cc-live-metric" data-testid="command-center-live-open-signals">
<span className="cc-live-metric-value">{signalsLoading ? "—" : signals ? formatCount(signals.open ?? 0) : "—"}</span>
<span className="cc-live-metric-label">{t("commandCenter.overview.openSignals", "open signals")}</span>
</span>
</div>
<div className="cc-live-trend" data-testid="command-center-throughput-trend">
<span className="cc-live-trend-label">{t("commandCenter.overview.throughputTrend", "throughput trend")}</span>
<Sparkline
values={activityTrendValues}
ariaLabel={t("commandCenter.overview.throughputTrendAria", "Recent activity throughput trend")}
/>
</div>
</div> </div>
{throughputSection} {throughputSection}
</div> </div>

View File

@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import type { ActivityAnalytics } from "@fusion/core"; import type { ActivityAnalytics } from "@fusion/core";
import type { DateRange } from "./DateRangePicker"; import type { DateRange } from "./DateRangePicker";
import { Funnel, type FunnelStage } from "./charts/Funnel"; import { Funnel, type FunnelStage } from "./charts/Funnel";
import { RadialGauge } from "./charts/RadialGauge";
import { AreaShell } from "./areas/AreaShell"; import { AreaShell } from "./areas/AreaShell";
import { useAnalyticsArea } from "./areas/useAnalyticsArea"; import { useAnalyticsArea } from "./areas/useAnalyticsArea";
import { formatCount } from "./areas/areaShared"; 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 { function formatThroughput(value: number): string {
if (!Number.isFinite(value)) return "—"; if (!Number.isFinite(value)) return "—";
return value.toFixed(2); return value.toFixed(2);
@@ -99,11 +95,12 @@ export function SdlcFunnel({ range }: { range: DateRange }) {
{t("commandCenter.funnel.throughputTitle", "Throughput")} {t("commandCenter.funnel.throughputTitle", "Throughput")}
</h3> </h3>
<div className="cc-stat-grid"> <div className="cc-stat-grid">
<div className="card cc-stat-card" data-testid="cc-funnel-completion-rate"> <div className="card cc-stat-card cc-stat-card--gauge" data-testid="cc-funnel-completion-rate">
<div className="cc-stat-label"> <RadialGauge
{t("commandCenter.funnel.completionRate", "Completion rate")} value={funnel?.completionRate ?? null}
</div> label={t("commandCenter.funnel.completionRate", "Completion rate")}
<div className="cc-stat-value">{formatRate(funnel?.completionRate ?? null)}</div> ariaLabel={t("commandCenter.funnel.completionRateAria", "Completion rate for in-range triage entrants")}
/>
<span className="cc-stat-sub"> <span className="cc-stat-sub">
{t("commandCenter.funnel.completionRateHint", "Done ÷ entered (in range)")} {t("commandCenter.funnel.completionRateHint", "Done ÷ entered (in range)")}
</span> </span>

View File

@@ -66,12 +66,13 @@ function toolsFixture(toolCalls = 30) {
}; };
} }
function activityFixture(overrides: Partial<Record<"sessions" | "messages" | "activeNodes" | "activeAgents" | "doneInRange", number>> = {}) { function activityFixture(overrides: Partial<Record<"sessions" | "messages" | "activeNodes" | "activeAgents" | "doneInRange" | "inProgress", number>> = {}) {
const sessions = overrides.sessions ?? 4; const sessions = overrides.sessions ?? 4;
const messages = overrides.messages ?? 18; const messages = overrides.messages ?? 18;
const activeNodes = overrides.activeNodes ?? 3; const activeNodes = overrides.activeNodes ?? 3;
const activeAgents = overrides.activeAgents ?? 2; const activeAgents = overrides.activeAgents ?? 2;
const doneInRange = overrides.doneInRange ?? 7; const doneInRange = overrides.doneInRange ?? 7;
const inProgress = overrides.inProgress ?? 3;
return { return {
from: "2026-06-08", from: "2026-06-08",
to: null, to: null,
@@ -79,13 +80,14 @@ function activityFixture(overrides: Partial<Record<"sessions" | "messages" | "ac
messages, messages,
activeNodes, activeNodes,
activeAgents, activeAgents,
daily: [], daily: messages > 0 ? [{ day: "2026-06-08", activeNodes, activeAgents, messages }] : [],
stickiness: activeAgents > 0 ? 0.5 : 0, stickiness: activeAgents > 0 ? 0.5 : 0,
mttr: { value: null, unavailable: true }, mttr: { value: null, unavailable: true },
monitor: { mttr: { value: null, unavailable: true }, incidents: 0, deployments: 0 }, monitor: { mttr: { value: null, unavailable: true }, incidents: 0, deployments: 0 },
funnel: { funnel: {
stages: [ stages: [
{ stage: "triage", entered: doneInRange, current: 0 }, { stage: "triage", entered: doneInRange, current: 0 },
{ stage: "in-progress", entered: inProgress, current: inProgress },
{ stage: "done", entered: doneInRange, current: doneInRange }, { stage: "done", entered: doneInRange, current: doneInRange },
], ],
enteredInRange: doneInRange, enteredInRange: doneInRange,
@@ -179,6 +181,12 @@ describe("CommandCenter shell", () => {
expect(statValue("command-center-stat-models")).toBe("2"); expect(statValue("command-center-stat-models")).toBe("2");
expect(statValue("command-center-stat-signals")).toBe("2"); expect(statValue("command-center-stat-signals")).toBe("2");
expect(screen.getByTestId("command-center-live-strip")).toBeTruthy(); 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(); expect(screen.getByTestId("command-center-throughput")).toBeTruthy();
}); });

View File

@@ -73,6 +73,7 @@ describe("SdlcFunnel", () => {
render(<SdlcFunnel range={range7d} />); render(<SdlcFunnel range={range7d} />);
await screen.findByTestId("cc-area-funnel"); 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-completion-rate").textContent).toContain("50%");
expect(screen.getByTestId("cc-funnel-done").textContent).toContain("2"); expect(screen.getByTestId("cc-funnel-done").textContent).toContain("2");
expect(screen.getByTestId("cc-funnel-entered").textContent).toContain("4"); expect(screen.getByTestId("cc-funnel-entered").textContent).toContain("4");
@@ -101,6 +102,7 @@ describe("SdlcFunnel", () => {
render(<SdlcFunnel range={range7d} />); render(<SdlcFunnel range={range7d} />);
await screen.findByTestId("cc-area-funnel"); 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("—"); expect(screen.getByTestId("cc-funnel-completion-rate").textContent).toContain("—");
}); });

View File

@@ -6,6 +6,7 @@ import { Bar } from "../charts/Bar";
import { StackedBar } from "../charts/StackedBar"; import { StackedBar } from "../charts/StackedBar";
import { Sparkline } from "../charts/Sparkline"; import { Sparkline } from "../charts/Sparkline";
import { Funnel } from "../charts/Funnel"; import { Funnel } from "../charts/Funnel";
import { RadialGauge } from "../charts/RadialGauge";
function widthOf(el: HTMLElement): string { function widthOf(el: HTMLElement): string {
return el.style.width; 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(<RadialGauge value={0.73} label="Completion" ariaLabel="Completion rate" />);
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(<RadialGauge value={null} label="Completion" ariaLabel="Completion rate" />);
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(<RadialGauge value={0} label="Zero" ariaLabel="Zero rate" />);
expect(screen.getByText("0%")).toBeTruthy();
rerender(<RadialGauge value={Number.NaN} label="NaN" ariaLabel="NaN rate" />);
expect(screen.getByText("0%")).toBeTruthy();
expect(screen.getByText("0%").textContent).not.toContain("NaN");
});
});
describe("Funnel", () => { describe("Funnel", () => {
it("renders stages with conversion from the prior stage", () => { it("renders stages with conversion from the prior stage", () => {
render( render(
@@ -134,7 +158,7 @@ describe("chart CSS animation tokens", () => {
const css = readFileSync(cssPath, "utf8"); const css = readFileSync(cssPath, "utf8");
it("uses a --duration-* token in the loader animation, not --transition-*", () => { 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); expect(animationLines.length).toBeGreaterThan(0);
for (const line of animationLines) { for (const line of animationLines) {
expect(line).not.toMatch(/var\(--transition-/); expect(line).not.toMatch(/var\(--transition-/);

View File

@@ -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 (
<div className="cc-radial-gauge" role="img" aria-label={ariaLabel} style={style}>
<div className="cc-radial-gauge-ring" aria-hidden="true">
<div className="cc-radial-gauge-core">
<span className="cc-radial-gauge-value">{valueText}</span>
</div>
</div>
<span className="cc-radial-gauge-label">{label}</span>
</div>
);
}

View File

@@ -118,6 +118,89 @@ Chart labels and legends must use --text-muted so command-center CSS stays align
transition: height var(--transition-normal); 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 ---- */ /* ---- Funnel ---- */
.cc-funnel { .cc-funnel {
list-style: none; list-style: none;

View File

@@ -13,6 +13,8 @@ export default defineConfig({
}, },
test: { test: {
include: ["src/**/*.test.ts"], 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))], 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))], globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
pool: "threads", pool: "threads",

View File

@@ -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.", "$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"
}
]
} }