FN-6720: add productivity task duration stats

Adds completed-task active-duration analytics to the Command Center Productivity surface.\n\n- Aggregate average, median, p90, total, and completed-task duration metrics from done tasks with recorded active time.\n- Render duration stat cards with unavailable dash sentinels and export matching CSV rows.\n- Cover analytics, dashboard, route, CSV, mobile/tablet layout, and documentation behavior.\n\nFiles changed:\n .changeset/soft-badgers-measure.md                 |   5 +\n docs/architecture.md                               |   4 +-\n docs/dashboard-guide.md                            |   2 +-\n docs/storage.md                                    |   2 +\n .../src/__tests__/productivity-analytics.test.ts   | 105 ++++++++++++++++++++-\n packages/core/src/productivity-analytics.ts        |  83 +++++++++++++++-\n .../__tests__/CommandCenter.mobile-scroll.test.tsx |  16 ++++\n .../__tests__/CommandCenter.tablet-layout.test.tsx |  19 ++++\n .../command-center/areas/ProductivityArea.tsx      |  65 ++++++++++++-\n .../command-center/areas/__tests__/areas.test.tsx  |  65 ++++++++++++-\n .../components/command-center/areas/areaShared.ts  |  12 +++\n .../src/__tests__/command-center-csv.test.ts       |   8 ++\n .../register-command-center-routes.test.ts         |  22 +++++\n packages/dashboard/src/command-center-csv.ts       |   5 +\n 14 files changed, 404 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-6720

Fusion-Task-Lineage: aa0980eb-d156-4dca-b6b8-1788570c166b
This commit is contained in:
gsxdsm
2026-06-19 17:25:35 -07:00
parent 711bf3e1b8
commit 5117944371
14 changed files with 404 additions and 9 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add Command Center Productivity task-duration analytics, dashboard stat cards, and CSV export rows for completed-task active execution time.

View File

@@ -856,7 +856,7 @@ Operator setup + troubleshooting guide: **[Remote Access runbook](./remote-acces
Key server capabilities:
- REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings
- System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry (including app CPU percentage and host memory rendered as numeric values, radial gauges, and trend sparklines in the Command Center System area), task/agent aggregates, and manual vitest process termination
- Command Center analytics APIs (`GET /api/command-center/tokens`, `/tools`, `/activity`, `/productivity`, `/team`, `/github`, `/signals`, `/plugin-activations`, `/live`) are project-scoped dashboard routes. `/productivity` reads Lines changed from nullable `task_commit_associations.additions`/`deletions` merge-time diff stats, derives estimated `hoursSaved` from that LOC via the exported `HUMAN_LINES_PER_HOUR` rate, and keeps the unavailable sentinel for both fields when no in-range association has stats. `/signals` aggregates real local `incidents` rows for total/open/resolved counts, MTTR, and source/severity/status breakdowns and returns honest empty/unavailable sentinels instead of synthetic signal volume. `/plugin-activations` aggregates persisted plugin/extension load events for the selected range and returns unavailable when no rows exist instead of treating missing history as zero activations.
- Command Center analytics APIs (`GET /api/command-center/tokens`, `/tools`, `/activity`, `/productivity`, `/team`, `/github`, `/signals`, `/plugin-activations`, `/live`) are project-scoped dashboard routes. `/productivity` reads Lines changed from nullable `task_commit_associations.additions`/`deletions` merge-time diff stats, derives estimated `hoursSaved` from that LOC via the exported `HUMAN_LINES_PER_HOUR` rate, and keeps the unavailable sentinel for both fields when no in-range association has stats. Its `taskDuration` payload aggregates done tasks whose `executionCompletedAt` falls in the selected range, using positive `tasks.cumulativeActiveMs` values for completed count, average, median, p90, and total active execution time; missing qualifying durations remain unavailable rather than zero. `/signals` aggregates real local `incidents` rows for total/open/resolved counts, MTTR, and source/severity/status breakdowns and returns honest empty/unavailable sentinels instead of synthetic signal volume. `/plugin-activations` aggregates persisted plugin/extension load events for the selected range and returns unavailable when no rows exist instead of treating missing history as zero activations.
- Remote access APIs (`/api/remote/*`) for provider config, activation, tunnel lifecycle, status, token issuance, authenticated URL generation, and QR payload generation
- Operational runbook (prereqs/security/troubleshooting): [`docs/remote-access.md`](./remote-access.md)
- `/api/remote/tunnel/start`, `/api/remote/tunnel/stop`, and `/api/remote/tunnel/kill-external` cover tunnel lifecycle and external funnel cleanup.
@@ -1461,6 +1461,8 @@ Dashboard session-diff route registration (`packages/dashboard/src/routes/regist
Commit associations also carry optional `additions`/`deletions` shortstat counts captured by merge paths. These nullable fields are the Command Center Productivity LOC source: analytics sum additions + deletions only when at least one in-range row has stats, derive estimated human hours saved as `round((additions + deletions) / HUMAN_LINES_PER_HOUR, 1)`, and preserve the `—` unavailable sentinel for both LOC and hours saved when all matching rows are `NULL` so unknown historical data is never rendered as `0`. The hours-saved field is a conservative estimate, not exact time tracking.
Command Center Productivity task-duration stats use task rows, not commit rows: done tasks completed in range (`executionCompletedAt`) contribute when `cumulativeActiveMs > 0`. The aggregator computes completed count plus average, median, p90, and total active execution milliseconds; if no qualifying task exists, the duration metrics use the same unavailable `—` contract instead of reporting `0`.
### Done-task files-changed sources of truth
Done-task file-count surfaces intentionally distinguish three data sources:

View File

@@ -669,7 +669,7 @@ Features:
- **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. 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 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. 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.
- **Productivity** separates outcome counters (commits and pull requests), task-duration stats, and volume proxies such as modified files, lines changed, and files by language. The task-duration block counts done tasks completed in the selected range and shows average, median, p90, and total active execution time from `cumulativeActiveMs`; when no qualifying duration data exists, duration values render the unavailable `—` sentinel rather than `0`. 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, and adds a real token-share pie from the same per-agent token totals. 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. Team has no per-day analytics series today, so it intentionally does not render a line chart or fabricate a trend. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users.
- **Ecosystem** shows active model breadth, per-model task activity, and real plugin activations for the selected range. Plugin activation counts come from project-scoped plugin/extension load events via `/api/command-center/plugin-activations`; if no activation rows exist in range, the metric renders unavailable (`—`) rather than fabricating zero. The tab still reuses the tokens analytics endpoint grouped by model, adds a task-share-by-model pie from `TokenAnalytics.groups`, and renders a tokens/tasks trend line when `TokenAnalytics.series` buckets are present; if series buckets are absent, no synthetic trend is shown.
- **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, a filed-vs-fixed pie, a filed/fixed recharts trend line, existing daily sparklines, and a by-repository bar breakdown.

View File

@@ -395,6 +395,8 @@ The `tasks.tokenUsage*` columns store cumulative per-task token usage for analyt
The nullable `tasks.tokenUsagePerModel` JSON column (migration 125) stores the per-task, per-runtime-model breakdown behind those cumulative totals. Each bucket records provider/model, token counts, and first/last use timestamps. Command Center model/provider analytics expand these buckets so multi-model tasks appear under every model they actually used; task-level totals, cost, time series, node grouping, and agent grouping still read the top-level aggregate so grand `nTasks` is not double-counted. Empty, missing, or malformed per-model JSON falls back to the legacy single-snapshot grouping path.
The `task_commit_associations.additions` and `task_commit_associations.deletions` columns (migration 123) store nullable merge-time git shortstat counts for the associated commit. Command Center Productivity uses `SUM(additions + deletions)` as the Lines changed source when at least one in-range association has non-null stats, then derives estimated `hoursSaved` as `round(loc / HUMAN_LINES_PER_HOUR, 1)`. `NULL` means stats were unknown or unavailable for that association, not zero; ranges with no non-null stats keep the unavailable `—` sentinel for both LOC and hours saved instead of reporting `0`.
The `tasks.cumulativeActiveMs` and `tasks.executionCompletedAt` columns are the Command Center Productivity task-duration source. Duration analytics select `column = 'done'` tasks completed in the requested range (`executionCompletedAt`) and include only positive `cumulativeActiveMs` values, then compute completed count, average, median, p90, and total active execution time. Missing, zero, or historical untracked duration values remain unavailable (`—`) rather than being serialized or rendered as `0`.
| `config` | Single-row project configuration (`nextId`, settings payload, workflow step counters). |
| `workflow_steps` | Workflow step definitions (`prompt`/`script`) with phase, template metadata, and model overrides. |
| `activityLog` | Per-project activity/event log with timestamp/type/task indexes. |

View File

@@ -14,6 +14,30 @@ function insertTaskWithFiles(db: Database, id: string, files: string[], updatedA
).run(id, updatedAt, updatedAt, JSON.stringify(files));
}
function insertCompletedTask(
db: Database,
id: string,
opts: {
cumulativeActiveMs?: number | null;
executionCompletedAt: string | null;
column?: string;
},
): void {
const createdAt = opts.executionCompletedAt ?? "2026-03-01T00:00:00.000Z";
db.prepare(
`INSERT INTO tasks
(id, description, "column", createdAt, updatedAt, cumulativeActiveMs, executionCompletedAt)
VALUES (?, 'desc', ?, ?, ?, ?, ?)`,
).run(
id,
opts.column ?? "done",
createdAt,
createdAt,
opts.cumulativeActiveMs ?? null,
opts.executionCompletedAt,
);
}
function insertCommit(
db: Database,
id: string,
@@ -129,20 +153,99 @@ describe("productivity-analytics", () => {
});
});
it("computes completed-task duration stats for done tasks completed in range", () => {
insertCompletedTask(db, "d1", { cumulativeActiveMs: 1_000, executionCompletedAt: "2026-03-01T00:00:00.000Z" });
insertCompletedTask(db, "d2", { cumulativeActiveMs: 2_000, executionCompletedAt: "2026-03-02T00:00:00.000Z" });
insertCompletedTask(db, "d3", { cumulativeActiveMs: 3_000, executionCompletedAt: "2026-03-03T00:00:00.000Z" });
insertCompletedTask(db, "d4", { cumulativeActiveMs: 4_000, executionCompletedAt: "2026-03-04T00:00:00.000Z" });
const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" });
expect(result.taskDuration).toEqual({
completedTasks: 4,
averageMs: 2_500,
medianMs: 2_500,
p90Ms: 4_000,
totalMs: 10_000,
unavailable: false,
});
});
it("excludes completed-task durations outside the executionCompletedAt range", () => {
insertCompletedTask(db, "before", { cumulativeActiveMs: 9_000, executionCompletedAt: "2026-02-28T23:59:59.999Z" });
insertCompletedTask(db, "inside", { cumulativeActiveMs: 2_000, executionCompletedAt: "2026-03-01T00:00:00.000Z" });
insertCompletedTask(db, "after", { cumulativeActiveMs: 8_000, executionCompletedAt: "2026-04-01T00:00:00.000Z" });
const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T23:59:59.999Z" });
expect(result.taskDuration).toEqual({
completedTasks: 1,
averageMs: 2_000,
medianMs: 2_000,
p90Ms: 2_000,
totalMs: 2_000,
unavailable: false,
});
});
it("excludes non-done tasks and null or zero cumulativeActiveMs durations", () => {
insertCompletedTask(db, "todo", { cumulativeActiveMs: 1_000, executionCompletedAt: "2026-03-01T00:00:00.000Z", column: "todo" });
insertCompletedTask(db, "null-duration", { cumulativeActiveMs: null, executionCompletedAt: "2026-03-02T00:00:00.000Z" });
insertCompletedTask(db, "zero-duration", { cumulativeActiveMs: 0, executionCompletedAt: "2026-03-03T00:00:00.000Z" });
insertCompletedTask(db, "valid", { cumulativeActiveMs: 5_000, executionCompletedAt: "2026-03-04T00:00:00.000Z" });
const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" });
expect(result.taskDuration).toEqual({
completedTasks: 1,
averageMs: 5_000,
medianMs: 5_000,
p90Ms: 5_000,
totalMs: 5_000,
unavailable: false,
});
});
it("reports task duration as unavailable, never zero, when no qualifying durations exist", () => {
insertCompletedTask(db, "zero-duration", { cumulativeActiveMs: 0, executionCompletedAt: "2026-03-01T00:00:00.000Z" });
insertCompletedTask(db, "todo", { cumulativeActiveMs: 1_000, executionCompletedAt: "2026-03-02T00:00:00.000Z", column: "todo" });
const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" });
expect(result.taskDuration).toEqual({
completedTasks: 0,
averageMs: null,
medianMs: null,
p90Ms: null,
totalMs: null,
unavailable: true,
});
expect(result.taskDuration.averageMs).not.toBe(0);
expect(result.taskDuration.medianMs).not.toBe(0);
expect(result.taskDuration.p90Ms).not.toBe(0);
expect(result.taskDuration.totalMs).not.toBe(0);
});
it("empty range returns zeroed structures, not nulls", () => {
insertTaskWithFiles(db, "t1", ["src/a.ts"], "2026-03-01T00:00:00.000Z");
insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z");
insertPr(db, "pr1", Date.parse("2026-03-01T00:00:00.000Z"));
insertCompletedTask(db, "d1", { cumulativeActiveMs: 1_000, executionCompletedAt: "2026-03-01T00:00:00.000Z" });
const result = aggregateProductivityAnalytics(db, { from: "2027-01-01T00:00:00.000Z", to: "2027-12-31T00:00:00.000Z" });
expect(result.modifiedFiles).toBe(0);
expect(result.byLanguage).toEqual([]);
expect(result.commits).toBe(0);
expect(result.pullRequests).toBe(0);
// LOC and derived hours are unavailable regardless of range.
// LOC, derived hours, and task duration are unavailable regardless of range.
expect(result.loc).toEqual({ value: null, unavailable: true });
expect(result.hoursSaved).toEqual({ value: null, unavailable: true });
expect(result.hoursSaved.value).not.toBe(0);
expect(result.taskDuration).toEqual({
completedTasks: 0,
averageMs: null,
medianMs: null,
p90Ms: null,
totalMs: null,
unavailable: true,
});
expect(result.taskDuration.totalMs).not.toBe(0);
});
it("includes a boundary task exactly at `from`", () => {

View File

@@ -17,7 +17,8 @@ import type { Database } from "./db.js";
*
* Inclusivity: `from`/`to` bounds are inclusive. Tasks are filtered by
* `updatedAt` (the last time the task — and therefore its modifiedFiles — was
* touched); commit associations by `authoredAt`; PRs by `createdAt`.
* touched); completed-task durations by `executionCompletedAt`; commit
* associations by `authoredAt`; PRs by `createdAt`.
*/
/*
@@ -59,6 +60,19 @@ export interface HoursSavedSummary {
unavailable: boolean;
}
/**
* FNXC:CommandCenterProductivity 2026-06-19-12:00:
* Task-duration productivity stats are derived from `tasks.cumulativeActiveMs` for done tasks completed in the selected range. Missing qualifying durations are unavailable, not zero, so old or untracked tasks do not read as instant work.
*/
export interface TaskDurationSummary {
completedTasks: number;
averageMs: number | null;
medianMs: number | null;
p90Ms: number | null;
totalMs: number | null;
unavailable: boolean;
}
export interface ProductivityAnalytics {
from: string | null;
to: string | null;
@@ -74,6 +88,8 @@ export interface ProductivityAnalytics {
loc: LocSummary;
/** Estimated human-hours equivalent derived from `loc` when LOC is available. */
hoursSaved: HoursSavedSummary;
/** Active execution duration for done tasks completed in range. */
taskDuration: TaskDurationSummary;
}
interface CountRow {
@@ -91,6 +107,10 @@ interface ModifiedFilesRow {
modifiedFiles: string | null;
}
interface TaskDurationRow {
cumulativeActiveMs: number;
}
/** Extract a coarse language key from a file path (its lowercased extension). */
function languageOf(path: string): string {
const base = path.split("/").pop() ?? path;
@@ -99,10 +119,26 @@ function languageOf(path: string): string {
return base.slice(dot + 1).toLowerCase();
}
function median(sortedValues: readonly number[]): number | null {
if (sortedValues.length === 0) return null;
const middle = Math.floor(sortedValues.length / 2);
if (sortedValues.length % 2 === 1) return sortedValues[middle] ?? null;
return ((sortedValues[middle - 1] ?? 0) + (sortedValues[middle] ?? 0)) / 2;
}
function nearestRankPercentile(sortedValues: readonly number[], percentile: number): number | null {
if (sortedValues.length === 0) return null;
const index = Math.min(
sortedValues.length - 1,
Math.max(0, Math.ceil(percentile * sortedValues.length) - 1),
);
return sortedValues[index] ?? null;
}
/**
* Aggregate productivity metrics over a date range. Empty range yields zeroed
* structures (not nulls); LOC remains the unavailable sentinel unless at least
* one in-range commit association carries diff stats.
* structures (not nulls); LOC and task duration remain unavailable sentinels
* unless at least one in-range row carries real source data.
*/
export function aggregateProductivityAnalytics(
db: Database,
@@ -181,6 +217,46 @@ export function aggregateProductivityAnalytics(
? { value: null, unavailable: true }
: { value: Math.round((loc.value / HUMAN_LINES_PER_HOUR) * 10) / 10, unavailable: false };
const durationClauses: string[] = [
`"column" = 'done'`,
"executionCompletedAt IS NOT NULL",
"cumulativeActiveMs IS NOT NULL",
"cumulativeActiveMs > 0",
];
const durationParams: string[] = [];
if (query.from !== undefined) {
durationClauses.push("executionCompletedAt >= ?");
durationParams.push(query.from);
}
if (query.to !== undefined) {
durationClauses.push("executionCompletedAt <= ?");
durationParams.push(query.to);
}
const durationRows = db
.prepare(
`SELECT cumulativeActiveMs FROM tasks WHERE ${durationClauses.join(" AND ")} ORDER BY cumulativeActiveMs ASC`,
)
.all(...durationParams) as TaskDurationRow[];
const durations = durationRows.map((row) => row.cumulativeActiveMs);
const totalDurationMs = durations.reduce((sum, durationMs) => sum + durationMs, 0);
const taskDuration: TaskDurationSummary = durations.length > 0
? {
completedTasks: durations.length,
averageMs: totalDurationMs / durations.length,
medianMs: median(durations),
p90Ms: nearestRankPercentile(durations, 0.9),
totalMs: totalDurationMs,
unavailable: false,
}
: {
completedTasks: 0,
averageMs: null,
medianMs: null,
p90Ms: null,
totalMs: null,
unavailable: true,
};
// Pull requests. `pull_requests.createdAt` is an INTEGER epoch-ms column, so
// convert the ISO bounds to epoch ms for comparison.
const prClauses: string[] = [];
@@ -209,5 +285,6 @@ export function aggregateProductivityAnalytics(
pullRequests,
loc,
hoursSaved,
taskDuration,
};
}

View File

@@ -117,6 +117,14 @@ function populatedProductivityFixture() {
commits: 2,
pullRequests: 1,
loc: { value: 42, unavailable: false },
taskDuration: {
completedTasks: 2,
averageMs: 1_800_000,
medianMs: 1_800_000,
p90Ms: 2_400_000,
totalMs: 3_600_000,
unavailable: false,
},
byLanguage: [{ language: "TypeScript", count: 6 }],
};
}
@@ -127,6 +135,14 @@ function emptyProductivityFixture() {
commits: 0,
pullRequests: 0,
loc: { value: null, unavailable: true },
taskDuration: {
completedTasks: 0,
averageMs: null,
medianMs: null,
p90Ms: null,
totalMs: null,
unavailable: true,
},
byLanguage: [],
};
}

View File

@@ -14,8 +14,11 @@ vi.mock("../../../api/legacy", () => ({
vi.mock("../../../api", () => ({
fetchSystemStats: () => Promise.resolve(systemStatsFixture()),
fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }),
fetchConfig: vi.fn().mockResolvedValue({ maxConcurrent: 2, rootDir: "/" }),
fetchSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxTriageConcurrent: 1, maxWorktrees: 5 }),
killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }),
updateGlobalSettings: () => Promise.resolve({}),
updateSettings: vi.fn().mockResolvedValue({}),
}));
function emptyTokenFixture() {
@@ -102,6 +105,14 @@ function populatedProductivityFixture() {
commits: 2,
pullRequests: 1,
loc: { value: 42, unavailable: false },
taskDuration: {
completedTasks: 2,
averageMs: 1_800_000,
medianMs: 1_800_000,
p90Ms: 2_400_000,
totalMs: 3_600_000,
unavailable: false,
},
byLanguage: [{ language: "TypeScript", count: 6 }],
};
}
@@ -112,6 +123,14 @@ function emptyProductivityFixture() {
commits: 0,
pullRequests: 0,
loc: { value: null, unavailable: true },
taskDuration: {
completedTasks: 0,
averageMs: null,
medianMs: null,
p90Ms: null,
totalMs: null,
unavailable: true,
},
byLanguage: [],
};
}

View File

@@ -6,7 +6,7 @@ import { Bar } from "../charts/Bar";
import { PieChart } from "../charts/recharts";
import { AreaShell } from "./AreaShell";
import { useAnalyticsArea } from "./useAnalyticsArea";
import { formatCount } from "./areaShared";
import { formatCount, formatDurationMs } from "./areaShared";
/*
FNXC:CommandCenterCharts 2026-06-18-23:40:
@@ -28,6 +28,9 @@ ProductivityAnalytics exposes a categorical language distribution but no per-day
*
* FNXC:CommandCenterProductivity 2026-06-19-12:00:
* Human hours saved is a derived estimate from LOC. It must render the unavailable "—" sentinel, never 0, when LOC is unavailable and stay visibly labeled as an estimate rather than exact accounting.
*
* FNXC:CommandCenter 2026-06-19-12:10:
* The task-duration block shows active execution time from completed tasks and must use the same unavailable "—" contract as LOC so absent duration history is never displayed as 0.
*/
export function ProductivityArea({ range }: { range: DateRange }) {
const { t } = useTranslation("app");
@@ -55,12 +58,36 @@ export function ProductivityArea({ range }: { range: DateRange }) {
[data?.byLanguage],
);
const taskDuration = data?.taskDuration ?? {
completedTasks: 0,
averageMs: null,
medianMs: null,
p90Ms: null,
totalMs: null,
unavailable: true,
};
const isEmpty =
!data ||
(data.modifiedFiles === 0 && data.commits === 0 && data.pullRequests === 0);
(data.modifiedFiles === 0 &&
data.commits === 0 &&
data.pullRequests === 0 &&
taskDuration.completedTasks === 0);
const locUnavailable = !data || data.loc.unavailable || data.loc.value === null;
const hoursSavedUnavailable = !data || data.hoursSaved.unavailable || data.hoursSaved.value === null;
const durationUnavailable = !data || taskDuration.unavailable;
const durationTitle = t(
"commandCenter.productivity.durationUnavailable",
"Task duration is unavailable until completed tasks have active execution time recorded",
);
const renderDurationValue = (value: number | null, testId: string) =>
durationUnavailable || value === null ? (
<span className="cc-unavailable" title={durationTitle} data-testid={testId}>
—
</span>
) : (
formatDurationMs(value)
);
return (
<AreaShell testId="productivity" isLoading={isLoading} error={error} isEmpty={isEmpty}>
@@ -129,6 +156,40 @@ export function ProductivityArea({ range }: { range: DateRange }) {
</div>
</div>
<div className="cc-area-section">
<h3 className="cc-area-section-title">{t("commandCenter.productivity.durationTitle", "Task duration")}</h3>
<div className="cc-stat-grid">
<div className="card cc-stat-card" data-testid="cc-productivity-duration-completed">
<div className="cc-stat-label">{t("commandCenter.productivity.completedTasks", "Completed tasks")}</div>
<div className="cc-stat-value">{formatCount(taskDuration.completedTasks)}</div>
</div>
<div className="card cc-stat-card" data-testid="cc-productivity-duration-avg">
<div className="cc-stat-label">{t("commandCenter.productivity.averageDuration", "Average")}</div>
<div className="cc-stat-value">
{renderDurationValue(taskDuration.averageMs, "cc-productivity-duration-avg-unavailable")}
</div>
</div>
<div className="card cc-stat-card" data-testid="cc-productivity-duration-median">
<div className="cc-stat-label">{t("commandCenter.productivity.medianDuration", "Median")}</div>
<div className="cc-stat-value">
{renderDurationValue(taskDuration.medianMs, "cc-productivity-duration-median-unavailable")}
</div>
</div>
<div className="card cc-stat-card" data-testid="cc-productivity-duration-p90">
<div className="cc-stat-label">{t("commandCenter.productivity.p90Duration", "P90")}</div>
<div className="cc-stat-value">
{renderDurationValue(taskDuration.p90Ms, "cc-productivity-duration-p90-unavailable")}
</div>
</div>
<div className="card cc-stat-card" data-testid="cc-productivity-duration-total">
<div className="cc-stat-label">{t("commandCenter.productivity.totalDuration", "Total active")}</div>
<div className="cc-stat-value">
{renderDurationValue(taskDuration.totalMs, "cc-productivity-duration-total-unavailable")}
</div>
</div>
</div>
</div>
<div className="cc-area-section">
<h3 className="cc-area-section-title">
{t("commandCenter.productivity.byLanguage", "Files by language")}

View File

@@ -777,7 +777,7 @@ describe("ToolsArea", () => {
});
describe("ProductivityArea", () => {
it("renders unavailable LOC and hours saved as dash sentinels, never 0 and keeps chart geometry finite", async () => {
it("renders unavailable LOC and hours saved as dash sentinels, duration stats, and finite chart geometry", async () => {
apiMock.mockResolvedValue({
from: "2026-06-08",
to: null,
@@ -787,6 +787,14 @@ describe("ProductivityArea", () => {
pullRequests: 2,
loc: { value: null, unavailable: true },
hoursSaved: { value: null, unavailable: true },
taskDuration: {
completedTasks: 3,
averageMs: 5_400_000,
medianMs: 3_600_000,
p90Ms: 7_200_000,
totalMs: 16_200_000,
unavailable: false,
},
});
render(<ProductivityArea range={range7d} />);
await screen.findByTestId("cc-area-productivity");
@@ -799,6 +807,11 @@ describe("ProductivityArea", () => {
expect(screen.getByTestId("cc-productivity-hours-saved").textContent).not.toContain("0");
// The commits outcome counter still shows a real number.
expect(screen.getByTestId("cc-productivity-commits").textContent).toContain("4");
expect(screen.getByTestId("cc-productivity-duration-completed").textContent).toContain("3");
expect(screen.getByTestId("cc-productivity-duration-avg").textContent).toContain("1h 30m");
expect(screen.getByTestId("cc-productivity-duration-median").textContent).toContain("1h");
expect(screen.getByTestId("cc-productivity-duration-p90").textContent).toContain("2h");
expect(screen.getByTestId("cc-productivity-duration-total").textContent).toContain("4h 30m");
expect(screen.getByRole("list", { name: "Files by language" })).toBeTruthy();
expect(screen.getByTestId("cc-productivity-pie")).toBeTruthy();
expect(screen.getByRole("img", { name: "Language share" })).toBeTruthy();
@@ -815,11 +828,20 @@ describe("ProductivityArea", () => {
pullRequests: 0,
loc: { value: null, unavailable: true },
hoursSaved: { value: null, unavailable: true },
taskDuration: {
completedTasks: 0,
averageMs: null,
medianMs: null,
p90Ms: null,
totalMs: null,
unavailable: true,
},
});
const { unmount } = render(<ProductivityArea range={range7d} />);
await screen.findByTestId("cc-area-productivity-empty");
expect(screen.queryByRole("list", { name: "Files by language" })).toBeNull();
expect(screen.queryByTestId("cc-productivity-pie")).toBeNull();
expect(screen.queryByTestId("cc-productivity-duration-avg")).toBeNull();
unmount();
apiMock.mockImplementationOnce(() => new Promise(() => undefined));
@@ -832,6 +854,39 @@ describe("ProductivityArea", () => {
await screen.findByTestId("cc-area-productivity-error");
expect(screen.getByTestId("cc-area-productivity-error").textContent).toContain("productivity failed");
expect(screen.queryByTestId("cc-productivity-pie")).toBeNull();
expect(screen.queryByTestId("cc-productivity-duration-avg")).toBeNull();
});
it("renders unavailable task duration as dash sentinels, never zero", async () => {
apiMock.mockResolvedValue({
from: "2026-06-08",
to: null,
modifiedFiles: 1,
byLanguage: [{ language: "ts", count: 1 }],
commits: 0,
pullRequests: 0,
loc: { value: null, unavailable: true },
hoursSaved: { value: null, unavailable: true },
taskDuration: {
completedTasks: 0,
averageMs: null,
medianMs: null,
p90Ms: null,
totalMs: null,
unavailable: true,
},
});
render(<ProductivityArea range={range7d} />);
await screen.findByTestId("cc-area-productivity");
const avg = screen.getByTestId("cc-productivity-duration-avg-unavailable");
expect(avg.textContent).toBe("—");
expect(avg.getAttribute("title")).toBeTruthy();
expect(screen.getByTestId("cc-productivity-duration-median-unavailable").textContent).toBe("—");
expect(screen.getByTestId("cc-productivity-duration-p90-unavailable").textContent).toBe("—");
expect(screen.getByTestId("cc-productivity-duration-total-unavailable").textContent).toBe("—");
expect(screen.getByTestId("cc-productivity-duration-avg").textContent).not.toContain("0");
});
it("keeps the productivity pie safe for single-item and non-finite language data", async () => {
@@ -844,6 +899,14 @@ describe("ProductivityArea", () => {
pullRequests: 0,
loc: { value: null, unavailable: true },
hoursSaved: { value: null, unavailable: true },
taskDuration: {
completedTasks: 0,
averageMs: null,
medianMs: null,
p90Ms: null,
totalMs: null,
unavailable: true,
},
});
render(<ProductivityArea range={range7d} />);

View File

@@ -29,6 +29,18 @@ export function formatCount(n: number): string {
return Number.isFinite(n) ? Math.round(n).toLocaleString() : "0";
}
/** Format milliseconds as compact active execution duration text. */
export function formatDurationMs(ms: number | null): string {
if (ms === null || !Number.isFinite(ms)) return "";
const totalSeconds = Math.max(0, Math.round(ms / 1_000));
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
if (minutes > 0) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
return `${seconds}s`;
}
/** Format a USD cost result, returning the unavailable sentinel "—" when unknown. */
export function formatCost(usd: number | null, unavailable: boolean): string {
if (unavailable || usd === null || !Number.isFinite(usd)) {

View File

@@ -103,6 +103,14 @@ describe("productivityAnalyticsToTable", () => {
pullRequests: 1,
loc: hoursSaved.unavailable ? { value: null, unavailable: true } : { value: 15, unavailable: false },
hoursSaved,
taskDuration: {
completedTasks: 0,
averageMs: null,
medianMs: null,
p90Ms: null,
totalMs: null,
unavailable: true,
},
};
}

View File

@@ -49,6 +49,14 @@ function seedDb(db: Database, opts: { taskId: string; model: string; tokens: num
});
}
function seedCompletedTaskDuration(db: Database, opts: { id: string; cumulativeActiveMs: number; completedAt: string }): void {
db.prepare(
`INSERT INTO tasks
(id, description, "column", cumulativeActiveMs, executionCompletedAt, createdAt, updatedAt)
VALUES (?, 'desc', 'done', ?, ?, ?, ?)`,
).run(opts.id, opts.cumulativeActiveMs, opts.completedAt, opts.completedAt, opts.completedAt);
}
function seedAgentRun(db: Database, opts: { id: string; agentId: string; startedAt: string; status: string }): void {
db.prepare(
`INSERT OR IGNORE INTO agents (id, name, role, state, createdAt, updatedAt)
@@ -295,6 +303,7 @@ describe("register-command-center-routes", () => {
it("returns the tools / activity / productivity aggregator shapes", async () => {
const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z";
seedAgentRun(dbA, { id: "run-a1", agentId: "agent-route", startedAt: "2026-03-02T00:00:00.000Z", status: "active" });
seedCompletedTaskDuration(dbA, { id: "FN-D1", cumulativeActiveMs: 120_000, completedAt: "2026-03-03T00:00:00.000Z" });
const tools = await request(app, "GET", `/api/command-center/tools?${range}&projectId=proj-a`);
expect(tools.status).toBe(200);
expect(tools.body).toHaveProperty("autonomyRatio");
@@ -312,6 +321,11 @@ describe("register-command-center-routes", () => {
expect(prod.body).toHaveProperty("loc");
expect(prod.body).toHaveProperty("hoursSaved");
expect(prod.body).toHaveProperty("byLanguage");
expect(prod.body).toHaveProperty("taskDuration");
expect((prod.body as { taskDuration: { completedTasks: number; totalMs: number } }).taskDuration).toMatchObject({
completedTasks: 1,
totalMs: 120_000,
});
seedGithubIssueMetrics(dbA, { prefix: "FN-A", repo: "acme/alpha", filed: 2, fixed: 1 });
const github = await request(app, "GET", `/api/command-center/github?${range}&projectId=proj-a`);
@@ -539,6 +553,7 @@ describe("register-command-center-routes", () => {
it("?format=csv works for tools / activity / productivity endpoints", async () => {
const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z";
seedCompletedTaskDuration(dbA, { id: "FN-DCSV", cumulativeActiveMs: 120_000, completedAt: "2026-03-03T00:00:00.000Z" });
for (const [path, filename] of [
["tools", "command-center-tools.csv"],
["activity", "command-center-activity.csv"],
@@ -556,6 +571,13 @@ describe("register-command-center-routes", () => {
`attachment; filename="${filename}"`,
);
expect((res.body as string).split("\r\n")[0].length).toBeGreaterThan(0);
if (path === "productivity") {
expect(res.body as string).toContain("completedTasks,1");
expect(res.body as string).toContain("avgDurationMs,120000");
expect(res.body as string).toContain("medianDurationMs,120000");
expect(res.body as string).toContain("p90DurationMs,120000");
expect(res.body as string).toContain("totalDurationMs,120000");
}
}
});

View File

@@ -174,6 +174,11 @@ export function productivityAnalyticsToTable(
rows.push(["pullRequests", result.pullRequests]);
rows.push(["loc", result.loc.value ?? ""]);
rows.push(["hoursSaved", result.hoursSaved.value ?? ""]);
rows.push(["completedTasks", result.taskDuration.completedTasks]);
rows.push(["avgDurationMs", result.taskDuration.averageMs ?? ""]);
rows.push(["medianDurationMs", result.taskDuration.medianMs ?? ""]);
rows.push(["p90DurationMs", result.taskDuration.p90Ms ?? ""]);
rows.push(["totalDurationMs", result.taskDuration.totalMs ?? ""]);
return { header, rows };
}