fix(dashboard): the card's completion timestamp reads the resolved complete lane (census 13 → 12) (#3146)

`TaskCard.tsx` 1 → 0. **Census 13 → 12**, baseline re-recorded
in-commit.

## The defect

`getInReviewCompletionMs` gated on `task.column === "done"`, so on a
board whose completion lane is renamed, a finished card rendered its
execution time **without the completion half** — the `Completed <when>`
part of the indicator's `title` / `aria-label` never appeared.

Nobody reported it because the card does not look broken. It looks like
a card whose completion time was never recorded.

## The recorded blocker had expired, and I trusted it twice

The note on that helper read:

> Module-scope, takes only a `Task`, and has no flags to consult.
Converting it means either threading resolved flags through a pure
duration helper or resolving a workflow inside it.

True when written (2026-07-30). False within a day, and the evidence is
in the same file:

- `taskColumnFlags` is a **prop of this component**, destructured and
already consumed by `isWipColumnRole` / `isReviewColumnRole`.
- The **sibling duration helpers were threaded for exactly this
purpose** — `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.

The threading the note called prohibitive was already done; only this
helper was left behind. I read that note twice this week and took it at
face value both times — and what finally prompted the check was main
landing `taskRevert 2 → 0 — **the recorded blocker named the wrong
variable**` (#3129), someone else finding the same class of decay in a
note I had also accepted.

This program's own learnings say a deferral's stated blocker is a claim
that ages like any measurement. I had applied every other entry in that
document this week except that one.

## A dependency-array bug the conversion would have introduced

The memo now reads `taskColumnFlags`, so it joins the dependency array.
Flags arrive **asynchronously** — the board resolves workflows after
first paint — so a card rendered before they load and re-rendered after
would otherwise keep the pre-flag answer, since none of the memo's other
inputs changed. This repo has **no `react-hooks/exhaustive-deps` rule**,
so nothing would have flagged the omission.

## Two wrong probes before a correct one, both caught by controls and
mutation

Recording these because the fix was right from the start and my
instruments were not:

1. **`textContent` matched nothing.** The completion time lands in
`title`/`aria-label`, never in visible text. The **control failed too**
— 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 <date>` line, and *that* path already
resolves the complete lane correctly. The probe was reading a different,
already-converted feature. **Mutation exposed it: reverting the fix left
all six green.**

The final assertion queries `.card-time-indicator` and reads its
`title`, which is the only form that can tell the two apart.

## Verification

| | result |
|---|---|
| suite | **6 passed** |
| mutation (restore `=== "done"`) | **1 failed \| 5 passed** — the
renamed case only, control still green |
| dashboard `tsc -p tsconfig.app.json` | **0 errors** |
| census `--strict` | exit 0, baseline re-recorded in-commit |

Flags stay optional with the legacy id as fallback
(`isCompleteColumnRole`), so any caller without resolved flags behaves
exactly as before.

## Note on `check-fnxc-future-dates`

It fails on this branch, but **not because of it** — `scheduler.ts` and
one PG test carry future stamps on `main` itself. #3139 fixes that. None
of my files appear in the report.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 10:18:18 -07:00
committed by GitHub
parent aef88a2976
commit a319e35a67
3 changed files with 120 additions and 5 deletions

View File

@@ -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));

View File

@@ -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 <date>" 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(
<TaskCard task={finishedTaskIn("done")} onOpenDetail={noop} addToast={noop} />,
);
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(
<TaskCard
task={finishedTaskIn("shipped")}
taskColumnFlags={{ complete: true }}
onOpenDetail={noop}
addToast={noop}
/>,
);
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(
<TaskCard
task={runningTaskIn("building")}
taskColumnFlags={{ countsTowardWip: true }}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(hasCompletionSuffix(container as unknown as HTMLElement)).toBe(false);
});
});

View File

@@ -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