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:
5
.changeset/soft-badgers-measure.md
Normal file
5
.changeset/soft-badgers-measure.md
Normal 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.
|
||||||
@@ -856,7 +856,7 @@ Operator setup + troubleshooting guide: **[Remote Access runbook](./remote-acces
|
|||||||
Key server capabilities:
|
Key server capabilities:
|
||||||
- REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings
|
- 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
|
- 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
|
- 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)
|
- 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.
|
- `/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.
|
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 files-changed sources of truth
|
||||||
|
|
||||||
Done-task file-count surfaces intentionally distinguish three data sources:
|
Done-task file-count surfaces intentionally distinguish three data sources:
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -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 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 `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). |
|
| `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. |
|
| `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. |
|
| `activityLog` | Per-project activity/event log with timestamp/type/task indexes. |
|
||||||
|
|||||||
@@ -14,6 +14,30 @@ function insertTaskWithFiles(db: Database, id: string, files: string[], updatedA
|
|||||||
).run(id, updatedAt, updatedAt, JSON.stringify(files));
|
).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(
|
function insertCommit(
|
||||||
db: Database,
|
db: Database,
|
||||||
id: string,
|
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", () => {
|
it("empty range returns zeroed structures, not nulls", () => {
|
||||||
insertTaskWithFiles(db, "t1", ["src/a.ts"], "2026-03-01T00:00:00.000Z");
|
insertTaskWithFiles(db, "t1", ["src/a.ts"], "2026-03-01T00:00:00.000Z");
|
||||||
insertCommit(db, "c1", "sha1", "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"));
|
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" });
|
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.modifiedFiles).toBe(0);
|
||||||
expect(result.byLanguage).toEqual([]);
|
expect(result.byLanguage).toEqual([]);
|
||||||
expect(result.commits).toBe(0);
|
expect(result.commits).toBe(0);
|
||||||
expect(result.pullRequests).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.loc).toEqual({ value: null, unavailable: true });
|
||||||
expect(result.hoursSaved).toEqual({ value: null, unavailable: true });
|
expect(result.hoursSaved).toEqual({ value: null, unavailable: true });
|
||||||
expect(result.hoursSaved.value).not.toBe(0);
|
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`", () => {
|
it("includes a boundary task exactly at `from`", () => {
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import type { Database } from "./db.js";
|
|||||||
*
|
*
|
||||||
* Inclusivity: `from`/`to` bounds are inclusive. Tasks are filtered by
|
* Inclusivity: `from`/`to` bounds are inclusive. Tasks are filtered by
|
||||||
* `updatedAt` (the last time the task — and therefore its modifiedFiles — was
|
* `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;
|
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 {
|
export interface ProductivityAnalytics {
|
||||||
from: string | null;
|
from: string | null;
|
||||||
to: string | null;
|
to: string | null;
|
||||||
@@ -74,6 +88,8 @@ export interface ProductivityAnalytics {
|
|||||||
loc: LocSummary;
|
loc: LocSummary;
|
||||||
/** Estimated human-hours equivalent derived from `loc` when LOC is available. */
|
/** Estimated human-hours equivalent derived from `loc` when LOC is available. */
|
||||||
hoursSaved: HoursSavedSummary;
|
hoursSaved: HoursSavedSummary;
|
||||||
|
/** Active execution duration for done tasks completed in range. */
|
||||||
|
taskDuration: TaskDurationSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CountRow {
|
interface CountRow {
|
||||||
@@ -91,6 +107,10 @@ interface ModifiedFilesRow {
|
|||||||
modifiedFiles: string | null;
|
modifiedFiles: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TaskDurationRow {
|
||||||
|
cumulativeActiveMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Extract a coarse language key from a file path (its lowercased extension). */
|
/** Extract a coarse language key from a file path (its lowercased extension). */
|
||||||
function languageOf(path: string): string {
|
function languageOf(path: string): string {
|
||||||
const base = path.split("/").pop() ?? path;
|
const base = path.split("/").pop() ?? path;
|
||||||
@@ -99,10 +119,26 @@ function languageOf(path: string): string {
|
|||||||
return base.slice(dot + 1).toLowerCase();
|
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
|
* Aggregate productivity metrics over a date range. Empty range yields zeroed
|
||||||
* structures (not nulls); LOC remains the unavailable sentinel unless at least
|
* structures (not nulls); LOC and task duration remain unavailable sentinels
|
||||||
* one in-range commit association carries diff stats.
|
* unless at least one in-range row carries real source data.
|
||||||
*/
|
*/
|
||||||
export function aggregateProductivityAnalytics(
|
export function aggregateProductivityAnalytics(
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -181,6 +217,46 @@ export function aggregateProductivityAnalytics(
|
|||||||
? { value: null, unavailable: true }
|
? { value: null, unavailable: true }
|
||||||
: { value: Math.round((loc.value / HUMAN_LINES_PER_HOUR) * 10) / 10, unavailable: false };
|
: { 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
|
// Pull requests. `pull_requests.createdAt` is an INTEGER epoch-ms column, so
|
||||||
// convert the ISO bounds to epoch ms for comparison.
|
// convert the ISO bounds to epoch ms for comparison.
|
||||||
const prClauses: string[] = [];
|
const prClauses: string[] = [];
|
||||||
@@ -209,5 +285,6 @@ export function aggregateProductivityAnalytics(
|
|||||||
pullRequests,
|
pullRequests,
|
||||||
loc,
|
loc,
|
||||||
hoursSaved,
|
hoursSaved,
|
||||||
|
taskDuration,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,14 @@ function populatedProductivityFixture() {
|
|||||||
commits: 2,
|
commits: 2,
|
||||||
pullRequests: 1,
|
pullRequests: 1,
|
||||||
loc: { value: 42, unavailable: false },
|
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 }],
|
byLanguage: [{ language: "TypeScript", count: 6 }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -127,6 +135,14 @@ function emptyProductivityFixture() {
|
|||||||
commits: 0,
|
commits: 0,
|
||||||
pullRequests: 0,
|
pullRequests: 0,
|
||||||
loc: { value: null, unavailable: true },
|
loc: { value: null, unavailable: true },
|
||||||
|
taskDuration: {
|
||||||
|
completedTasks: 0,
|
||||||
|
averageMs: null,
|
||||||
|
medianMs: null,
|
||||||
|
p90Ms: null,
|
||||||
|
totalMs: null,
|
||||||
|
unavailable: true,
|
||||||
|
},
|
||||||
byLanguage: [],
|
byLanguage: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ vi.mock("../../../api/legacy", () => ({
|
|||||||
vi.mock("../../../api", () => ({
|
vi.mock("../../../api", () => ({
|
||||||
fetchSystemStats: () => Promise.resolve(systemStatsFixture()),
|
fetchSystemStats: () => Promise.resolve(systemStatsFixture()),
|
||||||
fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }),
|
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: [] }),
|
killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }),
|
||||||
updateGlobalSettings: () => Promise.resolve({}),
|
updateGlobalSettings: () => Promise.resolve({}),
|
||||||
|
updateSettings: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function emptyTokenFixture() {
|
function emptyTokenFixture() {
|
||||||
@@ -102,6 +105,14 @@ function populatedProductivityFixture() {
|
|||||||
commits: 2,
|
commits: 2,
|
||||||
pullRequests: 1,
|
pullRequests: 1,
|
||||||
loc: { value: 42, unavailable: false },
|
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 }],
|
byLanguage: [{ language: "TypeScript", count: 6 }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -112,6 +123,14 @@ function emptyProductivityFixture() {
|
|||||||
commits: 0,
|
commits: 0,
|
||||||
pullRequests: 0,
|
pullRequests: 0,
|
||||||
loc: { value: null, unavailable: true },
|
loc: { value: null, unavailable: true },
|
||||||
|
taskDuration: {
|
||||||
|
completedTasks: 0,
|
||||||
|
averageMs: null,
|
||||||
|
medianMs: null,
|
||||||
|
p90Ms: null,
|
||||||
|
totalMs: null,
|
||||||
|
unavailable: true,
|
||||||
|
},
|
||||||
byLanguage: [],
|
byLanguage: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Bar } from "../charts/Bar";
|
|||||||
import { PieChart } from "../charts/recharts";
|
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, formatDurationMs } from "./areaShared";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:CommandCenterCharts 2026-06-18-23:40:
|
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:
|
* 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.
|
* 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 }) {
|
export function ProductivityArea({ range }: { range: DateRange }) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
@@ -55,12 +58,36 @@ export function ProductivityArea({ range }: { range: DateRange }) {
|
|||||||
[data?.byLanguage],
|
[data?.byLanguage],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const taskDuration = data?.taskDuration ?? {
|
||||||
|
completedTasks: 0,
|
||||||
|
averageMs: null,
|
||||||
|
medianMs: null,
|
||||||
|
p90Ms: null,
|
||||||
|
totalMs: null,
|
||||||
|
unavailable: true,
|
||||||
|
};
|
||||||
const isEmpty =
|
const isEmpty =
|
||||||
!data ||
|
!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 locUnavailable = !data || data.loc.unavailable || data.loc.value === null;
|
||||||
const hoursSavedUnavailable = !data || data.hoursSaved.unavailable || data.hoursSaved.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 (
|
return (
|
||||||
<AreaShell testId="productivity" isLoading={isLoading} error={error} isEmpty={isEmpty}>
|
<AreaShell testId="productivity" isLoading={isLoading} error={error} isEmpty={isEmpty}>
|
||||||
@@ -129,6 +156,40 @@ export function ProductivityArea({ range }: { range: DateRange }) {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="cc-area-section">
|
||||||
<h3 className="cc-area-section-title">
|
<h3 className="cc-area-section-title">
|
||||||
{t("commandCenter.productivity.byLanguage", "Files by language")}
|
{t("commandCenter.productivity.byLanguage", "Files by language")}
|
||||||
|
|||||||
@@ -777,7 +777,7 @@ describe("ToolsArea", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ProductivityArea", () => {
|
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({
|
apiMock.mockResolvedValue({
|
||||||
from: "2026-06-08",
|
from: "2026-06-08",
|
||||||
to: null,
|
to: null,
|
||||||
@@ -787,6 +787,14 @@ describe("ProductivityArea", () => {
|
|||||||
pullRequests: 2,
|
pullRequests: 2,
|
||||||
loc: { value: null, unavailable: true },
|
loc: { value: null, unavailable: true },
|
||||||
hoursSaved: { 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} />);
|
render(<ProductivityArea range={range7d} />);
|
||||||
await screen.findByTestId("cc-area-productivity");
|
await screen.findByTestId("cc-area-productivity");
|
||||||
@@ -799,6 +807,11 @@ describe("ProductivityArea", () => {
|
|||||||
expect(screen.getByTestId("cc-productivity-hours-saved").textContent).not.toContain("0");
|
expect(screen.getByTestId("cc-productivity-hours-saved").textContent).not.toContain("0");
|
||||||
// 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.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.getByRole("list", { name: "Files by language" })).toBeTruthy();
|
||||||
expect(screen.getByTestId("cc-productivity-pie")).toBeTruthy();
|
expect(screen.getByTestId("cc-productivity-pie")).toBeTruthy();
|
||||||
expect(screen.getByRole("img", { name: "Language share" })).toBeTruthy();
|
expect(screen.getByRole("img", { name: "Language share" })).toBeTruthy();
|
||||||
@@ -815,11 +828,20 @@ describe("ProductivityArea", () => {
|
|||||||
pullRequests: 0,
|
pullRequests: 0,
|
||||||
loc: { value: null, unavailable: true },
|
loc: { value: null, unavailable: true },
|
||||||
hoursSaved: { 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} />);
|
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();
|
expect(screen.queryByTestId("cc-productivity-pie")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-productivity-duration-avg")).toBeNull();
|
||||||
unmount();
|
unmount();
|
||||||
|
|
||||||
apiMock.mockImplementationOnce(() => new Promise(() => undefined));
|
apiMock.mockImplementationOnce(() => new Promise(() => undefined));
|
||||||
@@ -832,6 +854,39 @@ describe("ProductivityArea", () => {
|
|||||||
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();
|
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 () => {
|
it("keeps the productivity pie safe for single-item and non-finite language data", async () => {
|
||||||
@@ -844,6 +899,14 @@ describe("ProductivityArea", () => {
|
|||||||
pullRequests: 0,
|
pullRequests: 0,
|
||||||
loc: { value: null, unavailable: true },
|
loc: { value: null, unavailable: true },
|
||||||
hoursSaved: { 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} />);
|
render(<ProductivityArea range={range7d} />);
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,18 @@ export function formatCount(n: number): string {
|
|||||||
return Number.isFinite(n) ? Math.round(n).toLocaleString() : "0";
|
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. */
|
/** Format a USD cost result, returning the unavailable sentinel "—" when unknown. */
|
||||||
export function formatCost(usd: number | null, unavailable: boolean): string {
|
export function formatCost(usd: number | null, unavailable: boolean): string {
|
||||||
if (unavailable || usd === null || !Number.isFinite(usd)) {
|
if (unavailable || usd === null || !Number.isFinite(usd)) {
|
||||||
|
|||||||
@@ -103,6 +103,14 @@ describe("productivityAnalyticsToTable", () => {
|
|||||||
pullRequests: 1,
|
pullRequests: 1,
|
||||||
loc: hoursSaved.unavailable ? { value: null, unavailable: true } : { value: 15, unavailable: false },
|
loc: hoursSaved.unavailable ? { value: null, unavailable: true } : { value: 15, unavailable: false },
|
||||||
hoursSaved,
|
hoursSaved,
|
||||||
|
taskDuration: {
|
||||||
|
completedTasks: 0,
|
||||||
|
averageMs: null,
|
||||||
|
medianMs: null,
|
||||||
|
p90Ms: null,
|
||||||
|
totalMs: null,
|
||||||
|
unavailable: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
function seedAgentRun(db: Database, opts: { id: string; agentId: string; startedAt: string; status: string }): void {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT OR IGNORE INTO agents (id, name, role, state, createdAt, updatedAt)
|
`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 () => {
|
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";
|
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" });
|
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`);
|
const tools = await request(app, "GET", `/api/command-center/tools?${range}&projectId=proj-a`);
|
||||||
expect(tools.status).toBe(200);
|
expect(tools.status).toBe(200);
|
||||||
expect(tools.body).toHaveProperty("autonomyRatio");
|
expect(tools.body).toHaveProperty("autonomyRatio");
|
||||||
@@ -312,6 +321,11 @@ describe("register-command-center-routes", () => {
|
|||||||
expect(prod.body).toHaveProperty("loc");
|
expect(prod.body).toHaveProperty("loc");
|
||||||
expect(prod.body).toHaveProperty("hoursSaved");
|
expect(prod.body).toHaveProperty("hoursSaved");
|
||||||
expect(prod.body).toHaveProperty("byLanguage");
|
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 });
|
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`);
|
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 () => {
|
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";
|
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 [
|
for (const [path, filename] of [
|
||||||
["tools", "command-center-tools.csv"],
|
["tools", "command-center-tools.csv"],
|
||||||
["activity", "command-center-activity.csv"],
|
["activity", "command-center-activity.csv"],
|
||||||
@@ -556,6 +571,13 @@ describe("register-command-center-routes", () => {
|
|||||||
`attachment; filename="${filename}"`,
|
`attachment; filename="${filename}"`,
|
||||||
);
|
);
|
||||||
expect((res.body as string).split("\r\n")[0].length).toBeGreaterThan(0);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,11 @@ export function productivityAnalyticsToTable(
|
|||||||
rows.push(["pullRequests", result.pullRequests]);
|
rows.push(["pullRequests", result.pullRequests]);
|
||||||
rows.push(["loc", result.loc.value ?? ""]);
|
rows.push(["loc", result.loc.value ?? ""]);
|
||||||
rows.push(["hoursSaved", result.hoursSaved.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 };
|
return { header, rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user