FN-7286: add productivity duration trends

Expose completed-task duration trends in Command Center productivity analytics.

- Add daily average and median active-duration buckets to productivity analytics.
- Render a duration-over-time line chart when qualifying completed-task history exists.
- Extend chart formatting and tests for duration labels, empty trend states, and analytics aggregation.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/fn-7286-duration-trend.md               |  7 +++
 .../src/__tests__/productivity-analytics.test.ts   | 50 +++++++++++++++++++++
 packages/core/src/productivity-analytics.ts        | 39 +++++++++++++++-
 .../command-center/areas/ProductivityArea.tsx      | 52 +++++++++++++++++++++-
 .../areas/__tests__/areas.test-harness.tsx         | 16 +++++++
 .../command-center/areas/__tests__/areas.test.tsx  | 46 +++++++++++++++++++
 .../command-center/charts/recharts/LineChart.tsx   | 21 +++++++--
 7 files changed, 225 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-7286

Fusion-Task-Lineage: f9c486d0-dd8c-4736-9e8a-e658b7faecc1

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 10:30:57 -07:00
parent 6b3eec0a53
commit f0a15db85c
7 changed files with 225 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add Command Center task-duration trend lines for average and median completed active time.
category: feature
dev: Extends productivity analytics with taskDurationTrend buckets sourced from completed task cumulativeActiveMs.

View File

@@ -170,6 +170,54 @@ describe("productivity-analytics", () => {
});
});
it("buckets completed-task duration trend by executionCompletedAt day with average and median", () => {
insertCompletedTask(db, "day2-short", { cumulativeActiveMs: 30 * 60 * 1000, executionCompletedAt: "2026-03-02T09:00:00.000Z" });
insertCompletedTask(db, "day1-short", { cumulativeActiveMs: 30 * 60 * 1000, executionCompletedAt: "2026-03-01T09:00:00.000Z" });
insertCompletedTask(db, "day1-mid", { cumulativeActiveMs: 60 * 60 * 1000, executionCompletedAt: "2026-03-01T10:00:00.000Z" });
insertCompletedTask(db, "day1-long", { cumulativeActiveMs: 120 * 60 * 1000, executionCompletedAt: "2026-03-01T11:00:00.000Z" });
insertCompletedTask(db, "day2-long", { cumulativeActiveMs: 90 * 60 * 1000, executionCompletedAt: "2026-03-02T12:00:00.000Z" });
const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T23:59:59.999Z" });
expect(result.taskDurationTrend).toEqual([
{
bucket: "2026-03-01",
completedTasks: 3,
averageMs: 70 * 60 * 1000,
medianMs: 60 * 60 * 1000,
unavailable: false,
},
{
bucket: "2026-03-02",
completedTasks: 2,
averageMs: 60 * 60 * 1000,
medianMs: 60 * 60 * 1000,
unavailable: false,
},
]);
});
it("excludes non-qualifying tasks from completed-task duration trend without fake zero buckets", () => {
insertCompletedTask(db, "before", { cumulativeActiveMs: 10_000, executionCompletedAt: "2026-02-28T23:59:59.999Z" });
insertCompletedTask(db, "todo", { cumulativeActiveMs: 20_000, executionCompletedAt: "2026-03-01T00:00:00.000Z", column: "todo" });
insertCompletedTask(db, "null-duration", { cumulativeActiveMs: null, executionCompletedAt: "2026-03-02T00:00:00.000Z" });
insertCompletedTask(db, "zero-duration", { cumulativeActiveMs: 0, executionCompletedAt: "2026-03-03T00:00:00.000Z" });
insertCompletedTask(db, "valid", { cumulativeActiveMs: 45_000, executionCompletedAt: "2026-03-04T00:00:00.000Z" });
insertCompletedTask(db, "after", { cumulativeActiveMs: 30_000, executionCompletedAt: "2026-04-01T00:00:00.000Z" });
const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T23:59:59.999Z" });
expect(result.taskDurationTrend).toEqual([
{
bucket: "2026-03-04",
completedTasks: 1,
averageMs: 45_000,
medianMs: 45_000,
unavailable: false,
},
]);
expect(result.taskDurationTrend).not.toContainEqual(expect.objectContaining({ averageMs: 0 }));
expect(result.taskDurationTrend).not.toContainEqual(expect.objectContaining({ medianMs: 0 }));
});
it("excludes completed-task durations outside the executionCompletedAt range", () => {
insertCompletedTask(db, "before", { cumulativeActiveMs: 9_000, executionCompletedAt: "2026-02-28T23:59:59.999Z" });
insertCompletedTask(db, "inside", { cumulativeActiveMs: 2_000, executionCompletedAt: "2026-03-01T00:00:00.000Z" });
@@ -220,6 +268,7 @@ describe("productivity-analytics", () => {
expect(result.taskDuration.medianMs).not.toBe(0);
expect(result.taskDuration.p90Ms).not.toBe(0);
expect(result.taskDuration.totalMs).not.toBe(0);
expect(result.taskDurationTrend).toEqual([]);
});
it("empty range returns zeroed structures, not nulls", () => {
@@ -246,6 +295,7 @@ describe("productivity-analytics", () => {
unavailable: true,
});
expect(result.taskDuration.totalMs).not.toBe(0);
expect(result.taskDurationTrend).toEqual([]);
});
it("includes a boundary task exactly at `from`", () => {

View File

@@ -73,6 +73,18 @@ export interface TaskDurationSummary {
unavailable: boolean;
}
/**
* FNXC:CommandCenterProductivity 2026-06-30-10:17:
* Operators need average and median task active duration over time from real completed-task `cumulativeActiveMs` history. Trend buckets are emitted only for days with qualifying completed tasks; missing history must stay absent/unavailable, never fabricated as zero-duration chart points.
*/
export interface TaskDurationTrendBucket {
bucket: string;
completedTasks: number;
averageMs: number | null;
medianMs: number | null;
unavailable: boolean;
}
export interface ProductivityAnalytics {
from: string | null;
to: string | null;
@@ -90,6 +102,8 @@ export interface ProductivityAnalytics {
hoursSaved: HoursSavedSummary;
/** Active execution duration for done tasks completed in range. */
taskDuration: TaskDurationSummary;
/** Per-day active execution duration for done tasks completed in range. */
taskDurationTrend: TaskDurationTrendBucket[];
}
interface CountRow {
@@ -109,6 +123,7 @@ interface ModifiedFilesRow {
interface TaskDurationRow {
cumulativeActiveMs: number;
executionCompletedAt: string;
}
/** Extract a coarse language key from a file path (its lowercased extension). */
@@ -234,10 +249,10 @@ export function aggregateProductivityAnalytics(
}
const durationRows = db
.prepare(
`SELECT cumulativeActiveMs FROM tasks WHERE ${durationClauses.join(" AND ")} ORDER BY cumulativeActiveMs ASC`,
`SELECT cumulativeActiveMs, executionCompletedAt FROM tasks WHERE ${durationClauses.join(" AND ")} ORDER BY executionCompletedAt ASC`,
)
.all(...durationParams) as TaskDurationRow[];
const durations = durationRows.map((row) => row.cumulativeActiveMs);
const durations = durationRows.map((row) => row.cumulativeActiveMs).sort((a, b) => a - b);
const totalDurationMs = durations.reduce((sum, durationMs) => sum + durationMs, 0);
const taskDuration: TaskDurationSummary = durations.length > 0
? {
@@ -257,6 +272,25 @@ export function aggregateProductivityAnalytics(
unavailable: true,
};
const durationBuckets = new Map<string, number[]>();
for (const row of durationRows) {
const bucket = row.executionCompletedAt.slice(0, 10);
const bucketDurations = durationBuckets.get(bucket) ?? [];
bucketDurations.push(row.cumulativeActiveMs);
durationBuckets.set(bucket, bucketDurations);
}
const taskDurationTrend: TaskDurationTrendBucket[] = [...durationBuckets.entries()].map(([bucket, bucketDurations]) => {
const sortedBucketDurations = [...bucketDurations].sort((a, b) => a - b);
const bucketTotalMs = sortedBucketDurations.reduce((sum, durationMs) => sum + durationMs, 0);
return {
bucket,
completedTasks: sortedBucketDurations.length,
averageMs: sortedBucketDurations.length > 0 ? bucketTotalMs / sortedBucketDurations.length : null,
medianMs: median(sortedBucketDurations),
unavailable: sortedBucketDurations.length === 0,
};
});
// Pull requests. `pull_requests.createdAt` is an INTEGER epoch-ms column, so
// convert the ISO bounds to epoch ms for comparison.
const prClauses: string[] = [];
@@ -286,5 +320,6 @@ export function aggregateProductivityAnalytics(
loc,
hoursSaved,
taskDuration,
taskDurationTrend,
};
}

View File

@@ -9,7 +9,7 @@ import {
import { useConfirm } from "../../../hooks/useConfirm";
import type { DateRange } from "../DateRangePicker";
import { Bar } from "../charts/Bar";
import { PieChart } from "../charts/recharts";
import { LineChart, PieChart } from "../charts/recharts";
import { AreaShell } from "./AreaShell";
import { useAnalyticsArea } from "./useAnalyticsArea";
import { formatCount, formatDurationMs } from "./areaShared";
@@ -133,6 +133,42 @@ export function ProductivityArea({ range }: { range: DateRange }) {
totalMs: null,
unavailable: true,
};
const taskDurationTrend = useMemo(
() =>
(data?.taskDurationTrend ?? [])
.filter(
(bucket) =>
!bucket.unavailable
&& bucket.completedTasks > 0
&& bucket.averageMs !== null
&& bucket.medianMs !== null
&& Number.isFinite(bucket.averageMs)
&& Number.isFinite(bucket.medianMs),
),
[data?.taskDurationTrend],
);
const durationTrendSeries = useMemo(
() => [
{
label: t("commandCenter.productivity.averageDuration", "Average"),
values: taskDurationTrend.map((bucket) => bucket.averageMs ?? 0),
},
{
label: t("commandCenter.productivity.medianDuration", "Median"),
values: taskDurationTrend.map((bucket) => bucket.medianMs ?? 0),
},
],
[t, taskDurationTrend],
);
const durationTrendLabels = useMemo(
() => taskDurationTrend.map((bucket) => bucket.bucket),
[taskDurationTrend],
);
const hasDurationTrend = taskDurationTrend.length > 0;
/*
FNXC:CommandCenterProductivity 2026-06-30-10:32:
Operators need an average/median task-duration line graph sourced only from completed-task active execution history. Hide the graph for loading, error, legacy, empty, and no-trend payloads rather than rendering an empty shell or converting missing buckets to zero.
*/
/*
FNXC:CommandCenterProductivity 2026-06-22-00:32:
Backfill-era dashboard tests and cached clients can render ProductivityArea with legacy productivity payloads that predate LOC and hours-saved summaries. Treat missing nested summaries as unavailable sentinels so the whole Command Center remains mounted instead of crashing during responsive-layout verification.
@@ -339,6 +375,20 @@ export function ProductivityArea({ range }: { range: DateRange }) {
</div>
</div>
{hasDurationTrend ? (
<div className="cc-area-section" data-testid="cc-productivity-duration-trend">
<h3 className="cc-area-section-title">
{t("commandCenter.productivity.durationTrendTitle", "Task duration over time")}
</h3>
<LineChart
series={durationTrendSeries}
ariaLabel={t("commandCenter.productivity.durationTrendTitle", "Task duration over time")}
xAxisLabels={durationTrendLabels}
valueFormatter={formatDurationMs}
/>
</div>
) : null}
<div className="cc-area-section">
<h3 className="cc-area-section-title">
{t("commandCenter.productivity.byLanguage", "Files by language")}

View File

@@ -112,6 +112,22 @@ export function productivityFixture() {
totalMs: 270 * 60 * 1000,
unavailable: false,
},
taskDurationTrend: [
{
bucket: "2026-06-08",
completedTasks: 1,
averageMs: 60 * 60 * 1000,
medianMs: 60 * 60 * 1000,
unavailable: false,
},
{
bucket: "2026-06-09",
completedTasks: 2,
averageMs: 105 * 60 * 1000,
medianMs: 105 * 60 * 1000,
unavailable: false,
},
],
};
}

View File

@@ -847,6 +847,22 @@ describe("ProductivityArea", () => {
totalMs: 16_200_000,
unavailable: false,
},
taskDurationTrend: [
{
bucket: "2026-06-08",
completedTasks: 1,
averageMs: 3_600_000,
medianMs: 3_600_000,
unavailable: false,
},
{
bucket: "2026-06-09",
completedTasks: 2,
averageMs: 6_300_000,
medianMs: 6_300_000,
unavailable: false,
},
],
});
render(<ProductivityArea range={range7d} />);
await screen.findByTestId("cc-area-productivity");
@@ -864,6 +880,13 @@ describe("ProductivityArea", () => {
expect(screen.getByTestId("cc-productivity-duration-median").textContent).toContain("1h");
expect(screen.getByTestId("cc-productivity-duration-p90").textContent).toContain("2h");
expect(screen.getByTestId("cc-productivity-duration-total").textContent).toContain("4h 30m");
const durationTrend = screen.getByRole("img", { name: "Task duration over time" });
expect(durationTrend).toBeTruthy();
expect(durationTrend.getAttribute("data-responsive-width")).toBe("100%");
expect(screen.getByTestId("cc-productivity-duration-trend").textContent).toContain("Average");
expect(screen.getByTestId("cc-productivity-duration-trend").textContent).toContain("Median");
expect(screen.getByTestId("cc-productivity-duration-trend").textContent).not.toContain("NaN");
expect(screen.getByTestId("cc-productivity-duration-trend").textContent).not.toContain("Infinity");
expect(screen.getByRole("list", { name: "Files by language" })).toBeTruthy();
expect(screen.getByTestId("cc-productivity-pie")).toBeTruthy();
expect(screen.getByRole("img", { name: "Language share" })).toBeTruthy();
@@ -888,12 +911,14 @@ describe("ProductivityArea", () => {
totalMs: null,
unavailable: true,
},
taskDurationTrend: [],
});
const { unmount } = render(<ProductivityArea range={range7d} />);
await screen.findByTestId("cc-area-productivity-empty");
expect(screen.queryByRole("list", { name: "Files by language" })).toBeNull();
expect(screen.queryByTestId("cc-productivity-pie")).toBeNull();
expect(screen.queryByTestId("cc-productivity-duration-avg")).toBeNull();
expect(screen.queryByTestId("cc-productivity-duration-trend")).toBeNull();
unmount();
apiMock.mockImplementationOnce(() => new Promise(() => undefined));
@@ -907,6 +932,7 @@ describe("ProductivityArea", () => {
expect(screen.getByTestId("cc-area-productivity-error").textContent).toContain("productivity failed");
expect(screen.queryByTestId("cc-productivity-pie")).toBeNull();
expect(screen.queryByTestId("cc-productivity-duration-avg")).toBeNull();
expect(screen.queryByTestId("cc-productivity-duration-trend")).toBeNull();
});
it("renders unavailable task duration as dash sentinels, never zero", async () => {
@@ -927,6 +953,22 @@ describe("ProductivityArea", () => {
totalMs: null,
unavailable: true,
},
taskDurationTrend: [
{
bucket: "2026-06-08",
completedTasks: 0,
averageMs: null,
medianMs: null,
unavailable: true,
},
{
bucket: "2026-06-09",
completedTasks: 1,
averageMs: Number.NaN,
medianMs: Number.POSITIVE_INFINITY,
unavailable: false,
},
],
});
render(<ProductivityArea range={range7d} />);
@@ -939,6 +981,9 @@ describe("ProductivityArea", () => {
expect(screen.getByTestId("cc-productivity-duration-p90-unavailable").textContent).toBe("—");
expect(screen.getByTestId("cc-productivity-duration-total-unavailable").textContent).toBe("—");
expect(screen.getByTestId("cc-productivity-duration-avg").textContent).not.toContain("0");
expect(screen.queryByTestId("cc-productivity-duration-trend")).toBeNull();
expect(screen.getByTestId("cc-area-productivity").textContent).not.toContain("NaN");
expect(screen.getByTestId("cc-area-productivity").textContent).not.toContain("Infinity");
});
it("renders dash sentinels for contract-incomplete productivity payloads", async () => {
@@ -960,6 +1005,7 @@ describe("ProductivityArea", () => {
expect(screen.getByTestId("cc-productivity-duration-median-unavailable").textContent).toBe("—");
expect(screen.getByTestId("cc-productivity-duration-p90-unavailable").textContent).toBe("—");
expect(screen.getByTestId("cc-productivity-duration-total-unavailable").textContent).toBe("—");
expect(screen.queryByTestId("cc-productivity-duration-trend")).toBeNull();
expect(area.textContent).not.toContain("NaN");
expect(area.textContent?.trim()).not.toBe("");
});

View File

@@ -22,6 +22,8 @@ export interface LineChartProps {
width?: number | string;
height?: number | string;
emptyLabel?: string;
xAxisLabels?: string[];
valueFormatter?: (value: number) => string;
/**
* `shared` keeps comparable values on one absolute axis. `series` normalizes each series to its own max so mixed-unit trends remain legible.
*/
@@ -183,13 +185,24 @@ function useMeasuredChartDimensions() {
*
* FNXC:CommandCenterCharts 2026-06-23-08:47:
* Daily activity line and token/model line graphs must load even when Recharts cannot resolve a percentage `ResponsiveContainer` during the card's first layout pass. Measure the chart wrapper directly and pass concrete usable dimensions into Recharts, with a first-paint fallback that is replaced by ResizeObserver only after the observed box is large enough to draw a legible chart.
*
* FNXC:CommandCenterProductivity 2026-06-30-10:25:
* Duration trend callers need the shared line chart to keep real millisecond values while labeling axes/tooltips as human durations and dates. Formatting stays opt-in so existing Command Center charts preserve their numeric legends and normalized mixed-unit behavior.
*/
export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No chart data", scaleMode = "shared" }: LineChartProps) {
export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No chart data", xAxisLabels, valueFormatter, scaleMode = "shared" }: LineChartProps) {
const theme = getCommandCenterChartTheme();
const { ref, dimensions } = useMeasuredChartDimensions();
const chartDimensions = resolvedDimensions(dimensions, width, height);
const chartSeries = sanitizeSeries(series, scaleMode);
const chartData = lineChartData(chartSeries);
const formatXAxisLabel = (value: unknown) => {
const index = typeof value === "number" ? value : Number(value);
return Number.isFinite(index) ? (xAxisLabels?.[index - 1] ?? String(value)) : String(value);
};
const formatChartValue = (value: unknown) => {
const numericValue = typeof value === "number" ? value : Number(value);
return valueFormatter && Number.isFinite(numericValue) ? valueFormatter(numericValue) : String(value);
};
if (chartSeries.length === 0 || chartData.length === 0) {
return (
@@ -212,12 +225,12 @@ export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No c
>
<RechartsLineChart width={chartDimensions.width} height={chartDimensions.height} data={chartData}>
<CartesianGrid stroke={theme.grid} />
<XAxis dataKey="index" stroke={theme.tick} tick={{ fill: theme.tick }} />
<XAxis dataKey="index" stroke={theme.tick} tick={{ fill: theme.tick }} tickFormatter={xAxisLabels ? formatXAxisLabel : undefined} />
<YAxis
stroke={theme.tick}
tick={{ fill: theme.tick }}
domain={scaleMode === "series" ? [0, 100] : undefined}
tickFormatter={scaleMode === "series" ? (value) => `${value}%` : undefined}
tickFormatter={scaleMode === "series" ? (value) => `${value}%` : valueFormatter}
/>
<Tooltip
contentStyle={{
@@ -227,6 +240,8 @@ export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No c
}}
itemStyle={{ color: theme.tooltipText }}
labelStyle={{ color: theme.tooltipText }}
formatter={valueFormatter ? (value) => formatChartValue(value) : undefined}
labelFormatter={xAxisLabels ? formatXAxisLabel : undefined}
/>
<Legend wrapperStyle={{ color: theme.legendText }} />
{chartSeries.map((entry, index) => (