FN-6683: add analytics pie and line charts
Add chart coverage to Command Center analytics without replacing existing surfaces. - Add pie and Recharts line visualizations to Overview, Tokens, Tools, Activity, and Productivity analytics areas. - Reuse existing analytics payloads while preserving current bars, sparklines, tables, live refresh, and empty/loading branches. - Extend Command Center tests and documentation for the new chart affordances. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-6683-command-center-charts.md | 5 + docs/dashboard-guide.md | 14 +- .../components/command-center/CommandCenter.css | 15 +- .../components/command-center/CommandCenter.tsx | 37 +++++ .../__tests__/CommandCenter.mobile-scroll.test.tsx | 6 +- .../__tests__/CommandCenter.test.tsx | 17 +- .../command-center/areas/ActivityArea.tsx | 42 +++++ .../command-center/areas/ProductivityArea.tsx | 27 +++ .../components/command-center/areas/TokensArea.tsx | 45 +++++ .../components/command-center/areas/ToolsArea.tsx | 22 +++ .../command-center/areas/__tests__/areas.test.tsx | 184 ++++++++++++++++++++- .../app/components/command-center/areas/areas.css | 11 ++ 12 files changed, 407 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-6683 Fusion-Task-Lineage: ca9d3103-5132-41f8-a9ce-39e0bff581b8
This commit is contained in:
5
.changeset/fn-6683-command-center-charts.md
Normal file
5
.changeset/fn-6683-command-center-charts.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add Command Center pie and line chart affordances to the Overview, Tokens, Tools, Activity, and Productivity analytics surfaces using existing analytics data.
|
||||||
@@ -663,11 +663,11 @@ 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, agent runs, tasks done, model breadth, and open signals, and includes the SDLC throughput funnel for the selected range. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with tokens-by-model, tool-category, and daily activity trend charts that reuse the already-loaded tokens, tools, and activity analytics. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. 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.
|
- **Overview** summarizes token usage/cost, autonomy, active nodes, agent runs, tasks done, model breadth, and open signals, and includes the SDLC throughput funnel for the selected range. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with the existing tokens-by-model bar, tool-category bar, daily activity sparkline, plus real recharts token-share pie and daily activity multi-series line graphs. All of these reuse the already-loaded tokens, tools, and activity analytics; Overview adds no new endpoint. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. 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. Per-model and per-provider breakdowns use the task's analytics-only actually-used model snapshot when available, so usage from settings-resolved runs appears under the real runtime model instead of `(unknown)` without changing future model resolution; estimated cost uses the same snapshot-first, legacy-fallback model identity so those resolved runs price normally when the model is in the pricing table. It also includes a live token-usage-over-time chart backed by per-task token timestamps; use the granularity control to switch the chart between hourly, daily, and weekly buckets. The token total and chart poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users.
|
- **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage. Per-model and per-provider breakdowns use the task's analytics-only actually-used model snapshot when available, so usage from settings-resolved runs appears under the real runtime model instead of `(unknown)` without changing future model resolution; estimated cost uses the same snapshot-first, legacy-fallback model identity so those resolved runs price normally when the model is in the pricing table. It includes the existing token-usage-over-time chart, an additive recharts multi-series line graph, and a token-share pie backed by the same grouped token analytics; use the granularity control to switch the time-series request between hourly, daily, and weekly buckets. The token total and charts poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users.
|
||||||
- **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. The area keeps the existing category bar and adds a recharts category-share pie from `ToolAnalytics.byCategory`. There is intentionally no tools line chart yet because `ToolAnalytics` does not expose a per-day tool trend; the dashboard does not fabricate one or call a new endpoint.
|
||||||
- **Activity** tracks sessions, messages, active nodes, active agents, agent heartbeat runs, and stickiness. Agent-run sheets show total, active, completed, and failed runs for the selected range, and the Agent runs/day sparkline trends runs by `agentRuns.startedAt`. The area also renders live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`). These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users.
|
- **Activity** tracks sessions, messages, active nodes, active agents, agent heartbeat runs, and stickiness. Agent-run sheets show total, active, completed, and failed runs for the selected range, and the Agent runs/day sparkline trends runs by `agentRuns.startedAt`. The area keeps the existing live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`), and adds a recharts multi-series line graph for messages, active agents, and agent runs plus an agent-run outcome pie from the existing `agentRuns` split. These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users.
|
||||||
- **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. It keeps the files-by-language bar and adds a language-share pie from `ProductivityAnalytics.byLanguage`. There is intentionally no productivity line chart because the current productivity response has no per-day throughput or completion time series; no new endpoint is called.
|
||||||
- **Team** shows a per-agent analytics table plus tokens-by-agent and tasks-done-by-agent charts. Metrics come only from the project-scoped `tasks` and `agents` tables: token totals and estimated cost are summed from the `tokenUsage*` columns by `assignedAgentId`, files changed counts parsed `tasks.modifiedFiles` paths, tasks done counts `column = 'done'` moves in the selected range, and in-progress / in-review values reflect current task columns. Agent name, role, and live state come from the `agents` table; deleted-agent task history falls back to the raw agent id instead of crashing. The tab uses `/api/command-center/team`, adds no schema, never calls GitHub, and intentionally leaves per-agent issues filed/fixed to FN-6653. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users.
|
- **Team** shows a per-agent analytics table plus tokens-by-agent and tasks-done-by-agent charts. Metrics come only from the project-scoped `tasks` and `agents` tables: token totals and estimated cost are summed from the `tokenUsage*` columns by `assignedAgentId`, files changed counts parsed `tasks.modifiedFiles` paths, tasks done counts `column = 'done'` moves in the selected range, and in-progress / in-review values reflect current task columns. Agent name, role, and live state come from the `agents` table; deleted-agent task history falls back to the raw agent id instead of crashing. The tab uses `/api/command-center/team`, adds no schema, never calls GitHub, and intentionally leaves per-agent issues filed/fixed to FN-6653. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users.
|
||||||
- **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.
|
||||||
- **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, filed-vs-fixed daily sparklines, and a by-repository bar breakdown.
|
- **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, filed-vs-fixed daily sparklines, and a by-repository bar breakdown.
|
||||||
@@ -677,13 +677,13 @@ Features:
|
|||||||
- CSV exports are available from the analytics endpoints with `?format=csv`. The Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`.
|
- CSV exports are available from the analytics endpoints with `?format=csv`. The Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`.
|
||||||
|
|
||||||
Rendering invariants:
|
Rendering invariants:
|
||||||
- On mobile (`max-width: 768px`), `.cc-tabpanel` remains the sole vertical scroll owner for every chart-bearing tab. Shared chart primitives (`Bar`, `StackedBar`, `Sparkline`, `LineChart`, `RadialGauge`, `Funnel`, and `TokenSeriesChart`) must shrink within the tabpanel, keep non-zero usable height, avoid stretch/clipping artifacts, and never introduce a competing vertical overflow container.
|
- On mobile (`max-width: 768px`), `.cc-tabpanel` remains the sole vertical scroll owner for every chart-bearing tab. Shared chart primitives (`Bar`, `StackedBar`, `Sparkline`, `LineChart`, `RadialGauge`, `Funnel`, `TokenSeriesChart`, and the Command Center recharts wrappers) must shrink within the tabpanel, keep non-zero usable height, avoid stretch/clipping artifacts, and never introduce a competing vertical overflow container.
|
||||||
- Mobile chart text must not rely on min-content luck: bar labels, values, token-series axis labels, funnel headers, radial labels, legends, and chart tracks need explicit `min-inline-size: 0`, wrapping, or ellipsis rules so long model/agent/repo labels cannot crush the track or create hidden horizontal overflow in a real browser.
|
- Mobile chart text must not rely on min-content luck: bar labels, values, token-series axis labels, funnel headers, radial labels, legends, and chart tracks need explicit `min-inline-size: 0`, wrapping, or ellipsis rules so long model/agent/repo labels cannot crush the track or create hidden horizontal overflow in a real browser.
|
||||||
- On tablet (`min-width: 769px` and `max-width: 1024px`), `.project-content`, `.command-center`, and `.cc-tabpanel` keep the same definite flex/min-height scroll-owner chain, while the live strip and chart grids collapse before they can create document-level horizontal overflow.
|
- On tablet (`min-width: 769px` and `max-width: 1024px`), `.project-content`, `.command-center`, and `.cc-tabpanel` keep the same definite flex/min-height scroll-owner chain, while the live strip and chart grids collapse before they can create document-level horizontal overflow.
|
||||||
- Command Center stat cards, overview chart cards, live strips, table wrappers, Team chart panels, token-series plots, system control cards, and gauge/chart cards share the same tokenized rhythm: `--space-3` gaps/padding for card-like surfaces, `--border-width` borders, `--radius-md` radii, and `--surface-1` backgrounds. Area-specific accents may use `color-mix(...)`, but layout, border, radius, text color, and motion must stay on design tokens.
|
- Command Center stat cards, overview chart cards, live strips, table wrappers, Team chart panels, token-series plots, system control cards, and gauge/chart cards share the same tokenized rhythm: `--space-3` gaps/padding for card-like surfaces, `--border-width` borders, `--radius-md` radii, and `--surface-1` backgrounds. Area-specific accents may use `color-mix(...)`, but layout, border, radius, text color, and motion must stay on design tokens.
|
||||||
|
|
||||||
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. Overview, Tokens, Tools, Activity, and Productivity omit their additive recharts cards in loading/error/empty states, so non-populated data never leaves an empty chart shell.
|
||||||
- GitHub issue analytics is local and additive: empty filed/fixed totals keep the stat cards and historical backfill button available while omitting empty chart shells; malformed historical `githubTracking` JSON is skipped instead of breaking the Command Center.
|
- GitHub issue analytics is local and additive: empty filed/fixed totals keep the stat cards and historical backfill button available while omitting empty chart shells; malformed historical `githubTracking` JSON is skipped instead of breaking the Command Center.
|
||||||
- Team analytics renders its shared loading/error/empty states for null or zero-agent responses, omits empty chart shells for zero-value datasets, and keeps the Command Center tab panel as the mobile scroll owner.
|
- Team analytics renders its shared loading/error/empty states for null or zero-agent responses, omits empty chart shells for zero-value datasets, and keeps the Command Center tab panel as the mobile scroll owner.
|
||||||
- System telemetry keeps the previous snapshot visible during refresh failures, renders a first-sample CPU `Sampling…` state without NaN values, shows zero-value task/agent bars for empty collections, and keeps the Command Center tab panel as the mobile scroll owner.
|
- System telemetry keeps the previous snapshot visible during refresh failures, renders a first-sample CPU `Sampling…` state without NaN values, shows zero-value task/agent bars for empty collections, and keeps the Command Center tab panel as the mobile scroll owner.
|
||||||
|
|||||||
@@ -373,11 +373,24 @@ Overview charts must use dashboard tokens only and keep motion decorative; anima
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cc-overview-chart-card .cc-bar-chart,
|
.cc-overview-chart-card .cc-bar-chart,
|
||||||
.cc-overview-chart-card .cc-sparkline {
|
.cc-overview-chart-card .cc-sparkline,
|
||||||
|
.cc-overview-chart-card .cc-recharts-chart,
|
||||||
|
.cc-overview-chart-card .cc-recharts-empty {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CommandCenterCharts 2026-06-18-23:49:
|
||||||
|
Overview recharts cards use token-derived height so ResponsiveContainer can render without adding an inner scroll container; .cc-tabpanel remains the only vertical scroller at mobile widths.
|
||||||
|
*/
|
||||||
|
.cc-overview-chart-card .cc-recharts-chart,
|
||||||
|
.cc-overview-chart-card .cc-recharts-empty {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: calc(var(--space-20) * 3);
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.cc-overview-chart-card .cc-sparkline {
|
.cc-overview-chart-card .cc-sparkline {
|
||||||
block-size: var(--space-16);
|
block-size: var(--space-16);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { MissionControlPanel } from "./MissionControlPanel";
|
|||||||
import { SdlcFunnel } from "./SdlcFunnel";
|
import { SdlcFunnel } from "./SdlcFunnel";
|
||||||
import { Bar, type BarDatum } from "./charts/Bar";
|
import { Bar, type BarDatum } from "./charts/Bar";
|
||||||
import { Sparkline } from "./charts/Sparkline";
|
import { Sparkline } from "./charts/Sparkline";
|
||||||
|
import { LineChart as RechartsLineChart, PieChart } from "./charts/recharts";
|
||||||
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";
|
||||||
@@ -71,6 +72,9 @@ interface OverviewStatCard {
|
|||||||
/*
|
/*
|
||||||
FNXC:CommandCenter 2026-06-17-00:00:
|
FNXC:CommandCenter 2026-06-17-00:00:
|
||||||
Overview is the Command Center landing surface, so it must reflect real analytics instead of shell placeholders. Show loading while core analytics have not settled, show the empty state only after settled zero data, include the agent-runs card as a first-class activity signal, and treat Signals as best-effort because that endpoint can be absent without invalidating tokens/tools/activity metrics.
|
Overview is the Command Center landing surface, so it must reflect real analytics instead of shell placeholders. Show loading while core analytics have not settled, show the empty state only after settled zero data, include the agent-runs card as a first-class activity signal, and treat Signals as best-effort because that endpoint can be absent without invalidating tokens/tools/activity metrics.
|
||||||
|
|
||||||
|
FNXC:CommandCenter 2026-06-18-23:45:
|
||||||
|
FN-6683 adds real Overview pie and line charts by reusing the already-fetched tokens and activity analytics. Keep the existing overview bars, sparkline, live strip, funnel, and loading/error/empty branches intact; no new endpoint is allowed for these additive affordances.
|
||||||
*/
|
*/
|
||||||
const OVERVIEW_TOKEN_REFRESH_MS = 15_000;
|
const OVERVIEW_TOKEN_REFRESH_MS = 15_000;
|
||||||
|
|
||||||
@@ -177,10 +181,22 @@ function OverviewTab({ range }: { range: DateRange }) {
|
|||||||
})),
|
})),
|
||||||
[tools.data?.byCategory],
|
[tools.data?.byCategory],
|
||||||
);
|
);
|
||||||
|
const overviewPieData = useMemo(
|
||||||
|
() => tokensByModelData.map((entry) => ({ label: entry.label, value: entry.value })),
|
||||||
|
[tokensByModelData],
|
||||||
|
);
|
||||||
const dailyActivityValues = useMemo(
|
const dailyActivityValues = useMemo(
|
||||||
() => (activity.data?.daily ?? []).map((day) => day.messages + day.activeAgents + (day.agentRuns ?? 0)),
|
() => (activity.data?.daily ?? []).map((day) => day.messages + day.activeAgents + (day.agentRuns ?? 0)),
|
||||||
[activity.data?.daily],
|
[activity.data?.daily],
|
||||||
);
|
);
|
||||||
|
const overviewLineSeries = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: t("commandCenter.activity.messages", "Messages"), values: (activity.data?.daily ?? []).map((day) => day.messages) },
|
||||||
|
{ label: t("commandCenter.activity.activeAgents", "Active agents"), values: (activity.data?.daily ?? []).map((day) => day.activeAgents) },
|
||||||
|
{ label: t("commandCenter.activity.agentRuns", "Agent runs"), values: (activity.data?.daily ?? []).map((day) => day.agentRuns) },
|
||||||
|
],
|
||||||
|
[activity.data?.daily, t],
|
||||||
|
);
|
||||||
const activityTrendValues =
|
const activityTrendValues =
|
||||||
dailyActivityValues.length > 0
|
dailyActivityValues.length > 0
|
||||||
? dailyActivityValues
|
? dailyActivityValues
|
||||||
@@ -335,6 +351,15 @@ function OverviewTab({ range }: { range: DateRange }) {
|
|||||||
<Bar data={tokensByModelData} ariaLabel={t("commandCenter.overview.tokensByModel", "Tokens by model")} />
|
<Bar data={tokensByModelData} ariaLabel={t("commandCenter.overview.tokensByModel", "Tokens by model")} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{overviewPieData.length > 0 ? (
|
||||||
|
<div className="card cc-overview-chart-card" data-testid="cc-overview-pie">
|
||||||
|
<div className="cc-overview-chart-header">
|
||||||
|
<h3 className="cc-area-section-title">{t("commandCenter.overview.tokensByModelPie", "Token share by model")}</h3>
|
||||||
|
<p>{t("commandCenter.overview.tokensByModelPieHint", "Top model token share in this range")}</p>
|
||||||
|
</div>
|
||||||
|
<PieChart data={overviewPieData} ariaLabel={t("commandCenter.overview.tokensByModelPie", "Token share by model")} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{toolCategoryData.length > 0 ? (
|
{toolCategoryData.length > 0 ? (
|
||||||
<div className="card cc-overview-chart-card" data-testid="command-center-overview-chart-tools">
|
<div className="card cc-overview-chart-card" data-testid="command-center-overview-chart-tools">
|
||||||
<div className="cc-overview-chart-header">
|
<div className="cc-overview-chart-header">
|
||||||
@@ -356,6 +381,18 @@ function OverviewTab({ range }: { range: DateRange }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{dailyActivityValues.length > 0 ? (
|
||||||
|
<div className="card cc-overview-chart-card cc-overview-chart-card--trend" data-testid="cc-overview-line">
|
||||||
|
<div className="cc-overview-chart-header">
|
||||||
|
<h3 className="cc-area-section-title">{t("commandCenter.overview.dailyActivityLine", "Daily activity line")}</h3>
|
||||||
|
<p>{t("commandCenter.overview.dailyActivityLineHint", "Messages, agents, and runs by day")}</p>
|
||||||
|
</div>
|
||||||
|
<RechartsLineChart
|
||||||
|
series={overviewLineSeries}
|
||||||
|
ariaLabel={t("commandCenter.overview.dailyActivityLine", "Daily activity line")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
{throughputSection}
|
{throughputSection}
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ function assertScrollOwnerContract(panel: HTMLElement) {
|
|||||||
function assertNoChartScrollSteal(panel: HTMLElement) {
|
function assertNoChartScrollSteal(panel: HTMLElement) {
|
||||||
// FN-6680: jsdom does not compute real flex/grid layout, so this guards rule presence and scroll-owner structure only; the CSS-string regression plus Blink audit cover actual mobile pixel layout.
|
// FN-6680: jsdom does not compute real flex/grid layout, so this guards rule presence and scroll-owner structure only; the CSS-string regression plus Blink audit cover actual mobile pixel layout.
|
||||||
const chartContainers = panel.querySelectorAll<HTMLElement>(
|
const chartContainers = panel.querySelectorAll<HTMLElement>(
|
||||||
".cc-bar-chart, .cc-bar-row, .cc-sparkline, .cc-line-chart, .cc-radial-gauge, .cc-funnel, .cc-token-series, .cc-token-series-plot, .cc-overview-chart-card, .cc-team-chart-panel, .cc-stat-card",
|
".cc-bar-chart, .cc-bar-row, .cc-sparkline, .cc-line-chart, .cc-recharts-chart, .cc-recharts-empty, .cc-radial-gauge, .cc-funnel, .cc-token-series, .cc-token-series-plot, .cc-overview-chart-card, .cc-team-chart-panel, .cc-stat-card",
|
||||||
);
|
);
|
||||||
expect(chartContainers.length).toBeGreaterThan(0);
|
expect(chartContainers.length).toBeGreaterThan(0);
|
||||||
for (const container of chartContainers) {
|
for (const container of chartContainers) {
|
||||||
@@ -368,6 +368,8 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => {
|
|||||||
|
|
||||||
await screen.findByTestId("command-center-overview-charts");
|
await screen.findByTestId("command-center-overview-charts");
|
||||||
expect(screen.getByTestId("command-center-overview-chart-tokens")).toBeTruthy();
|
expect(screen.getByTestId("command-center-overview-chart-tokens")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-overview-pie")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-overview-line")).toBeTruthy();
|
||||||
expect(screen.getByTestId("command-center-live-tasks-in-progress")).toBeTruthy();
|
expect(screen.getByTestId("command-center-live-tasks-in-progress")).toBeTruthy();
|
||||||
assertScrollOwnerContract(screen.getByTestId("command-center-panel-overview"));
|
assertScrollOwnerContract(screen.getByTestId("command-center-panel-overview"));
|
||||||
});
|
});
|
||||||
@@ -399,6 +401,8 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => {
|
|||||||
expect(styles).toContain("overflow-x: hidden");
|
expect(styles).toContain("overflow-x: hidden");
|
||||||
expect(styles).toContain("grid-template-columns: minmax(0, 1fr) minmax(var(--space-12), 2fr)");
|
expect(styles).toContain("grid-template-columns: minmax(0, 1fr) minmax(var(--space-12), 2fr)");
|
||||||
expect(styles).toContain(".cc-line-chart");
|
expect(styles).toContain(".cc-line-chart");
|
||||||
|
expect(styles).toContain(".cc-recharts-chart");
|
||||||
|
expect(styles).toContain("block-size: calc(var(--space-20) * 3)");
|
||||||
expect(styles).toContain("aspect-ratio: auto");
|
expect(styles).toContain("aspect-ratio: auto");
|
||||||
expect(styles).toContain(".cc-radial-gauge-ring");
|
expect(styles).toContain(".cc-radial-gauge-ring");
|
||||||
expect(styles).toContain("inline-size: clamp(var(--space-20), 44vw, var(--space-32))");
|
expect(styles).toContain("inline-size: clamp(var(--space-20), 44vw, var(--space-32))");
|
||||||
|
|||||||
@@ -292,8 +292,12 @@ describe("CommandCenter shell", () => {
|
|||||||
expect(screen.queryByTestId("command-center-empty")).toBeNull();
|
expect(screen.queryByTestId("command-center-empty")).toBeNull();
|
||||||
expect(screen.getByTestId("command-center-overview-loading")).toBeTruthy();
|
expect(screen.getByTestId("command-center-overview-loading")).toBeTruthy();
|
||||||
expect(screen.queryByTestId("command-center-overview-charts")).toBeNull();
|
expect(screen.queryByTestId("command-center-overview-charts")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-overview-pie")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-overview-line")).toBeNull();
|
||||||
await screen.findByTestId("command-center-empty");
|
await screen.findByTestId("command-center-empty");
|
||||||
expect(screen.queryByTestId("command-center-overview-charts")).toBeNull();
|
expect(screen.queryByTestId("command-center-overview-charts")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-overview-pie")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-overview-line")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the Overview agent-runs card when run data is the only activity", async () => {
|
it("renders the Overview agent-runs card when run data is the only activity", async () => {
|
||||||
@@ -339,6 +343,10 @@ describe("CommandCenter shell", () => {
|
|||||||
expect(within(charts).getByText("Tokens by model")).toBeTruthy();
|
expect(within(charts).getByText("Tokens by model")).toBeTruthy();
|
||||||
expect(within(screen.getByTestId("command-center-overview-chart-tokens")).getByText("gpt-4o")).toBeTruthy();
|
expect(within(screen.getByTestId("command-center-overview-chart-tokens")).getByText("gpt-4o")).toBeTruthy();
|
||||||
expect(within(screen.getByTestId("command-center-overview-chart-tools")).getByText("read")).toBeTruthy();
|
expect(within(screen.getByTestId("command-center-overview-chart-tools")).getByText("read")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-overview-pie")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-overview-line")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("img", { name: "Token share by model" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("img", { name: "Daily activity line" })).toBeTruthy();
|
||||||
expect(screen.getByRole("img", { name: "Daily activity trend" })).toBeTruthy();
|
expect(screen.getByRole("img", { name: "Daily activity trend" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -464,8 +472,10 @@ describe("CommandCenter shell", () => {
|
|||||||
|
|
||||||
await screen.findByTestId("command-center-overview-charts");
|
await screen.findByTestId("command-center-overview-charts");
|
||||||
expect(screen.getByTestId("command-center-overview-chart-tokens")).toBeTruthy();
|
expect(screen.getByTestId("command-center-overview-chart-tokens")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-overview-pie")).toBeTruthy();
|
||||||
expect(screen.queryByTestId("command-center-overview-chart-tools")).toBeNull();
|
expect(screen.queryByTestId("command-center-overview-chart-tools")).toBeNull();
|
||||||
expect(screen.getByTestId("command-center-overview-chart-activity")).toBeTruthy();
|
expect(screen.getByTestId("command-center-overview-chart-activity")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-overview-line")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles empty, undefined, single-item, and zero chart data without NaN output", async () => {
|
it("handles empty, undefined, single-item, and zero chart data without NaN output", async () => {
|
||||||
@@ -499,9 +509,12 @@ describe("CommandCenter shell", () => {
|
|||||||
|
|
||||||
await screen.findByTestId("command-center-overview-charts");
|
await screen.findByTestId("command-center-overview-charts");
|
||||||
expect(screen.getByTestId("command-center-overview-chart-tokens").textContent).toContain("idle-model");
|
expect(screen.getByTestId("command-center-overview-chart-tokens").textContent).toContain("idle-model");
|
||||||
|
expect(screen.getByTestId("cc-overview-pie")).toBeTruthy();
|
||||||
expect(screen.queryByTestId("command-center-overview-chart-tools")).toBeNull();
|
expect(screen.queryByTestId("command-center-overview-chart-tools")).toBeNull();
|
||||||
expect(screen.getByTestId("command-center-overview-chart-activity")).toBeTruthy();
|
expect(screen.getByTestId("command-center-overview-chart-activity")).toBeTruthy();
|
||||||
expect(screen.getByTestId("command-center-panel-overview").textContent).not.toContain("NaN");
|
expect(screen.getByTestId("cc-overview-line")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-overview-pie").textContent).not.toContain("NaN");
|
||||||
|
expect(screen.getByTestId("cc-overview-line").textContent).not.toContain("NaN");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Overview populated when the signals endpoint is missing", async () => {
|
it("keeps Overview populated when the signals endpoint is missing", async () => {
|
||||||
@@ -530,6 +543,8 @@ describe("CommandCenter shell", () => {
|
|||||||
expect(screen.queryByTestId("command-center-overview-loading")).toBeNull();
|
expect(screen.queryByTestId("command-center-overview-loading")).toBeNull();
|
||||||
expect(screen.queryByTestId("command-center-empty")).toBeNull();
|
expect(screen.queryByTestId("command-center-empty")).toBeNull();
|
||||||
expect(screen.queryByTestId("command-center-overview-charts")).toBeNull();
|
expect(screen.queryByTestId("command-center-overview-charts")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-overview-pie")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-overview-line")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("re-fetches and re-derives the Overview empty state when the range changes", async () => {
|
it("re-fetches and re-derives the Overview empty state when the range changes", async () => {
|
||||||
|
|||||||
@@ -4,12 +4,18 @@ import type { ActivityAnalytics } from "@fusion/core";
|
|||||||
import type { DateRange } from "../DateRangePicker";
|
import type { DateRange } from "../DateRangePicker";
|
||||||
import { LineChart } from "../charts/LineChart";
|
import { LineChart } from "../charts/LineChart";
|
||||||
import { Sparkline } from "../charts/Sparkline";
|
import { Sparkline } from "../charts/Sparkline";
|
||||||
|
import { LineChart as RechartsLineChart, PieChart } from "../charts/recharts";
|
||||||
import { AreaShell } from "./AreaShell";
|
import { AreaShell } from "./AreaShell";
|
||||||
import { useAnalyticsArea } from "./useAnalyticsArea";
|
import { useAnalyticsArea } from "./useAnalyticsArea";
|
||||||
import { formatCount, isInvalidRange } from "./areaShared";
|
import { formatCount, isInvalidRange } from "./areaShared";
|
||||||
|
|
||||||
const ACTIVITY_LIVE_REFRESH_MS = 15_000;
|
const ACTIVITY_LIVE_REFRESH_MS = 15_000;
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CommandCenterCharts 2026-06-18-23:34:
|
||||||
|
The Activity surface must add FN-6682 recharts affordances without replacing the existing hand-rolled line/sparkline testids or live-refresh behavior. Reuse the current daily and agent-run outcome analytics; never introduce a new endpoint or fabricate missing dimensions.
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FNXC:CommandCenter 2026-06-18-14:29:
|
* FNXC:CommandCenter 2026-06-18-14:29:
|
||||||
* Activity metrics surface as live, animated line charts auto-refreshed via reload() on a bounded interval; motion is decorative and reduced-motion-safe, uses the existing activity endpoint, and keeps prior data visible during polling revalidation.
|
* Activity metrics surface as live, animated line charts auto-refreshed via reload() on a bounded interval; motion is decorative and reduced-motion-safe, uses the existing activity endpoint, and keeps prior data visible during polling revalidation.
|
||||||
@@ -27,6 +33,14 @@ export function ActivityArea({ range }: { range: DateRange }) {
|
|||||||
() => daily.map((d) => d.messages + d.activeAgents + d.activeNodes),
|
() => daily.map((d) => d.messages + d.activeAgents + d.activeNodes),
|
||||||
[daily],
|
[daily],
|
||||||
);
|
);
|
||||||
|
const rechartsLineSeries = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: t("commandCenter.activity.messages", "Messages"), values: messagesSeries },
|
||||||
|
{ label: t("commandCenter.activity.activeAgents", "Active agents"), values: agentsSeries },
|
||||||
|
{ label: t("commandCenter.activity.agentRuns", "Agent runs"), values: agentRunsSeries },
|
||||||
|
],
|
||||||
|
[agentRunsSeries, agentsSeries, messagesSeries, t],
|
||||||
|
);
|
||||||
const invalidRange = isInvalidRange(range);
|
const invalidRange = isInvalidRange(range);
|
||||||
const isInitialLoading = isLoading && data === null;
|
const isInitialLoading = isLoading && data === null;
|
||||||
|
|
||||||
@@ -41,6 +55,14 @@ export function ActivityArea({ range }: { range: DateRange }) {
|
|||||||
}, [invalidRange, reload]);
|
}, [invalidRange, reload]);
|
||||||
|
|
||||||
const agentRuns = data?.agentRuns ?? { total: 0, active: 0, completed: 0, failed: 0 };
|
const agentRuns = data?.agentRuns ?? { total: 0, active: 0, completed: 0, failed: 0 };
|
||||||
|
const agentRunPieData = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: t("commandCenter.activity.agentRunsActive", "Active"), value: agentRuns.active },
|
||||||
|
{ label: t("commandCenter.activity.agentRunsCompleted", "Completed"), value: agentRuns.completed },
|
||||||
|
{ label: t("commandCenter.activity.agentRunsFailed", "Failed"), value: agentRuns.failed },
|
||||||
|
].filter((entry) => entry.value > 0),
|
||||||
|
[agentRuns.active, agentRuns.completed, agentRuns.failed, t],
|
||||||
|
);
|
||||||
const isEmpty =
|
const isEmpty =
|
||||||
!data ||
|
!data ||
|
||||||
(data.sessions === 0 &&
|
(data.sessions === 0 &&
|
||||||
@@ -98,6 +120,26 @@ export function ActivityArea({ range }: { range: DateRange }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{daily.length > 0 ? (
|
||||||
|
<div className="cc-area-section" data-testid="cc-activity-line">
|
||||||
|
<h3 className="cc-area-section-title">{t("commandCenter.activity.rechartsLine", "Activity trend")}</h3>
|
||||||
|
<RechartsLineChart
|
||||||
|
series={rechartsLineSeries}
|
||||||
|
ariaLabel={t("commandCenter.activity.rechartsLine", "Activity trend")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{agentRunPieData.length > 0 ? (
|
||||||
|
<div className="cc-area-section" data-testid="cc-activity-pie">
|
||||||
|
<h3 className="cc-area-section-title">{t("commandCenter.activity.agentRunOutcomeShare", "Agent run outcome share")}</h3>
|
||||||
|
<PieChart
|
||||||
|
data={agentRunPieData}
|
||||||
|
ariaLabel={t("commandCenter.activity.agentRunOutcomeShare", "Agent run outcome share")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="cc-area-section" data-testid="cc-activity-line-messages">
|
<div className="cc-area-section" data-testid="cc-activity-line-messages">
|
||||||
<h3 className="cc-area-section-title">{t("commandCenter.activity.messagesPerDay", "Messages / day")}</h3>
|
<h3 className="cc-area-section-title">{t("commandCenter.activity.messagesPerDay", "Messages / day")}</h3>
|
||||||
<LineChart
|
<LineChart
|
||||||
|
|||||||
@@ -3,10 +3,16 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { ProductivityAnalytics } from "@fusion/core";
|
import type { ProductivityAnalytics } from "@fusion/core";
|
||||||
import type { DateRange } from "../DateRangePicker";
|
import type { DateRange } from "../DateRangePicker";
|
||||||
import { Bar } from "../charts/Bar";
|
import { Bar } from "../charts/Bar";
|
||||||
|
import { PieChart } from "../charts/recharts";
|
||||||
import { AreaShell } from "./AreaShell";
|
import { AreaShell } from "./AreaShell";
|
||||||
import { useAnalyticsArea } from "./useAnalyticsArea";
|
import { useAnalyticsArea } from "./useAnalyticsArea";
|
||||||
import { formatCount } from "./areaShared";
|
import { formatCount } from "./areaShared";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CommandCenterCharts 2026-06-18-23:40:
|
||||||
|
ProductivityAnalytics exposes a categorical language distribution but no per-day throughput series. Add the real language pie from already-fetched data, preserve the bar/stat affordances, and document that a line chart is intentionally omitted until a genuine trend source exists.
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Productivity area. Per the plan's A5 framing, LOC and tool/file counts are
|
* Productivity area. Per the plan's A5 framing, LOC and tool/file counts are
|
||||||
* presented as *volume* proxies, kept visually distinct from outcome counters
|
* presented as *volume* proxies, kept visually distinct from outcome counters
|
||||||
@@ -34,6 +40,15 @@ export function ProductivityArea({ range }: { range: DateRange }) {
|
|||||||
[data?.byLanguage],
|
[data?.byLanguage],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const languagePieData = useMemo(
|
||||||
|
() =>
|
||||||
|
(data?.byLanguage ?? []).slice(0, 12).map((l) => ({
|
||||||
|
label: l.language,
|
||||||
|
value: l.count,
|
||||||
|
})),
|
||||||
|
[data?.byLanguage],
|
||||||
|
);
|
||||||
|
|
||||||
const isEmpty =
|
const isEmpty =
|
||||||
!data ||
|
!data ||
|
||||||
(data.modifiedFiles === 0 && data.commits === 0 && data.pullRequests === 0);
|
(data.modifiedFiles === 0 && data.commits === 0 && data.pullRequests === 0);
|
||||||
@@ -93,6 +108,18 @@ export function ProductivityArea({ range }: { range: DateRange }) {
|
|||||||
</h3>
|
</h3>
|
||||||
<Bar data={languageBars} ariaLabel={t("commandCenter.productivity.byLanguage", "Files by language")} />
|
<Bar data={languageBars} ariaLabel={t("commandCenter.productivity.byLanguage", "Files by language")} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{languagePieData.length > 0 ? (
|
||||||
|
<div className="cc-area-section" data-testid="cc-productivity-pie">
|
||||||
|
<h3 className="cc-area-section-title">
|
||||||
|
{t("commandCenter.productivity.languagePie", "Language share")}
|
||||||
|
</h3>
|
||||||
|
<PieChart
|
||||||
|
data={languagePieData}
|
||||||
|
ariaLabel={t("commandCenter.productivity.languagePie", "Language share")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</AreaShell>
|
</AreaShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
import type { DateRange } from "../DateRangePicker";
|
import type { DateRange } from "../DateRangePicker";
|
||||||
import { Bar } from "../charts/Bar";
|
import { Bar } from "../charts/Bar";
|
||||||
import { TokenSeriesChart } from "../charts/TokenSeriesChart";
|
import { TokenSeriesChart } from "../charts/TokenSeriesChart";
|
||||||
|
import { LineChart as RechartsLineChart, PieChart } from "../charts/recharts";
|
||||||
import { AreaShell } from "./AreaShell";
|
import { AreaShell } from "./AreaShell";
|
||||||
import { useAnalyticsArea } from "./useAnalyticsArea";
|
import { useAnalyticsArea } from "./useAnalyticsArea";
|
||||||
import { formatCost, formatCount } from "./areaShared";
|
import { formatCost, formatCount } from "./areaShared";
|
||||||
@@ -22,6 +23,11 @@ type SortKey = "key" | "totalTokens" | "cost";
|
|||||||
const TOKENS_LIVE_REFRESH_MS = 15_000;
|
const TOKENS_LIVE_REFRESH_MS = 15_000;
|
||||||
const GRANULARITIES: TokenTimeGranularity[] = ["hour", "day", "week"];
|
const GRANULARITIES: TokenTimeGranularity[] = ["hour", "day", "week"];
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CommandCenterCharts 2026-06-18-23:20:
|
||||||
|
The Tokens surface must add real pie and line charts from already-fetched token analytics only. Keep the existing bars, tables, granularity controls, loading/error/empty branches, and testids intact while the FN-6682 recharts wrappers provide token-themed, non-finite-safe visuals.
|
||||||
|
*/
|
||||||
|
|
||||||
function costSortValue(cost: CostResult): number {
|
function costSortValue(cost: CostResult): number {
|
||||||
return cost.unavailable || cost.usd === null ? -1 : cost.usd;
|
return cost.unavailable || cost.usd === null ? -1 : cost.usd;
|
||||||
}
|
}
|
||||||
@@ -96,6 +102,28 @@ export function TokensArea({ range }: { range: DateRange }) {
|
|||||||
[groups, t],
|
[groups, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pieData = useMemo(
|
||||||
|
() =>
|
||||||
|
[...groups]
|
||||||
|
.sort((a, b) => b.totalTokens - a.totalTokens)
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((g) => ({
|
||||||
|
label: g.key ?? t("commandCenter.tokens.unknownModel", "(unknown)"),
|
||||||
|
value: g.totalTokens,
|
||||||
|
})),
|
||||||
|
[groups, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const lineSeries = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: t("commandCenter.tokens.input", "Input"), values: series.map((point) => point.inputTokens) },
|
||||||
|
{ label: t("commandCenter.tokens.output", "Output"), values: series.map((point) => point.outputTokens) },
|
||||||
|
{ label: t("commandCenter.tokens.cached", "Cached"), values: series.map((point) => point.cachedTokens) },
|
||||||
|
{ label: t("commandCenter.tokens.total", "Total"), values: series.map((point) => point.totalTokens) },
|
||||||
|
],
|
||||||
|
[series, t],
|
||||||
|
);
|
||||||
|
|
||||||
function toggleSort(key: SortKey) {
|
function toggleSort(key: SortKey) {
|
||||||
if (key === sortKey) {
|
if (key === sortKey) {
|
||||||
setSortDir((d) => (d === 1 ? -1 : 1));
|
setSortDir((d) => (d === 1 ? -1 : 1));
|
||||||
@@ -165,11 +193,28 @@ export function TokensArea({ range }: { range: DateRange }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{series.length > 0 ? (
|
||||||
|
<div className="cc-area-section" data-testid="cc-tokens-line">
|
||||||
|
<h3 className="cc-area-section-title">{t("commandCenter.tokens.rechartsLine", "Tokens trend")}</h3>
|
||||||
|
<RechartsLineChart
|
||||||
|
series={lineSeries}
|
||||||
|
ariaLabel={t("commandCenter.tokens.rechartsLine", "Tokens trend")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="cc-area-section">
|
<div className="cc-area-section">
|
||||||
<h3 className="cc-area-section-title">{t("commandCenter.tokens.byModelChart", "Tokens by model")}</h3>
|
<h3 className="cc-area-section-title">{t("commandCenter.tokens.byModelChart", "Tokens by model")}</h3>
|
||||||
<Bar data={barData} ariaLabel={t("commandCenter.tokens.byModelChart", "Tokens by model")} />
|
<Bar data={barData} ariaLabel={t("commandCenter.tokens.byModelChart", "Tokens by model")} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{pieData.length > 0 ? (
|
||||||
|
<div className="cc-area-section" data-testid="cc-tokens-pie">
|
||||||
|
<h3 className="cc-area-section-title">{t("commandCenter.tokens.pieChart", "Token share by model")}</h3>
|
||||||
|
<PieChart data={pieData} ariaLabel={t("commandCenter.tokens.pieChart", "Token share by model")} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="cc-area-section">
|
<div className="cc-area-section">
|
||||||
<h3 className="cc-area-section-title">{t("commandCenter.tokens.tableTitle", "Per-model breakdown")}</h3>
|
<h3 className="cc-area-section-title">{t("commandCenter.tokens.tableTitle", "Per-model breakdown")}</h3>
|
||||||
<div className="cc-table-wrap">
|
<div className="cc-table-wrap">
|
||||||
|
|||||||
@@ -3,10 +3,16 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { ToolAnalytics } from "@fusion/core";
|
import type { ToolAnalytics } from "@fusion/core";
|
||||||
import type { DateRange } from "../DateRangePicker";
|
import type { DateRange } from "../DateRangePicker";
|
||||||
import { Bar } from "../charts/Bar";
|
import { Bar } from "../charts/Bar";
|
||||||
|
import { PieChart } from "../charts/recharts";
|
||||||
import { AreaShell } from "./AreaShell";
|
import { AreaShell } from "./AreaShell";
|
||||||
import { useAnalyticsArea } from "./useAnalyticsArea";
|
import { useAnalyticsArea } from "./useAnalyticsArea";
|
||||||
import { formatCount } from "./areaShared";
|
import { formatCount } from "./areaShared";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CommandCenterCharts 2026-06-18-23:29:
|
||||||
|
The Tools surface has categorical tool-call analytics but no per-day tool trend in ToolAnalytics. Add only the real category pie from already-fetched data and leave the existing bar/summary affordances unchanged; do not fabricate a line series.
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tools area: autonomy ratio readout + tool categories rendered as a bar chart
|
* Tools area: autonomy ratio readout + tool categories rendered as a bar chart
|
||||||
* sorted descending by count (the endpoint already returns `byCategory`
|
* sorted descending by count (the endpoint already returns `byCategory`
|
||||||
@@ -36,6 +42,15 @@ export function ToolsArea({ range }: { range: DateRange }) {
|
|||||||
[sortedCategories],
|
[sortedCategories],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pieData = useMemo(
|
||||||
|
() =>
|
||||||
|
sortedCategories.map((c) => ({
|
||||||
|
label: c.category,
|
||||||
|
value: c.count,
|
||||||
|
})),
|
||||||
|
[sortedCategories],
|
||||||
|
);
|
||||||
|
|
||||||
const isEmpty = !data || data.toolCalls === 0;
|
const isEmpty = !data || data.toolCalls === 0;
|
||||||
|
|
||||||
const ratioLabel = data
|
const ratioLabel = data
|
||||||
@@ -83,6 +98,13 @@ export function ToolsArea({ range }: { range: DateRange }) {
|
|||||||
<h3 className="cc-area-section-title">{t("commandCenter.tools.categoriesTitle", "Tool categories")}</h3>
|
<h3 className="cc-area-section-title">{t("commandCenter.tools.categoriesTitle", "Tool categories")}</h3>
|
||||||
<Bar data={barData} ariaLabel={t("commandCenter.tools.categoriesTitle", "Tool categories")} />
|
<Bar data={barData} ariaLabel={t("commandCenter.tools.categoriesTitle", "Tool categories")} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{pieData.length > 0 ? (
|
||||||
|
<div className="cc-area-section" data-testid="cc-tools-pie">
|
||||||
|
<h3 className="cc-area-section-title">{t("commandCenter.tools.pieChart", "Tool category share")}</h3>
|
||||||
|
<PieChart data={pieData} ariaLabel={t("commandCenter.tools.pieChart", "Tool category share")} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</AreaShell>
|
</AreaShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,10 @@ describe("ActivityArea", () => {
|
|||||||
expect(screen.getByTestId("cc-activity-agent-runs-completed").textContent).toContain("6");
|
expect(screen.getByTestId("cc-activity-agent-runs-completed").textContent).toContain("6");
|
||||||
expect(screen.getByTestId("cc-activity-agent-runs-failed").textContent).toContain("1");
|
expect(screen.getByTestId("cc-activity-agent-runs-failed").textContent).toContain("1");
|
||||||
expect(screen.getByTestId("cc-activity-stickiness").textContent).toContain("50%");
|
expect(screen.getByTestId("cc-activity-stickiness").textContent).toContain("50%");
|
||||||
|
expect(screen.getByTestId("cc-activity-line")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-activity-pie")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("img", { name: "Activity trend" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("img", { name: "Agent run outcome share" })).toBeTruthy();
|
||||||
expect(screen.getByTestId("cc-activity-line-messages")).toBeTruthy();
|
expect(screen.getByTestId("cc-activity-line-messages")).toBeTruthy();
|
||||||
expect(screen.getByTestId("cc-activity-line-agents")).toBeTruthy();
|
expect(screen.getByTestId("cc-activity-line-agents")).toBeTruthy();
|
||||||
expect(screen.getByTestId("cc-activity-line-nodes")).toBeTruthy();
|
expect(screen.getByTestId("cc-activity-line-nodes")).toBeTruthy();
|
||||||
@@ -270,10 +274,32 @@ describe("ActivityArea", () => {
|
|||||||
expect(screen.queryByTestId("cc-area-activity-empty")).toBeNull();
|
expect(screen.queryByTestId("cc-area-activity-empty")).toBeNull();
|
||||||
expect(screen.getByTestId("cc-activity-agent-runs").textContent).toContain("2");
|
expect(screen.getByTestId("cc-activity-agent-runs").textContent).toContain("2");
|
||||||
expect(screen.getByTestId("cc-activity-agent-runs-sparkline")).toBeTruthy();
|
expect(screen.getByTestId("cc-activity-agent-runs-sparkline")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-activity-line")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-activity-pie")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the empty state for zero activity without empty chart shells", async () => {
|
it("keeps activity recharts safe for single-item and non-finite data", async () => {
|
||||||
apiMock.mockResolvedValue({
|
apiMock.mockResolvedValue({
|
||||||
|
...activityFixture(),
|
||||||
|
sessions: 1,
|
||||||
|
messages: 1,
|
||||||
|
activeNodes: 1,
|
||||||
|
activeAgents: 1,
|
||||||
|
agentRuns: { total: 1, active: 0, completed: 1, failed: 0 },
|
||||||
|
daily: [{ day: "2026-06-08", messages: Number.NaN, activeNodes: 1, activeAgents: Number.POSITIVE_INFINITY, agentRuns: -1 }],
|
||||||
|
});
|
||||||
|
render(<ActivityArea range={range7d} />);
|
||||||
|
|
||||||
|
await screen.findByTestId("cc-area-activity");
|
||||||
|
expect(screen.getByTestId("cc-activity-line")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-activity-pie")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-activity-line").textContent).not.toContain("NaN");
|
||||||
|
expect(screen.getByTestId("cc-activity-line").textContent).not.toContain("Infinity");
|
||||||
|
expect(screen.getByTestId("cc-activity-pie").textContent).not.toContain("NaN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders empty, loading, and error states without activity recharts shells", async () => {
|
||||||
|
apiMock.mockResolvedValueOnce({
|
||||||
...activityFixture(),
|
...activityFixture(),
|
||||||
sessions: 0,
|
sessions: 0,
|
||||||
messages: 0,
|
messages: 0,
|
||||||
@@ -283,14 +309,30 @@ describe("ActivityArea", () => {
|
|||||||
daily: [],
|
daily: [],
|
||||||
stickiness: 0,
|
stickiness: 0,
|
||||||
});
|
});
|
||||||
render(<ActivityArea range={range7d} />);
|
const empty = render(<ActivityArea range={range7d} />);
|
||||||
|
|
||||||
await screen.findByTestId("cc-area-activity-empty");
|
await screen.findByTestId("cc-area-activity-empty");
|
||||||
|
expect(screen.queryByTestId("cc-activity-line")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-activity-pie")).toBeNull();
|
||||||
expect(screen.queryByTestId("cc-activity-line-messages")).toBeNull();
|
expect(screen.queryByTestId("cc-activity-line-messages")).toBeNull();
|
||||||
expect(screen.queryByTestId("cc-activity-line-agents")).toBeNull();
|
expect(screen.queryByTestId("cc-activity-line-agents")).toBeNull();
|
||||||
expect(screen.queryByTestId("cc-activity-line-nodes")).toBeNull();
|
expect(screen.queryByTestId("cc-activity-line-nodes")).toBeNull();
|
||||||
expect(screen.queryByTestId("cc-activity-agent-runs-sparkline")).toBeNull();
|
expect(screen.queryByTestId("cc-activity-agent-runs-sparkline")).toBeNull();
|
||||||
expect(screen.queryByTestId("cc-activity-line-throughput")).toBeNull();
|
expect(screen.queryByTestId("cc-activity-line-throughput")).toBeNull();
|
||||||
|
empty.unmount();
|
||||||
|
|
||||||
|
apiMock.mockImplementationOnce(() => new Promise(() => undefined));
|
||||||
|
const pending = render(<ActivityArea range={range7d} />);
|
||||||
|
expect(screen.getByTestId("cc-area-activity-loading")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("cc-activity-line")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-activity-pie")).toBeNull();
|
||||||
|
pending.unmount();
|
||||||
|
|
||||||
|
apiMock.mockRejectedValueOnce(new Error("activity failed"));
|
||||||
|
render(<ActivityArea range={range7d} />);
|
||||||
|
await screen.findByTestId("cc-area-activity-error");
|
||||||
|
expect(screen.queryByTestId("cc-activity-line")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-activity-pie")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("polls activity while mounted, keeps content during refresh, and clears the interval on unmount", async () => {
|
it("polls activity while mounted, keeps content during refresh, and clears the interval on unmount", async () => {
|
||||||
@@ -341,6 +383,10 @@ describe("TokensArea", () => {
|
|||||||
expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,500");
|
expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,500");
|
||||||
expect(screen.getByTestId("cc-tokens-cost").textContent).toContain("$12.50");
|
expect(screen.getByTestId("cc-tokens-cost").textContent).toContain("$12.50");
|
||||||
expect(screen.getByTestId("cc-token-series-chart")).toBeTruthy();
|
expect(screen.getByTestId("cc-token-series-chart")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-tokens-line")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-tokens-pie")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("img", { name: "Tokens trend" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("img", { name: "Token share by model" })).toBeTruthy();
|
||||||
expect(screen.getByLabelText("2026-06-09: 900")).toBeTruthy();
|
expect(screen.getByLabelText("2026-06-09: 900")).toBeTruthy();
|
||||||
expect(screen.getByTestId("cc-tokens-row-gpt-4o")).toBeTruthy();
|
expect(screen.getByTestId("cc-tokens-row-gpt-4o")).toBeTruthy();
|
||||||
expect(screen.getByTestId("cc-tokens-row-claude-sonnet")).toBeTruthy();
|
expect(screen.getByTestId("cc-tokens-row-claude-sonnet")).toBeTruthy();
|
||||||
@@ -393,8 +439,8 @@ describe("TokensArea", () => {
|
|||||||
expect(lastCall).toContain("from=2026-05-01");
|
expect(lastCall).toContain("from=2026-05-01");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the empty state with no token data (no crash)", async () => {
|
it("renders empty, loading, and error states without token recharts shells", async () => {
|
||||||
apiMock.mockResolvedValue({
|
apiMock.mockResolvedValueOnce({
|
||||||
from: null,
|
from: null,
|
||||||
to: null,
|
to: null,
|
||||||
groupBy: "model",
|
groupBy: "model",
|
||||||
@@ -403,8 +449,56 @@ describe("TokensArea", () => {
|
|||||||
groups: [],
|
groups: [],
|
||||||
series: [],
|
series: [],
|
||||||
});
|
});
|
||||||
render(<TokensArea range={range7d} />);
|
const empty = render(<TokensArea range={range7d} />);
|
||||||
await screen.findByTestId("cc-area-tokens-empty");
|
await screen.findByTestId("cc-area-tokens-empty");
|
||||||
|
expect(screen.queryByTestId("cc-tokens-line")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-tokens-pie")).toBeNull();
|
||||||
|
empty.unmount();
|
||||||
|
|
||||||
|
apiMock.mockImplementationOnce(() => new Promise(() => undefined));
|
||||||
|
const pending = render(<TokensArea range={range7d} />);
|
||||||
|
expect(screen.getByTestId("cc-area-tokens-loading")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("cc-tokens-line")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-tokens-pie")).toBeNull();
|
||||||
|
pending.unmount();
|
||||||
|
|
||||||
|
apiMock.mockRejectedValueOnce(new Error("tokens failed"));
|
||||||
|
render(<TokensArea range={range7d} />);
|
||||||
|
await screen.findByTestId("cc-area-tokens-error");
|
||||||
|
expect(screen.queryByTestId("cc-tokens-line")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-tokens-pie")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders token recharts with a single valid item", async () => {
|
||||||
|
apiMock.mockResolvedValue({
|
||||||
|
...tokenFixture(),
|
||||||
|
groups: [tokenFixture().groups[0]],
|
||||||
|
series: [tokenFixture().series[0]],
|
||||||
|
});
|
||||||
|
render(<TokensArea range={range7d} />);
|
||||||
|
|
||||||
|
await screen.findByTestId("cc-area-tokens");
|
||||||
|
expect(screen.getByTestId("cc-tokens-line")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-tokens-pie")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-tokens-line").textContent).not.toContain("NaN");
|
||||||
|
expect(screen.getByTestId("cc-tokens-pie").textContent).not.toContain("NaN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps token recharts safe for non-finite data", async () => {
|
||||||
|
apiMock.mockResolvedValue({
|
||||||
|
...tokenFixture(),
|
||||||
|
totals: { ...tokenFixture().totals, totalTokens: 1 },
|
||||||
|
groups: [{ ...tokenFixture().groups[0], key: "broken-model", totalTokens: Number.NaN }],
|
||||||
|
series: [{ ...tokenFixture().series[0], inputTokens: Number.NaN, outputTokens: Number.POSITIVE_INFINITY, cachedTokens: -1, totalTokens: Number.NaN }],
|
||||||
|
});
|
||||||
|
render(<TokensArea range={range7d} />);
|
||||||
|
|
||||||
|
await screen.findByTestId("cc-area-tokens");
|
||||||
|
expect(screen.getByTestId("cc-tokens-line")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-tokens-pie")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-tokens-line").textContent).not.toContain("NaN");
|
||||||
|
expect(screen.getByTestId("cc-tokens-line").textContent).not.toContain("Infinity");
|
||||||
|
expect(screen.getByTestId("cc-tokens-pie").textContent).not.toContain("NaN");
|
||||||
});
|
});
|
||||||
|
|
||||||
// The critical SWR-identity regression: a revalidation that returns
|
// The critical SWR-identity regression: a revalidation that returns
|
||||||
@@ -473,6 +567,8 @@ describe("ToolsArea", () => {
|
|||||||
render(<ToolsArea range={range7d} />);
|
render(<ToolsArea range={range7d} />);
|
||||||
await screen.findByTestId("cc-area-tools");
|
await screen.findByTestId("cc-area-tools");
|
||||||
expect(screen.getByTestId("cc-tools-autonomy").textContent).toContain("10.0:1");
|
expect(screen.getByTestId("cc-tools-autonomy").textContent).toContain("10.0:1");
|
||||||
|
expect(screen.getByTestId("cc-tools-pie")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("img", { name: "Tool category share" })).toBeTruthy();
|
||||||
|
|
||||||
// Sorted descending by count: read (20) first.
|
// Sorted descending by count: read (20) first.
|
||||||
const chart = screen.getByRole("list", { name: "Tool categories" });
|
const chart = screen.getByRole("list", { name: "Tool categories" });
|
||||||
@@ -480,8 +576,8 @@ describe("ToolsArea", () => {
|
|||||||
expect(labels[0]).toBe("read: 20");
|
expect(labels[0]).toBe("read: 20");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the empty state when there are no tool calls", async () => {
|
it("renders empty, loading, and error states without tools pie shells", async () => {
|
||||||
apiMock.mockResolvedValue({
|
apiMock.mockResolvedValueOnce({
|
||||||
from: null,
|
from: null,
|
||||||
to: null,
|
to: null,
|
||||||
toolCalls: 0,
|
toolCalls: 0,
|
||||||
@@ -491,8 +587,58 @@ describe("ToolsArea", () => {
|
|||||||
autonomyRatio: 0,
|
autonomyRatio: 0,
|
||||||
fullyAutonomous: true,
|
fullyAutonomous: true,
|
||||||
});
|
});
|
||||||
render(<ToolsArea range={range7d} />);
|
const empty = render(<ToolsArea range={range7d} />);
|
||||||
await screen.findByTestId("cc-area-tools-empty");
|
await screen.findByTestId("cc-area-tools-empty");
|
||||||
|
expect(screen.queryByTestId("cc-tools-pie")).toBeNull();
|
||||||
|
empty.unmount();
|
||||||
|
|
||||||
|
apiMock.mockImplementationOnce(() => new Promise(() => undefined));
|
||||||
|
const pending = render(<ToolsArea range={range7d} />);
|
||||||
|
expect(screen.getByTestId("cc-area-tools-loading")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("cc-tools-pie")).toBeNull();
|
||||||
|
pending.unmount();
|
||||||
|
|
||||||
|
apiMock.mockRejectedValueOnce(new Error("tools failed"));
|
||||||
|
render(<ToolsArea range={range7d} />);
|
||||||
|
await screen.findByTestId("cc-area-tools-error");
|
||||||
|
expect(screen.queryByTestId("cc-tools-pie")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the tools pie with a single valid category", async () => {
|
||||||
|
apiMock.mockResolvedValue({
|
||||||
|
from: "2026-06-08",
|
||||||
|
to: null,
|
||||||
|
toolCalls: 1,
|
||||||
|
byCategory: [{ category: "edit", count: 1 }],
|
||||||
|
sessions: 1,
|
||||||
|
interventions: { approvals: 0, userSteers: 0, total: 0 },
|
||||||
|
autonomyRatio: 1,
|
||||||
|
fullyAutonomous: true,
|
||||||
|
});
|
||||||
|
render(<ToolsArea range={range7d} />);
|
||||||
|
|
||||||
|
await screen.findByTestId("cc-area-tools");
|
||||||
|
expect(screen.getByTestId("cc-tools-pie")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-tools-pie").textContent).not.toContain("NaN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the tools pie safe for non-finite category data", async () => {
|
||||||
|
apiMock.mockResolvedValue({
|
||||||
|
from: "2026-06-08",
|
||||||
|
to: null,
|
||||||
|
toolCalls: 1,
|
||||||
|
byCategory: [{ category: "broken", count: Number.NaN }],
|
||||||
|
sessions: 1,
|
||||||
|
interventions: { approvals: 0, userSteers: 0, total: 0 },
|
||||||
|
autonomyRatio: 1,
|
||||||
|
fullyAutonomous: true,
|
||||||
|
});
|
||||||
|
render(<ToolsArea range={range7d} />);
|
||||||
|
|
||||||
|
await screen.findByTestId("cc-area-tools");
|
||||||
|
expect(screen.getByTestId("cc-tools-pie")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-tools-pie").textContent).not.toContain("NaN");
|
||||||
|
expect(screen.getByTestId("cc-tools-pie").textContent).not.toContain("Infinity");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -515,6 +661,8 @@ describe("ProductivityArea", () => {
|
|||||||
// The commits outcome counter still shows a real number.
|
// The commits outcome counter still shows a real number.
|
||||||
expect(screen.getByTestId("cc-productivity-commits").textContent).toContain("4");
|
expect(screen.getByTestId("cc-productivity-commits").textContent).toContain("4");
|
||||||
expect(screen.getByRole("list", { name: "Files by language" })).toBeTruthy();
|
expect(screen.getByRole("list", { name: "Files by language" })).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-productivity-pie")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("img", { name: "Language share" })).toBeTruthy();
|
||||||
expect(screen.getByTestId("cc-area-productivity").textContent).not.toContain("NaN");
|
expect(screen.getByTestId("cc-area-productivity").textContent).not.toContain("NaN");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -531,6 +679,7 @@ describe("ProductivityArea", () => {
|
|||||||
const { unmount } = render(<ProductivityArea range={range7d} />);
|
const { unmount } = render(<ProductivityArea range={range7d} />);
|
||||||
await screen.findByTestId("cc-area-productivity-empty");
|
await screen.findByTestId("cc-area-productivity-empty");
|
||||||
expect(screen.queryByRole("list", { name: "Files by language" })).toBeNull();
|
expect(screen.queryByRole("list", { name: "Files by language" })).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-productivity-pie")).toBeNull();
|
||||||
unmount();
|
unmount();
|
||||||
|
|
||||||
apiMock.mockImplementationOnce(() => new Promise(() => undefined));
|
apiMock.mockImplementationOnce(() => new Promise(() => undefined));
|
||||||
@@ -542,6 +691,25 @@ describe("ProductivityArea", () => {
|
|||||||
render(<ProductivityArea range={range7d} />);
|
render(<ProductivityArea range={range7d} />);
|
||||||
await screen.findByTestId("cc-area-productivity-error");
|
await screen.findByTestId("cc-area-productivity-error");
|
||||||
expect(screen.getByTestId("cc-area-productivity-error").textContent).toContain("productivity failed");
|
expect(screen.getByTestId("cc-area-productivity-error").textContent).toContain("productivity failed");
|
||||||
|
expect(screen.queryByTestId("cc-productivity-pie")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the productivity pie safe for single-item and non-finite language data", async () => {
|
||||||
|
apiMock.mockResolvedValue({
|
||||||
|
from: "2026-06-08",
|
||||||
|
to: null,
|
||||||
|
modifiedFiles: 1,
|
||||||
|
byLanguage: [{ language: "broken", count: Number.NaN }],
|
||||||
|
commits: 0,
|
||||||
|
pullRequests: 0,
|
||||||
|
loc: { value: null, unavailable: true },
|
||||||
|
});
|
||||||
|
render(<ProductivityArea range={range7d} />);
|
||||||
|
|
||||||
|
await screen.findByTestId("cc-area-productivity");
|
||||||
|
expect(screen.getByTestId("cc-productivity-pie")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-productivity-pie").textContent).not.toContain("NaN");
|
||||||
|
expect(screen.getByTestId("cc-productivity-pie").textContent).not.toContain("Infinity");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,17 @@ FN-6680 preserves the FN-6664 section rhythm while adding real-layout shrink gua
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CommandCenterCharts 2026-06-18-23:24:
|
||||||
|
FN-6683 recharts wrappers must stay responsive without creating an inner scroll owner or fixed pixel box. Give chart sections a token-sized block axis so ResponsiveContainer can measure while .cc-tabpanel remains the vertical scroll container on mobile.
|
||||||
|
*/
|
||||||
|
.cc-area .cc-recharts-chart,
|
||||||
|
.cc-area .cc-recharts-empty {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: calc(var(--space-20) * 3);
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reuse the shell's stat-grid/stat-card look. */
|
/* Reuse the shell's stat-grid/stat-card look. */
|
||||||
.cc-area .cc-stat-grid {
|
.cc-area .cc-stat-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Reference in New Issue
Block a user