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:
5
.changeset/fn-6631-command-center-rate-gauge.md
Normal file
5
.changeset/fn-6631-command-center-rate-gauge.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string>();
|
||||
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 =
|
||||
enteredInRange === 0 ? null : doneInRange / enteredInRange;
|
||||
enteredInRange === 0 ? null : completedTriageEntrants / enteredInRange;
|
||||
|
||||
const rangeDays = countWholeDays(query.from, query.to);
|
||||
const throughputPerDay = doneInRange / rangeDays;
|
||||
|
||||
@@ -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 ---- */
|
||||
|
||||
@@ -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 }) {
|
||||
</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">
|
||||
<span className="cc-live-strip-label">{t("commandCenter.overview.liveStrip", "Live activity")}</span>
|
||||
<span className="cc-live-strip-placeholder">
|
||||
{t("commandCenter.overview.liveStripPending", "Live Mission Control loads with active sessions.")}
|
||||
</span>
|
||||
<div className="cc-live-strip-heading">
|
||||
<span className="status-dot status-dot--connecting" aria-hidden="true" />
|
||||
<span className="cc-live-strip-label">{t("commandCenter.overview.liveStrip", "Live activity snapshot")}</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>
|
||||
{throughputSection}
|
||||
</div>
|
||||
|
||||
@@ -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")}
|
||||
</h3>
|
||||
<div className="cc-stat-grid">
|
||||
<div className="card cc-stat-card" data-testid="cc-funnel-completion-rate">
|
||||
<div className="cc-stat-label">
|
||||
{t("commandCenter.funnel.completionRate", "Completion rate")}
|
||||
</div>
|
||||
<div className="cc-stat-value">{formatRate(funnel?.completionRate ?? null)}</div>
|
||||
<div className="card cc-stat-card cc-stat-card--gauge" data-testid="cc-funnel-completion-rate">
|
||||
<RadialGauge
|
||||
value={funnel?.completionRate ?? null}
|
||||
label={t("commandCenter.funnel.completionRate", "Completion rate")}
|
||||
ariaLabel={t("commandCenter.funnel.completionRateAria", "Completion rate for in-range triage entrants")}
|
||||
/>
|
||||
<span className="cc-stat-sub">
|
||||
{t("commandCenter.funnel.completionRateHint", "Done ÷ entered (in range)")}
|
||||
</span>
|
||||
|
||||
@@ -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 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<Record<"sessions" | "messages" | "ac
|
||||
messages,
|
||||
activeNodes,
|
||||
activeAgents,
|
||||
daily: [],
|
||||
daily: messages > 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();
|
||||
});
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ describe("SdlcFunnel", () => {
|
||||
render(<SdlcFunnel range={range7d} />);
|
||||
|
||||
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(<SdlcFunnel range={range7d} />);
|
||||
|
||||
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("—");
|
||||
});
|
||||
|
||||
|
||||
@@ -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(<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", () => {
|
||||
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-/);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user