FN-6721: estimate productivity hours saved

Estimate human hours saved from productivity LOC analytics and expose it consistently.

- Add a shared human-lines-per-hour conversion and hoursSaved summary to productivity analytics.
- Show human hours saved in the Command Center productivity area with unavailable-state handling.
- Include hoursSaved in productivity CSV exports, docs, tests, and the release changeset.

Files changed:
 .changeset/fuzzy-productivity-hours.md             |  5 ++++
 docs/architecture.md                               |  4 +--
 docs/storage.md                                    |  4 +--
 .../src/__tests__/productivity-analytics.test.ts   | 29 ++++++++++++++++------
 packages/core/src/index.ts                         |  3 ++-
 packages/core/src/productivity-analytics.ts        | 28 +++++++++++++++++++--
 .../command-center/areas/ProductivityArea.tsx      | 24 ++++++++++++++++++
 .../command-center/areas/__tests__/areas.test.tsx  |  9 ++++++-
 .../src/__tests__/command-center-csv.test.ts       | 29 +++++++++++++++++++++-
 .../register-command-center-routes.test.ts         |  1 +
 packages/dashboard/src/command-center-csv.ts       |  1 +
 11 files changed, 120 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-6721

Fusion-Task-Lineage: 80114bfc-7d7f-4659-983a-9488660e5f0f
This commit is contained in:
gsxdsm
2026-06-19 15:50:07 -07:00
parent c158dda8a6
commit 59d3eee7f4
11 changed files with 120 additions and 17 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add estimated human hours saved to Command Center Productivity analytics, UI stats, and CSV exports.

View File

@@ -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 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 - 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.
@@ -1459,7 +1459,7 @@ Dashboard session-diff route registration (`packages/dashboard/src/routes/regist
- `legacy` = recovered via legacy task-id/subject matching - `legacy` = recovered via legacy task-id/subject matching
- `ambiguous` = manual reconciliation where historical task-id attribution could be misleading - `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 ### Done-task files-changed sources of truth

View File

@@ -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 `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). | | `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. |
| `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). | | `archivedTasks` | Archived task snapshots (compact JSON payload + archive timestamp). |
| `automations` | Scheduled automation definitions, run state, and run history. | | `automations` | Scheduled automation definitions, run state, and run history. |
| `agents` | Agent registry/state/task assignment metadata. | | `agents` | Agent registry/state/task assignment metadata. |

View File

@@ -5,7 +5,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { Database } from "../db.js"; 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 { function insertTaskWithFiles(db: Database, id: string, files: string[], updatedAt: string): void {
db.prepare( db.prepare(
@@ -86,19 +86,24 @@ describe("productivity-analytics", () => {
const result = aggregateProductivityAnalytics(db, {}); const result = aggregateProductivityAnalytics(db, {});
expect(result.loc).toEqual({ value: null, unavailable: true }); expect(result.loc).toEqual({ value: null, unavailable: true });
expect(result.loc.value).not.toBe(0); 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, "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 }); 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" }); 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.commits).toBe(1);
expect(result.loc).toEqual({ value: 20, unavailable: false }); 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, "c1", "sha1", "2026-03-01T00:00:00.000Z");
insertCommit(db, "c2", "sha2", "2026-03-02T00:00:00.000Z", { additions: null, deletions: null }); 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.commits).toBe(2);
expect(result.loc).toEqual({ value: null, unavailable: true }); expect(result.loc).toEqual({ value: null, unavailable: true });
expect(result.loc.value).not.toBe(0); 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-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-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 }); 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" }); 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.commits).toBe(3);
expect(result.loc).toEqual({ value: 11, unavailable: false }); 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", () => { it("empty range returns zeroed structures, not nulls", () => {
@@ -128,8 +139,10 @@ describe("productivity-analytics", () => {
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 unavailable regardless of range // LOC and derived hours 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.value).not.toBe(0);
}); });
it("includes a boundary task exactly at `from`", () => { it("includes a boundary task exactly at `from`", () => {

View File

@@ -571,12 +571,13 @@ export type {
MttrSummary, MttrSummary,
MonitorMetrics, MonitorMetrics,
} from "./activity-analytics.js"; } from "./activity-analytics.js";
export { aggregateProductivityAnalytics } from "./productivity-analytics.js"; export { aggregateProductivityAnalytics, HUMAN_LINES_PER_HOUR } from "./productivity-analytics.js";
export type { export type {
ProductivityAnalytics, ProductivityAnalytics,
ProductivityAnalyticsQuery, ProductivityAnalyticsQuery,
LanguageCount, LanguageCount,
LocSummary, LocSummary,
HoursSavedSummary,
} from "./productivity-analytics.js"; } from "./productivity-analytics.js";
export { aggregatePluginActivations } from "./plugin-activation-analytics.js"; export { aggregatePluginActivations } from "./plugin-activation-analytics.js";
export type { export type {

View File

@@ -3,7 +3,8 @@ import type { Database } from "./db.js";
/** /**
* Productivity analytics: files modified (count + language distribution) from * Productivity analytics: files modified (count + language distribution) from
* `tasks.modifiedFiles`, commit associations from `task_commit_associations`, * `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 * **LOC availability.** Fusion persists nullable `additions`/`deletions` on
* `task_commit_associations` when merge paths can capture git shortstat output. * `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 * has non-null stats. If the range has no recorded stats, the documented
* unavailable sentinel — `{ value: null, unavailable: true }` — is preserved, * unavailable sentinel — `{ value: null, unavailable: true }` — is preserved,
* **never `0`**, so missing historical data is not mistaken for "zero lines * **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 * 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); 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 { export interface ProductivityAnalyticsQuery {
/** ISO-8601 lower bound (inclusive). */ /** ISO-8601 lower bound (inclusive). */
from?: string; from?: string;
@@ -41,6 +49,16 @@ export interface LocSummary {
unavailable: boolean; 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 { export interface ProductivityAnalytics {
from: string | null; from: string | null;
to: string | null; to: string | null;
@@ -54,6 +72,8 @@ export interface ProductivityAnalytics {
pullRequests: number; pullRequests: number;
/** LOC from commit association diff stats when at least one in-range row has stats. */ /** LOC from commit association diff stats when at least one in-range row has stats. */
loc: LocSummary; loc: LocSummary;
/** Estimated human-hours equivalent derived from `loc` when LOC is available. */
hoursSaved: HoursSavedSummary;
} }
interface CountRow { interface CountRow {
@@ -157,6 +177,9 @@ export function aggregateProductivityAnalytics(
const loc: LocSummary = commitStats.statsRows > 0 const loc: LocSummary = commitStats.statsRows > 0
? { value: (commitStats.additions ?? 0) + (commitStats.deletions ?? 0), unavailable: false } ? { value: (commitStats.additions ?? 0) + (commitStats.deletions ?? 0), unavailable: false }
: { value: null, unavailable: true }; : { 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 // 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.
@@ -185,5 +208,6 @@ export function aggregateProductivityAnalytics(
commits, commits,
pullRequests, pullRequests,
loc, loc,
hoursSaved,
}; };
} }

View File

@@ -25,6 +25,9 @@ ProductivityAnalytics exposes a categorical language distribution but no per-day
* *
* FNXC:CommandCenter 2026-06-19-00:00: * 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. * 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 }) { export function ProductivityArea({ range }: { range: DateRange }) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
@@ -57,6 +60,7 @@ export function ProductivityArea({ range }: { range: DateRange }) {
(data.modifiedFiles === 0 && data.commits === 0 && data.pullRequests === 0); (data.modifiedFiles === 0 && data.commits === 0 && data.pullRequests === 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;
return ( return (
<AreaShell testId="productivity" isLoading={isLoading} error={error} isEmpty={isEmpty}> <AreaShell testId="productivity" isLoading={isLoading} error={error} isEmpty={isEmpty}>
@@ -71,6 +75,26 @@ export function ProductivityArea({ range }: { range: DateRange }) {
<div className="cc-stat-label">{t("commandCenter.productivity.pullRequests", "Pull requests")}</div> <div className="cc-stat-label">{t("commandCenter.productivity.pullRequests", "Pull requests")}</div>
<div className="cc-stat-value">{formatCount(data?.pullRequests ?? 0)}</div> <div className="cc-stat-value">{formatCount(data?.pullRequests ?? 0)}</div>
</div> </div>
<div className="card cc-stat-card" data-testid="cc-productivity-hours-saved">
<div className="cc-stat-label">{t("commandCenter.productivity.hoursSaved", "Human hours saved")}</div>
<div className="cc-stat-value">
{hoursSavedUnavailable ? (
<span
className="cc-unavailable"
title={t(
"commandCenter.productivity.hoursSavedUnavailable",
"Estimated human hours saved is unavailable until commit diff stats exist",
)}
data-testid="cc-productivity-hours-saved-unavailable"
>
—
</span>
) : (
formatCount(data.hoursSaved.value ?? 0)
)}
</div>
<span className="cc-stat-sub">{t("commandCenter.productivity.hoursSavedEstimate", "estimate from lines changed")}</span>
</div>
</div> </div>
</div> </div>

View File

@@ -777,7 +777,7 @@ describe("ToolsArea", () => {
}); });
describe("ProductivityArea", () => { 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({ apiMock.mockResolvedValue({
from: "2026-06-08", from: "2026-06-08",
to: null, to: null,
@@ -786,12 +786,17 @@ describe("ProductivityArea", () => {
commits: 4, commits: 4,
pullRequests: 2, pullRequests: 2,
loc: { value: null, unavailable: true }, loc: { value: null, unavailable: true },
hoursSaved: { value: null, unavailable: true },
}); });
render(<ProductivityArea range={range7d} />); render(<ProductivityArea range={range7d} />);
await screen.findByTestId("cc-area-productivity"); await screen.findByTestId("cc-area-productivity");
const loc = screen.getByTestId("cc-productivity-loc-unavailable"); const loc = screen.getByTestId("cc-productivity-loc-unavailable");
expect(loc.textContent).toBe("—"); expect(loc.textContent).toBe("—");
expect(loc.getAttribute("title")).toBeTruthy(); 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. // The commits outcome counter still shows a real number.
expect(screen.getByTestId("cc-productivity-commits").textContent).toContain("4"); expect(screen.getByTestId("cc-productivity-commits").textContent).toContain("4");
expect(screen.getByRole("list", { name: "Files by language" })).toBeTruthy(); expect(screen.getByRole("list", { name: "Files by language" })).toBeTruthy();
@@ -809,6 +814,7 @@ describe("ProductivityArea", () => {
commits: 0, commits: 0,
pullRequests: 0, pullRequests: 0,
loc: { value: null, unavailable: true }, loc: { value: null, unavailable: true },
hoursSaved: { value: 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");
@@ -837,6 +843,7 @@ describe("ProductivityArea", () => {
commits: 0, commits: 0,
pullRequests: 0, pullRequests: 0,
loc: { value: null, unavailable: true }, loc: { value: null, unavailable: true },
hoursSaved: { value: null, unavailable: true },
}); });
render(<ProductivityArea range={range7d} />); render(<ProductivityArea range={range7d} />);

View File

@@ -5,9 +5,10 @@ import {
serializeCsv, serializeCsv,
tokenAnalyticsToTable, tokenAnalyticsToTable,
activityAnalyticsToTable, activityAnalyticsToTable,
productivityAnalyticsToTable,
type CsvTable, type CsvTable,
} from "../command-center-csv.js"; } from "../command-center-csv.js";
import type { ActivityAnalytics, TokenAnalytics } from "@fusion/core"; import type { ActivityAnalytics, ProductivityAnalytics, TokenAnalytics } from "@fusion/core";
describe("serializeCsv (RFC-4180)", () => { describe("serializeCsv (RFC-4180)", () => {
it("emits a header row and CRLF-terminated records", () => { 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", () => { describe("tokenAnalyticsToTable", () => {
function emptyResult(): TokenAnalytics { function emptyResult(): TokenAnalytics {
return { return {

View File

@@ -310,6 +310,7 @@ describe("register-command-center-routes", () => {
const prod = await request(app, "GET", `/api/command-center/productivity?${range}&projectId=proj-a`); const prod = await request(app, "GET", `/api/command-center/productivity?${range}&projectId=proj-a`);
expect(prod.status).toBe(200); expect(prod.status).toBe(200);
expect(prod.body).toHaveProperty("loc"); expect(prod.body).toHaveProperty("loc");
expect(prod.body).toHaveProperty("hoursSaved");
expect(prod.body).toHaveProperty("byLanguage"); expect(prod.body).toHaveProperty("byLanguage");
seedGithubIssueMetrics(dbA, { prefix: "FN-A", repo: "acme/alpha", filed: 2, fixed: 1 }); seedGithubIssueMetrics(dbA, { prefix: "FN-A", repo: "acme/alpha", filed: 2, fixed: 1 });

View File

@@ -173,6 +173,7 @@ export function productivityAnalyticsToTable(
rows.push(["commits", result.commits]); rows.push(["commits", result.commits]);
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 ?? ""]);
return { header, rows }; return { header, rows };
} }