diff --git a/.changeset/fn-7286-duration-trend.md b/.changeset/fn-7286-duration-trend.md new file mode 100644 index 0000000000..034d2d48f2 --- /dev/null +++ b/.changeset/fn-7286-duration-trend.md @@ -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. diff --git a/packages/core/src/__tests__/productivity-analytics.test.ts b/packages/core/src/__tests__/productivity-analytics.test.ts index 205c5569ec..9a0640bfec 100644 --- a/packages/core/src/__tests__/productivity-analytics.test.ts +++ b/packages/core/src/__tests__/productivity-analytics.test.ts @@ -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`", () => { diff --git a/packages/core/src/productivity-analytics.ts b/packages/core/src/productivity-analytics.ts index 5459366ea6..b91aa6e35d 100644 --- a/packages/core/src/productivity-analytics.ts +++ b/packages/core/src/productivity-analytics.ts @@ -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(); + 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, }; } diff --git a/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx b/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx index 7d8e4185b9..7015be6902 100644 --- a/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/ProductivityArea.tsx @@ -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 }) { + {hasDurationTrend ? ( +
+

+ {t("commandCenter.productivity.durationTrendTitle", "Task duration over time")} +

+ +
+ ) : null} +

{t("commandCenter.productivity.byLanguage", "Files by language")} diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test-harness.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test-harness.tsx index 28f8c015c0..8d6c9f8ca7 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test-harness.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test-harness.tsx @@ -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, + }, + ], }; } diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx index e444c2c301..9eeb52312e 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx @@ -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(); 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(); 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(); @@ -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(""); }); diff --git a/packages/dashboard/app/components/command-center/charts/recharts/LineChart.tsx b/packages/dashboard/app/components/command-center/charts/recharts/LineChart.tsx index 6d66b13fd6..5533eba96a 100644 --- a/packages/dashboard/app/components/command-center/charts/recharts/LineChart.tsx +++ b/packages/dashboard/app/components/command-center/charts/recharts/LineChart.tsx @@ -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 > - + `${value}%` : undefined} + tickFormatter={scaleMode === "series" ? (value) => `${value}%` : valueFormatter} />