diff --git a/.changeset/fuzzy-productivity-hours.md b/.changeset/fuzzy-productivity-hours.md new file mode 100644 index 0000000000..4795f50b9c --- /dev/null +++ b/.changeset/fuzzy-productivity-hours.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add estimated human hours saved to Command Center Productivity analytics, UI stats, and CSV exports. diff --git a/docs/architecture.md b/docs/architecture.md index 7690372198..8d0184cd78 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 and keeps the unavailable sentinel 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. `/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. @@ -1459,7 +1459,7 @@ Dashboard session-diff route registration (`packages/dashboard/src/routes/regist - `legacy` = recovered via legacy task-id/subject matching - `ambiguous` = manual reconciliation where historical task-id attribution could be misleading -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, and preserve the `—` unavailable sentinel when all matching rows are `NULL` so unknown historical data is never rendered as `0`. +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. ### Done-task files-changed sources of truth diff --git a/docs/storage.md b/docs/storage.md index db538f09e3..cfd27bc90b 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -392,11 +392,11 @@ The `tasks.sourceIssueClosedAt` column (migration 122) backs `TaskSourceIssue.cl The `tasks.tokenUsage*` columns store cumulative per-task token usage for analytics. `tokenUsageModelProvider` and `tokenUsageModelId` are analytics-only snapshots of the actually-used runtime model recorded when usage is accumulated; they let Command Center group and price resolved-via-settings usage by provider/model without writing the task-level `modelProvider` / `modelId` own-model override fields that control future model resolution. Cost attribution reads the snapshot first and falls back to the legacy own-model columns for pre-snapshot rows. -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. `NULL` means stats were unknown or unavailable for that association, not zero; ranges with no non-null stats keep the unavailable `—` sentinel 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`. | `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. | -| `task_commit_associations` | Commit-to-task-lineage associations for canonical and legacy landed-commit attribution. Includes nullable `additions`/`deletions` diff-stat columns captured at merge time for Command Center Productivity LOC; `NULL` means stats unknown, not zero. | +| `task_commit_associations` | Commit-to-task-lineage associations for canonical and legacy landed-commit attribution. Includes nullable `additions`/`deletions` diff-stat columns captured at merge time for Command Center Productivity LOC and derived estimated `hoursSaved`; `NULL` means stats unknown, not zero. | | `archivedTasks` | Archived task snapshots (compact JSON payload + archive timestamp). | | `automations` | Scheduled automation definitions, run state, and run history. | | `agents` | Agent registry/state/task assignment metadata. | diff --git a/packages/core/src/__tests__/productivity-analytics.test.ts b/packages/core/src/__tests__/productivity-analytics.test.ts index 5db312da2e..2bfb83b24d 100644 --- a/packages/core/src/__tests__/productivity-analytics.test.ts +++ b/packages/core/src/__tests__/productivity-analytics.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { Database } from "../db.js"; -import { aggregateProductivityAnalytics } from "../productivity-analytics.js"; +import { aggregateProductivityAnalytics, HUMAN_LINES_PER_HOUR } from "../productivity-analytics.js"; function insertTaskWithFiles(db: Database, id: string, files: string[], updatedAt: string): void { db.prepare( @@ -86,19 +86,24 @@ describe("productivity-analytics", () => { const result = aggregateProductivityAnalytics(db, {}); expect(result.loc).toEqual({ value: null, unavailable: true }); expect(result.loc.value).not.toBe(0); + expect(result.hoursSaved).toEqual({ value: null, unavailable: true }); + expect(result.hoursSaved.value).not.toBe(0); }); - it("sums additions and deletions into LOC when commit stats exist", () => { + it("sums additions and deletions into LOC and derives estimated hours saved when commit stats exist", () => { insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z", { additions: 10, deletions: 5 }); - insertCommit(db, "c2", "sha2", "2026-03-02T00:00:00.000Z", { additions: 3, deletions: 2 }); insertCommit(db, "c-old", "sha-old", "2025-01-01T00:00:00.000Z", { additions: 100, deletions: 100 }); const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); - expect(result.commits).toBe(2); - expect(result.loc).toEqual({ value: 20, unavailable: false }); + expect(result.commits).toBe(1); + expect(result.loc).toEqual({ value: 15, unavailable: false }); + expect(result.hoursSaved).toEqual({ + value: Math.round((15 / HUMAN_LINES_PER_HOUR) * 10) / 10, + unavailable: false, + }); }); - it("keeps the LOC sentinel when in-range commit rows have only null stats", () => { + it("keeps the LOC and hours-saved sentinels when in-range commit rows have only null stats", () => { insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z"); insertCommit(db, "c2", "sha2", "2026-03-02T00:00:00.000Z", { additions: null, deletions: null }); @@ -106,9 +111,11 @@ describe("productivity-analytics", () => { expect(result.commits).toBe(2); expect(result.loc).toEqual({ value: null, unavailable: true }); expect(result.loc.value).not.toBe(0); + expect(result.hoursSaved).toEqual({ value: null, unavailable: true }); + expect(result.hoursSaved.value).not.toBe(0); }); - it("sums only valued LOC rows while allowing partial commit-stat coverage", () => { + it("sums only valued LOC rows and hours saved while allowing partial commit-stat coverage", () => { insertCommit(db, "c-null", "sha-null", "2026-03-01T00:00:00.000Z"); insertCommit(db, "c-additions", "sha-additions", "2026-03-02T00:00:00.000Z", { additions: 7 }); insertCommit(db, "c-deletions", "sha-deletions", "2026-03-03T00:00:00.000Z", { deletions: 4 }); @@ -116,6 +123,10 @@ describe("productivity-analytics", () => { const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); expect(result.commits).toBe(3); expect(result.loc).toEqual({ value: 11, unavailable: false }); + expect(result.hoursSaved).toEqual({ + value: Math.round((11 / HUMAN_LINES_PER_HOUR) * 10) / 10, + unavailable: false, + }); }); it("empty range returns zeroed structures, not nulls", () => { @@ -128,8 +139,10 @@ describe("productivity-analytics", () => { expect(result.byLanguage).toEqual([]); expect(result.commits).toBe(0); expect(result.pullRequests).toBe(0); - // LOC unavailable regardless of range + // LOC and derived hours 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); }); it("includes a boundary task exactly at `from`", () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d4551afa39..1672c1c232 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -571,12 +571,13 @@ export type { MttrSummary, MonitorMetrics, } from "./activity-analytics.js"; -export { aggregateProductivityAnalytics } from "./productivity-analytics.js"; +export { aggregateProductivityAnalytics, HUMAN_LINES_PER_HOUR } from "./productivity-analytics.js"; export type { ProductivityAnalytics, ProductivityAnalyticsQuery, LanguageCount, LocSummary, + HoursSavedSummary, } from "./productivity-analytics.js"; export { aggregatePluginActivations } from "./plugin-activation-analytics.js"; export type { diff --git a/packages/core/src/productivity-analytics.ts b/packages/core/src/productivity-analytics.ts index aff8a976a6..93c29b08fd 100644 --- a/packages/core/src/productivity-analytics.ts +++ b/packages/core/src/productivity-analytics.ts @@ -3,7 +3,8 @@ import type { Database } from "./db.js"; /** * Productivity analytics: files modified (count + language distribution) from * `tasks.modifiedFiles`, commit associations from `task_commit_associations`, - * pull requests from `pull_requests`, and LOC from merge-time commit diff stats. + * pull requests from `pull_requests`, LOC from merge-time commit diff stats, + * and estimated human hours saved derived from the same LOC source. * * **LOC availability.** Fusion persists nullable `additions`/`deletions` on * `task_commit_associations` when merge paths can capture git shortstat output. @@ -11,13 +12,20 @@ import type { Database } from "./db.js"; * has non-null stats. If the range has no recorded stats, the documented * unavailable sentinel — `{ value: null, unavailable: true }` — is preserved, * **never `0`**, so missing historical data is not mistaken for "zero lines - * changed". + * changed". Human-hours-saved uses the same sentinel because it is a + * conservative estimate over real LOC rather than an independent data source. * * 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`. */ +/* +FNXC:CommandCenterProductivity 2026-06-19-12:00: +Human hours saved is intentionally a rough headline estimate from already-aggregated changed LOC. Use one conservative exported rate so dashboards, CSV exports, and docs can cite the same assumption without adding a new data source or implying precision. +*/ +export const HUMAN_LINES_PER_HOUR = 15; + export interface ProductivityAnalyticsQuery { /** ISO-8601 lower bound (inclusive). */ from?: string; @@ -41,6 +49,16 @@ export interface LocSummary { unavailable: boolean; } +/** + * Estimated human hours saved. `value` is an estimate in hours. It is null and + * `unavailable` true when the underlying LOC source is unavailable — never `0` + * for unknown data. + */ +export interface HoursSavedSummary { + value: number | null; + unavailable: boolean; +} + export interface ProductivityAnalytics { from: string | null; to: string | null; @@ -54,6 +72,8 @@ export interface ProductivityAnalytics { pullRequests: number; /** LOC from commit association diff stats when at least one in-range row has stats. */ loc: LocSummary; + /** Estimated human-hours equivalent derived from `loc` when LOC is available. */ + hoursSaved: HoursSavedSummary; } interface CountRow { @@ -157,6 +177,9 @@ export function aggregateProductivityAnalytics( const loc: LocSummary = commitStats.statsRows > 0 ? { value: (commitStats.additions ?? 0) + (commitStats.deletions ?? 0), unavailable: false } : { value: null, unavailable: true }; + const hoursSaved: HoursSavedSummary = loc.unavailable || loc.value === null + ? { value: null, unavailable: true } + : { value: Math.round((loc.value / HUMAN_LINES_PER_HOUR) * 10) / 10, unavailable: false }; // Pull requests. `pull_requests.createdAt` is an INTEGER epoch-ms column, so // convert the ISO bounds to epoch ms for comparison. @@ -185,5 +208,6 @@ export function aggregateProductivityAnalytics( commits, pullRequests, loc, + hoursSaved, }; } diff --git a/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx b/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx index 41b3a02153..8718622839 100644 --- a/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx @@ -25,6 +25,9 @@ ProductivityAnalytics exposes a categorical language distribution but no per-day * * FNXC:CommandCenter 2026-06-19-00:00: * FN-6704 owns real commit diff-stat capture for LOC. Keep this sentinel honest until a persisted additions/deletions source exists; do not backfill with modified-file counts or any other proxy. + * + * 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. */ export function ProductivityArea({ range }: { range: DateRange }) { const { t } = useTranslation("app"); @@ -57,6 +60,7 @@ export function ProductivityArea({ range }: { range: DateRange }) { (data.modifiedFiles === 0 && data.commits === 0 && data.pullRequests === 0); const locUnavailable = !data || data.loc.unavailable || data.loc.value === null; + const hoursSavedUnavailable = !data || data.hoursSaved.unavailable || data.hoursSaved.value === null; return ( @@ -71,6 +75,26 @@ export function ProductivityArea({ range }: { range: DateRange }) {
{t("commandCenter.productivity.pullRequests", "Pull requests")}
{formatCount(data?.pullRequests ?? 0)}
+
+
{t("commandCenter.productivity.hoursSaved", "Human hours saved")}
+
+ {hoursSavedUnavailable ? ( + + — + + ) : ( + formatCount(data.hoursSaved.value ?? 0) + )} +
+ {t("commandCenter.productivity.hoursSavedEstimate", "estimate from lines changed")} +
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 c5bfc11e18..2a3458df38 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 as the dash sentinel, never 0 and keeps chart geometry finite", async () => { + it("renders unavailable LOC and hours saved as dash sentinels, never 0 and keeps chart geometry finite", async () => { apiMock.mockResolvedValue({ from: "2026-06-08", to: null, @@ -786,12 +786,17 @@ describe("ProductivityArea", () => { commits: 4, pullRequests: 2, loc: { value: null, unavailable: true }, + hoursSaved: { value: null, unavailable: true }, }); render(); await screen.findByTestId("cc-area-productivity"); const loc = screen.getByTestId("cc-productivity-loc-unavailable"); expect(loc.textContent).toBe("—"); expect(loc.getAttribute("title")).toBeTruthy(); + const hoursSaved = screen.getByTestId("cc-productivity-hours-saved-unavailable"); + expect(hoursSaved.textContent).toBe("—"); + expect(hoursSaved.getAttribute("title")).toBeTruthy(); + 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.getByRole("list", { name: "Files by language" })).toBeTruthy(); @@ -809,6 +814,7 @@ describe("ProductivityArea", () => { commits: 0, pullRequests: 0, loc: { value: null, unavailable: true }, + hoursSaved: { value: null, unavailable: true }, }); const { unmount } = render(); await screen.findByTestId("cc-area-productivity-empty"); @@ -837,6 +843,7 @@ describe("ProductivityArea", () => { commits: 0, pullRequests: 0, loc: { value: null, unavailable: true }, + hoursSaved: { value: null, unavailable: true }, }); render(); diff --git a/packages/dashboard/src/__tests__/command-center-csv.test.ts b/packages/dashboard/src/__tests__/command-center-csv.test.ts index bec7d9ed39..39d7b2ee65 100644 --- a/packages/dashboard/src/__tests__/command-center-csv.test.ts +++ b/packages/dashboard/src/__tests__/command-center-csv.test.ts @@ -5,9 +5,10 @@ import { serializeCsv, tokenAnalyticsToTable, activityAnalyticsToTable, + productivityAnalyticsToTable, type CsvTable, } from "../command-center-csv.js"; -import type { ActivityAnalytics, TokenAnalytics } from "@fusion/core"; +import type { ActivityAnalytics, ProductivityAnalytics, TokenAnalytics } from "@fusion/core"; describe("serializeCsv (RFC-4180)", () => { it("emits a header row and CRLF-terminated records", () => { @@ -91,6 +92,32 @@ describe("activityAnalyticsToTable", () => { }); }); +describe("productivityAnalyticsToTable", () => { + function result(hoursSaved: ProductivityAnalytics["hoursSaved"]): ProductivityAnalytics { + return { + from: null, + to: null, + modifiedFiles: 2, + byLanguage: [{ language: "ts", count: 2 }], + commits: 1, + pullRequests: 1, + loc: hoursSaved.unavailable ? { value: null, unavailable: true } : { value: 15, unavailable: false }, + hoursSaved, + }; + } + + it("exports hours saved and preserves the unavailable sentinel as an empty cell", () => { + expect(productivityAnalyticsToTable(result({ value: null, unavailable: true })).rows).toContainEqual([ + "hoursSaved", + "", + ]); + expect(productivityAnalyticsToTable(result({ value: 1, unavailable: false })).rows).toContainEqual([ + "hoursSaved", + 1, + ]); + }); +}); + describe("tokenAnalyticsToTable", () => { function emptyResult(): TokenAnalytics { return { 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 d83292ab20..82a4bf0382 100644 --- a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts +++ b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts @@ -310,6 +310,7 @@ describe("register-command-center-routes", () => { const prod = await request(app, "GET", `/api/command-center/productivity?${range}&projectId=proj-a`); expect(prod.status).toBe(200); expect(prod.body).toHaveProperty("loc"); + expect(prod.body).toHaveProperty("hoursSaved"); expect(prod.body).toHaveProperty("byLanguage"); seedGithubIssueMetrics(dbA, { prefix: "FN-A", repo: "acme/alpha", filed: 2, fixed: 1 }); diff --git a/packages/dashboard/src/command-center-csv.ts b/packages/dashboard/src/command-center-csv.ts index 2dd838a6bc..085b513b04 100644 --- a/packages/dashboard/src/command-center-csv.ts +++ b/packages/dashboard/src/command-center-csv.ts @@ -173,6 +173,7 @@ export function productivityAnalyticsToTable( rows.push(["commits", result.commits]); rows.push(["pullRequests", result.pullRequests]); rows.push(["loc", result.loc.value ?? ""]); + rows.push(["hoursSaved", result.hoursSaved.value ?? ""]); return { header, rows }; }