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:
5
.changeset/fuzzy-productivity-hours.md
Normal file
5
.changeset/fuzzy-productivity-hours.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add estimated human hours saved to Command Center Productivity analytics, UI stats, and CSV exports.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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`", () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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} />);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user