diff --git a/.changeset/soft-badgers-measure.md b/.changeset/soft-badgers-measure.md new file mode 100644 index 0000000000..b5ecf8e654 --- /dev/null +++ b/.changeset/soft-badgers-measure.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 8d0184cd78..d6e1949def 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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: diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 4a333d0d3e..79480d6f36 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/docs/storage.md b/docs/storage.md index 6a8eabfe96..4c6d642d4b 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -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. | diff --git a/packages/core/src/__tests__/productivity-analytics.test.ts b/packages/core/src/__tests__/productivity-analytics.test.ts index 2bfb83b24d..205c5569ec 100644 --- a/packages/core/src/__tests__/productivity-analytics.test.ts +++ b/packages/core/src/__tests__/productivity-analytics.test.ts @@ -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`", () => { diff --git a/packages/core/src/productivity-analytics.ts b/packages/core/src/productivity-analytics.ts index 93c29b08fd..5459366ea6 100644 --- a/packages/core/src/productivity-analytics.ts +++ b/packages/core/src/productivity-analytics.ts @@ -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, }; } diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx index 1fd31b73d1..4b7062ba72 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx @@ -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: [], }; } diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.tablet-layout.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.tablet-layout.test.tsx index 60796aabf6..9d43a335e2 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.tablet-layout.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.tablet-layout.test.tsx @@ -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: [], }; } diff --git a/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx b/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx index 8718622839..a13a2d82bc 100644 --- a/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx @@ -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 ? ( + + — + + ) : ( + formatDurationMs(value) + ); return ( @@ -129,6 +156,40 @@ export function ProductivityArea({ range }: { range: DateRange }) { +
+

{t("commandCenter.productivity.durationTitle", "Task duration")}

+
+
+
{t("commandCenter.productivity.completedTasks", "Completed tasks")}
+
{formatCount(taskDuration.completedTasks)}
+
+
+
{t("commandCenter.productivity.averageDuration", "Average")}
+
+ {renderDurationValue(taskDuration.averageMs, "cc-productivity-duration-avg-unavailable")} +
+
+
+
{t("commandCenter.productivity.medianDuration", "Median")}
+
+ {renderDurationValue(taskDuration.medianMs, "cc-productivity-duration-median-unavailable")} +
+
+
+
{t("commandCenter.productivity.p90Duration", "P90")}
+
+ {renderDurationValue(taskDuration.p90Ms, "cc-productivity-duration-p90-unavailable")} +
+
+
+
{t("commandCenter.productivity.totalDuration", "Total active")}
+
+ {renderDurationValue(taskDuration.totalMs, "cc-productivity-duration-total-unavailable")} +
+
+
+
+

{t("commandCenter.productivity.byLanguage", "Files by language")} diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx index 2a3458df38..8187031161 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx @@ -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(); 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(); 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(); + 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(); diff --git a/packages/dashboard/app/components/command-center/areas/areaShared.ts b/packages/dashboard/app/components/command-center/areas/areaShared.ts index c294e96638..e2c2bd214b 100644 --- a/packages/dashboard/app/components/command-center/areas/areaShared.ts +++ b/packages/dashboard/app/components/command-center/areas/areaShared.ts @@ -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)) { diff --git a/packages/dashboard/src/__tests__/command-center-csv.test.ts b/packages/dashboard/src/__tests__/command-center-csv.test.ts index 39d7b2ee65..c2d6bd19dd 100644 --- a/packages/dashboard/src/__tests__/command-center-csv.test.ts +++ b/packages/dashboard/src/__tests__/command-center-csv.test.ts @@ -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, + }, }; } diff --git a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts index 82a4bf0382..4125e4046f 100644 --- a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts +++ b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts @@ -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"); + } } }); diff --git a/packages/dashboard/src/command-center-csv.ts b/packages/dashboard/src/command-center-csv.ts index 085b513b04..41ab865034 100644 --- a/packages/dashboard/src/command-center-csv.ts +++ b/packages/dashboard/src/command-center-csv.ts @@ -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 }; }