diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index f0f47b2162..94d6a7180e 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -401,9 +401,32 @@ Module-scope, takes only a `Task`, and has no flags to consult. Converting it me resolved flags through a pure duration helper or resolving a workflow inside it — the same shape flagged at `project-engine.ts:2555` and `github-tracking-comments.ts:165`. Left counted so the census keeps pointing at the class rather than at me having decided it away. + +FNXC:WorkflowResolvedColumns 2026-07-31-23:59 — THAT BLOCKER HAS SINCE EXPIRED, and the evidence is in +this file. + +"Has no flags to consult" was true when written and is not true now. `taskColumnFlags` is a prop of +this component, destructured and already consumed by `isWipColumnRole` / `isReviewColumnRole` a few +hundred lines below, and `TaskContextMenuColumnFlags` carries `complete`. The sibling duration helpers +here were threaded for exactly this reason — `getTotalAgentActiveMs` carries the note "THREADED SO THE +CONVERSION IS NOT INERT". This helper has ONE caller, inside the component, where the flags are in +scope. + +So the threading the note called prohibitive is already done; only this helper was left behind. The +flags are OPTIONAL and the legacy id remains the fallback (`isCompleteColumnRole`), so a caller without +resolved flags behaves exactly as before. + +WHAT THE LITERAL COST: on a board whose complete lane is renamed, `task.column === "done"` matched +nothing, so a finished card showed its execution time WITHOUT the completion timestamp — the "done N +ago" half of the label simply never appeared. Cosmetic, but only visible on renamed boards, which is +why nobody reported it. + +A DECAYED DEFERRAL, recorded as such: this program's learnings say a deferral's stated blocker is a +claim that ages like any measurement. Mine aged out in one day, and I re-read it twice this week and +took it at face value both times. */ -function getInReviewCompletionMs(task: Task): number | null { - return task.column === "done" ? getDoneCompletionMs(task) : null; +function getInReviewCompletionMs(task: Task, columnFlags?: TaskContextMenuColumnFlags): number | null { + return isCompleteColumnRole(columnFlags, task.column) ? getDoneCompletionMs(task) : null; } function getMergeElapsedMs(task: Task, nowMs: number): number | null { @@ -1838,7 +1861,7 @@ function TaskCardComponent({ return null; } - const completionMs = getInReviewCompletionMs(task); + const completionMs = getInReviewCompletionMs(task, taskColumnFlags); if (completionMs == null) { return { label: elapsedLabel, @@ -1853,7 +1876,12 @@ function TaskCardComponent({ title: t("tasks.executionTimeCompleted", "Execution time {{elapsed}}. Completed {{completedAt}}", { elapsed: elapsedLabel, completedAt }), ariaLabel: t("tasks.executionTimeCompleted", "Execution time {{elapsed}}. Completed {{completedAt}}", { elapsed: elapsedLabel, completedAt }), }; - }, [task.column, task.status, task.columnMovedAt, task.timedExecutionMs, task.updatedAt, task.workflowStepResults, task.log, task.firstExecutionAt, task.cumulativeActiveMs, task.cumulativePlanningMs, task.planningStartedAt, task.executionStartedAt, task.executionCompletedAt, timeIndicatorNowMs]); + /* FNXC:WorkflowResolvedColumns 2026-07-31-23:59: `taskColumnFlags` joins the deps because this memo + now READS it. Flags arrive asynchronously (the board resolves workflows after first paint), so a + card that renders before they load and re-renders after would otherwise keep the pre-flag answer + — the memo's inputs would be unchanged. This repo has no `react-hooks/exhaustive-deps` rule, so + nothing would have flagged the omission. */ + }, [task.column, task.status, task.columnMovedAt, task.timedExecutionMs, task.updatedAt, task.workflowStepResults, task.log, task.firstExecutionAt, task.cumulativeActiveMs, task.cumulativePlanningMs, task.planningStartedAt, task.executionStartedAt, task.executionCompletedAt, timeIndicatorNowMs, taskColumnFlags]); const lifecycleDates = useMemo(() => { const created = formatCompactLifecycleDate(task.createdAt, locale, new Date(lifecycleNowMs)); diff --git a/packages/dashboard/app/components/__tests__/TaskCard.time-indicator-renamed-lanes.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.time-indicator-renamed-lanes.test.tsx index 0a80d47432..c24c11bd4f 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.time-indicator-renamed-lanes.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.time-indicator-renamed-lanes.test.tsx @@ -88,3 +88,91 @@ describe("the card time indicator under a renamed board vocabulary", () => { expect(hasDuration(container as unknown as HTMLElement)).toBe(false); }); }); + +/* +FNXC:WorkflowResolvedColumns 2026-07-31-23:59: +THE COMPLETION HALF OF THE SAME LABEL, which the cases above do not reach. + +`getInReviewCompletionMs` gated on `task.column === "done"`, so on a board with a renamed completion +lane a finished card rendered its execution time WITHOUT the "done N ago" suffix — the label appears, +just permanently missing half of itself. That is why nobody reported it: the card does not look +broken, it looks like a card whose completion time has not been recorded. + +The deferral note on that helper said it had "no flags to consult". That was true when written and +expired within a day: `taskColumnFlags` is a prop of this component, the sibling duration helpers in +that file were threaded for exactly this purpose, and this helper's single caller sits inside the +component where the flags are in scope. + +DIFFERENTIAL BY CONSTRUCTION: `shipped` collides with no legacy id, so a surviving `=== "done"` cannot +pass by luck, and the control below pins that the default vocabulary still works. +*/ +function finishedTaskIn(column: string): Task { + return { + ...runningTaskIn(column), + executionCompletedAt: "2026-06-01T00:30:00.000Z", + columnMovedAt: "2026-06-01T00:30:00.000Z", + updatedAt: "2026-06-01T00:30:00.000Z", + } as unknown as Task; +} + +/* +SCOPED TO `.card-time-indicator`, and it took two wrong probes to get here — both caught by controls +and by mutation rather than by reading the code. + + 1. `textContent` matched nothing: the completion time lands in the indicator's `title` / + `aria-label`, never in visible text. The CONTROL failed too, which is the signature of a broken + probe rather than a broken fix. + 2. `innerHTML` on the whole card matched ALWAYS: the lifecycle-dates footer renders its own + "Completed " line, and that path resolves the complete lane CORRECTLY already. So the probe + was reading a different, already-converted feature. Mutation exposed it — reverting the fix left + all six green. + +Querying the indicator element and reading its `title` is the only assertion that can distinguish the +two, which is the whole point of the test. +*/ +const completionTitle = (root: HTMLElement) => + root.querySelector(".card-time-indicator")?.getAttribute("title") ?? ""; +const hasCompletionSuffix = (root: HTMLElement) => /Completed/i.test(completionTitle(root)); + +describe("the card completion timestamp under a renamed board vocabulary", () => { + /* Control: the legacy `done` lane renders the completion suffix with no flags supplied. */ + it("default vocabulary: a card in `done` shows when it completed", () => { + const { container } = render( + , + ); + + expect(hasCompletionSuffix(container as unknown as HTMLElement)).toBe(true); + }); + + /* The defect: `shipped` matched no legacy id, so the completion half never rendered. */ + it("renamed vocabulary: a card whose traits say COMPLETE shows when it completed", () => { + const { container } = render( + , + ); + + expect(hasCompletionSuffix(container as unknown as HTMLElement)).toBe(true); + }); + + /* + The paired negative: resolving traits must not stamp a completion time on a card that has not + finished. A renamed WIP card is still running, so the suffix must stay absent — otherwise the fix + trades a missing timestamp for a false one. + */ + it("renamed vocabulary: a running card in the WIP lane shows no completion time", () => { + const { container } = render( + , + ); + + expect(hasCompletionSuffix(container as unknown as HTMLElement)).toBe(false); + }); +}); diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index 3034a7ab4a..1e0976bd49 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -8,7 +8,6 @@ "packages/core/src/task-store/moves.ts": 1, "packages/core/src/task-store/task-id-integrity.ts": 1, "packages/dashboard/app/components/ResearchTaskActionModal.tsx": 1, - "packages/dashboard/app/components/TaskCard.tsx": 1, "packages/engine/src/notification/notification-service.ts": 1, "packages/engine/src/self-healing.ts": 1, "packages/engine/src/triage.ts": 1