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:
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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`", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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-value">{formatCount(data?.pullRequests ?? 0)}</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>
|
||||
|
||||
|
||||
@@ -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(<ProductivityArea range={range7d} />);
|
||||
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(<ProductivityArea range={range7d} />);
|
||||
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(<ProductivityArea range={range7d} />);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user