diff --git a/.changeset/productivity-analytics-renamed-lanes.md b/.changeset/productivity-analytics-renamed-lanes.md new file mode 100644 index 0000000000..88715360d5 --- /dev/null +++ b/.changeset/productivity-analytics-renamed-lanes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Task-duration stats now include finished work on renamed boards. +category: fix +dev: `aggregateProductivityAnalytics` takes an optional lane store and resolves the complete columns via `resolveProjectColumnsForRoles`; its duration query previously filtered on the literal `'done'`. diff --git a/packages/core/src/__tests__/postgres/productivity-analytics-renamed-lanes.pg.test.ts b/packages/core/src/__tests__/postgres/productivity-analytics-renamed-lanes.pg.test.ts new file mode 100644 index 0000000000..2de02a2e13 --- /dev/null +++ b/packages/core/src/__tests__/postgres/productivity-analytics-renamed-lanes.pg.test.ts @@ -0,0 +1,135 @@ +/* +FNXC:WorkflowResolvedColumns 2026-07-30-19:30 (task-duration stats computed from an empty set): + +`aggregateProductivityAnalytics` filtered its duration query on `"column" = 'done'`. On a renamed +board that matches nothing, so the whole task-duration distribution — median, p90, the spread — is +computed from an EMPTY row set and reports zeros while the project ships work. Nothing errors. + +WHY NO EXISTING CHECK SAW IT. The lifecycle census parses TypeScript comparisons; this id lives +inside a SQL string. The sweep that converted this file's TypeScript guards left the query alone and +the file scored as converted. + +The cases are DIFFERENTIAL: the same finished task, aggregated under two vocabularies whose roles are +identical and only the ids differ. `shipped` collides with no legacy id, so a surviving `'done'` +cannot pass by luck. +*/ + +import { it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; +import { sql } from "drizzle-orm"; +import { + pgDescribe, + createSharedPgTaskStoreTestHarness, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; +import { aggregateProductivityAnalytics } from "../../productivity-analytics.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../../index.js"; + +const IN_RANGE = "2026-06-15T12:00:00.000Z"; +const RANGE = { from: "2026-06-01T00:00:00.000Z", to: "2026-06-30T23:59:59.999Z" }; + +pgDescribe("productivity analytics under a renamed board vocabulary", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_productivity_lanes", + }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + async function seedRenamedWorkflow(): Promise { + const RENAME: Record = { + todo: "drafting", + "in-progress": "building", + "in-review": "checking", + done: "shipped", + }; + const rename = (id: string | undefined) => (id && RENAME[id]) ?? id; + const ir = JSON.parse(JSON.stringify(BUILTIN_CODING_WORKFLOW_IR)) as { + id: string; + nodes?: { column?: string }[]; + columns?: { id: string }[]; + }; + ir.id = "custom:renamed-productivity"; + for (const node of ir.nodes ?? []) node.column = rename(node.column); + for (const column of ir.columns ?? []) column.id = rename(column.id) as string; + + const ids = (ir.columns ?? []).map((column) => column.id); + expect(ids).toContain("shipped"); + expect(ids).not.toContain("done"); + + await h.store().createWorkflowDefinition({ name: "Renamed", kind: "workflow", ir } as never); + } + + /** A finished task with real active/planning time, so it lands in the duration distribution. */ + async function seedFinishedTask(lane: string): Promise { + const store = h.store(); + await store.createTaskWithReservedId( + { description: "KB-DUR", column: "todo" }, + { taskId: "KB-DUR", createdAt: IN_RANGE, updatedAt: IN_RANGE, applyDefaultWorkflowSteps: false }, + ); + /* Seeded directly: the query needs execution_completed_at inside the range plus non-zero + cumulative time, neither of which moveTask would produce. */ + await h.adminDb().execute(sql` + UPDATE project.tasks + SET "column" = ${lane}, + execution_completed_at = ${IN_RANGE}, + cumulative_active_ms = 60000, + cumulative_planning_ms = 30000, + updated_at = ${IN_RANGE} + WHERE id = 'KB-DUR'`); + store.taskCache.delete("KB-DUR"); + } + + const run = (withStore: boolean) => aggregateProductivityAnalytics( + Object.assign(h.layer(), { projectId: "p1" }), + RANGE, + withStore ? h.store() : undefined, + ); + + /* Control: the default vocabulary sees the finished task. Passes before and after the fix. */ + it("default vocabulary: a finished task contributes to the duration stats", async () => { + await seedFinishedTask("done"); + + const result = await run(true); + + expect(result.taskDuration.completedTasks).toBe(1); + expect(result.taskDuration.medianMs).toBe(90_000); + }); + + /* The defect: before the fix this row set was empty on a renamed board. */ + it("renamed vocabulary: a task in the RENAMED complete lane contributes", async () => { + await seedRenamedWorkflow(); + await seedFinishedTask("shipped"); + + const result = await run(true); + + expect(result.taskDuration.completedTasks).toBe(1); + expect(result.taskDuration.medianMs).toBe(90_000); + }); + + /* + The paired negative: resolving the real lanes must not degrade into "every column is complete". + Unfinished work must stay out of the duration distribution, or the fix trades a zero for a wrong + median — worse, because a plausible number invites no scrutiny. + */ + it("renamed vocabulary: a task still in the WIP lane does NOT contribute", async () => { + await seedRenamedWorkflow(); + await seedFinishedTask("building"); + + const result = await run(true); + + expect(result.taskDuration.completedTasks).toBe(0); + expect(result.taskDuration.medianMs).toBeNull(); + }); + + /* Omitting the store must keep the legacy answer, so an unconverted caller is byte-identical. */ + it("without a lane store, the legacy id still answers", async () => { + await seedFinishedTask("done"); + + const result = await run(false); + + expect(result.taskDuration.completedTasks).toBe(1); + expect(result.taskDuration.medianMs).toBe(90_000); + }); +}); diff --git a/packages/core/src/productivity-analytics.ts b/packages/core/src/productivity-analytics.ts index 01b72c28c9..4b2159fd12 100644 --- a/packages/core/src/productivity-analytics.ts +++ b/packages/core/src/productivity-analytics.ts @@ -1,5 +1,6 @@ import { sql } from "drizzle-orm"; import type { Database } from "./db.js"; +import { resolveProjectColumnsForRoles, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js"; import type { AsyncDataLayer } from "./postgres/data-layer.js"; /** @@ -161,6 +162,22 @@ function nearestRankPercentile(sortedValues: readonly number[], percentile: numb export async function aggregateProductivityAnalytics( dbOrLayer: Database | AsyncDataLayer, query: ProductivityAnalyticsQuery = {}, + /* + FNXC:WorkflowResolvedColumns 2026-07-30-19:20: + The store, used ONLY to resolve which columns carry the `complete` trait. + + The duration query filtered on `"column" = 'done'`. That id lives inside a SQL string, which the + lifecycle census cannot see (it parses TypeScript comparisons), so the sweep that converted this + file's TS guards left it alone and the file scored as converted. On a renamed board the + task-duration distribution — median, p90, the whole spread — is computed from an EMPTY row set and + reports zeros while the project ships work. + + Resolved per PROJECT: this aggregates a whole project, so the union of the complete columns across + its workflows is the right set and a bound IN list is enough. + + Omitted, the legacy id answers, so an unconverted caller is byte-identical. + */ + laneStore?: ProjectLaneVocabularyStore, ): Promise { // FNXC:PostgresCommandCenterAnalytics 2026-06-27-10:00: // Backend (PostgreSQL) path. The async connection does not put `project` on @@ -172,7 +189,7 @@ export async function aggregateProductivityAnalytics( // epoch ms. Semantics (range columns, COALESCE/SUM/COUNT, statsRows gate, LOC // unavailable sentinel, duration percentiles) mirror the sync branch exactly. if ("ping" in dbOrLayer) { - return aggregateProductivityAnalyticsAsync(dbOrLayer, query); + return aggregateProductivityAnalyticsAsync(dbOrLayer, query, laneStore); } const db = dbOrLayer as Database; // Modified files: read the JSON array off tasks updated in range. @@ -347,7 +364,14 @@ export async function aggregateProductivityAnalytics( async function aggregateProductivityAnalyticsAsync( layer: AsyncDataLayer, query: ProductivityAnalyticsQuery, + laneStore?: ProjectLaneVocabularyStore, ): Promise { + const completeLanes = laneStore + ? [...await resolveProjectColumnsForRoles(laneStore, ["complete"])] + : ["done"]; + /* An IN list of bound parameters, not `= ANY(${array})`: drizzle expands a JS array in a template + into a tuple, which PostgreSQL rejects for ANY. Each id stays a parameter. */ + const completeIn = sql.join(completeLanes.map((lane) => sql`${lane}`), sql`, `); // Modified files: tasks updated in range whose modified_files is a non-empty // jsonb array. postgres-js returns jsonb already parsed. const mfFrom = query.from !== undefined ? sql`AND updated_at >= ${query.from}` : sql``; @@ -403,7 +427,7 @@ async function aggregateProductivityAnalyticsAsync( const durationRows = (await layer.db.execute( sql`SELECT cumulative_active_ms AS "cumulativeActiveMs", cumulative_planning_ms AS "cumulativePlanningMs", execution_completed_at AS "executionCompletedAt" FROM project.tasks - WHERE "column" = 'done' + WHERE "column" IN (${completeIn}) AND execution_completed_at IS NOT NULL AND (COALESCE(cumulative_active_ms, 0) + COALESCE(cumulative_planning_ms, 0)) > 0 ${dFrom} ${dTo} diff --git a/packages/dashboard/src/routes/register-command-center-routes.ts b/packages/dashboard/src/routes/register-command-center-routes.ts index 5ed8bb3e08..cf45a02e3a 100644 --- a/packages/dashboard/src/routes/register-command-center-routes.ts +++ b/packages/dashboard/src/routes/register-command-center-routes.ts @@ -348,7 +348,9 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { const result = await aggregateProductivityAnalytics(requireAsyncLayer(store, "Command Center productivity analytics"), { from: range.from, to: range.to, - }); + /* FNXC:WorkflowResolvedColumns 2026-07-30-19:25: store supplied so the duration distribution + resolves the board's complete lanes instead of the literal 'done'. */ + }, store); if (wantsCsv(req.query)) { sendCsv( res, diff --git a/scripts/lib/sql-column-literals-baseline.json b/scripts/lib/sql-column-literals-baseline.json index acbaa6abdc..4f96a88639 100644 --- a/scripts/lib/sql-column-literals-baseline.json +++ b/scripts/lib/sql-column-literals-baseline.json @@ -4,7 +4,7 @@ "packages/core/src/github-issue-analytics.ts": 2, "packages/core/src/gitlab-issue-analytics.ts": 2, "packages/core/src/mission-store.ts": 1, - "packages/core/src/productivity-analytics.ts": 2, + "packages/core/src/productivity-analytics.ts": 1, "packages/core/src/task-store/async-archive-lineage.ts": 3, "packages/core/src/task-store/async-maintenance.ts": 1, "packages/core/src/task-store/async-merge-coordination.ts": 1,