fix(dashboard): finished cards on a renamed board never showed a completion date (#3007)

The **fifth** instance of the async-memo shape #2998 documents, and the
one that survived #3001's sweep.

`lifecycleDates` gates its `completed` value on `isCompleteColumn ||
isArchivedColumn` — both derived from the async `taskColumnFlags` prop —
while listing neither:

```js
}, [task.createdAt, task.executionCompletedAt, task.archivedAt, task.column, locale, lifecycleNowMs]);
```

First paint runs with the flags undefined, the role helpers fall back to
the legacy ids, and on a board whose complete lane is named anything but
`done` that answers false. The flags arrive, `task.column` has not
changed, nothing recomputes, and the card renders **no "Completed
<date>" line at all**.

## Why #3001's sweep called this covered

That PR recorded `mergeSignature` as *"the last live site … nine
persistent candidates, seven covered transitively or by a dependency
that already carries the flags."* This memo was presumably in the
covered pile, and the reasoning is nearly right: it **does** list a
dependency that changes — `lifecycleNowMs`.

But that value is driven by a timer scheduled with
`millisecondsUntilNextLocalMidnight` (FN-8561, so compact date labels
turn over at the viewer's midnight). **A dependency that changes once a
day is not coverage for a value that must be correct on first paint.**
The card shows no completion date for the rest of the session.

That distinction is worth adding to the doc's property 2: *does a listed
dependency change* is the wrong question — *does it change when the
resolved value arrives* is the right one.

## Verification

| state | result |
|---|---|
| clean | 2/2 pass |
| revert the dep fix | **1 failed / 1 passed** |

The control case (a `done` board) passes either way by design, so a
failure in the renamed case means "renamed board", not "nothing
renders".

**One trap worth recording**, since it nearly cost me the finding: my
first `completedLine()` used `time[datetime]:last-of-type`. When only
the *Created* line renders, that selector returns **that** element — so
the pre-resolution absence assertion silently passed against the wrong
node. The test now matches on the element's own `Completed` label. A
positional selector cannot express "this specific line is missing".

`tsc -p tsconfig.app.json` 0 errors, lint clean, 8/8 across all three
renamed-lane TaskCard suites.

## Note

`main` is currently red on the FNXC gate for an unrelated reason
(#2994's impossible-hour stamps landing after #2995); fixed in #3006.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 00:47:14 -07:00
committed by GitHub
parent 083f8f5c5b
commit f0875a79c6
3 changed files with 131 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Finished cards on a renamed board now show their completion date.
category: fix
dev: `lifecycleDates` in TaskCard omitted `isCompleteColumn`/`isArchivedColumn` from its dependency list; both derive from the async `taskColumnFlags` prop.

View File

@@ -1863,7 +1863,25 @@ function TaskCardComponent({
? formatCompactLifecycleDate(completionSource, locale, new Date(lifecycleNowMs)) ? formatCompactLifecycleDate(completionSource, locale, new Date(lifecycleNowMs))
: null; : null;
return { created, completed }; return { created, completed };
}, [task.createdAt, task.executionCompletedAt, task.archivedAt, task.column, locale, lifecycleNowMs]); /*
FNXC:WorkflowResolvedColumns 2026-07-31-08:20:
`isCompleteColumn` AND `isArchivedColumn` BELONG IN THIS LIST — both derive from the async
`taskColumnFlags` prop, and the completion date is gated on them.
The board resolves workflow traits after first paint, so the first computation runs with the flags
undefined and the role helpers fall back to the legacy ids. On a renamed board that answers false,
`completed` is null, and the "Completed <date>" line never renders. When the flags arrive nothing in
the old list had changed — every entry was a `task.*` field, `locale`, or `lifecycleNowMs`.
WHY THE SWEEP IN #3001 CALLED THIS COVERED: `lifecycleNowMs` does change, so the memo does
eventually recompute — but its timer fires at the viewer's LOCAL MIDNIGHT (see the
`millisecondsUntilNextLocalMidnight` effect). A dependency that turns over once a day is not
coverage for a value that must be right on first paint; the card shows no completion date for the
rest of the session.
A default board hides it: `column === "done"` is already true before the flags land.
*/
}, [task.createdAt, task.executionCompletedAt, task.archivedAt, task.column, locale, lifecycleNowMs, isCompleteColumn, isArchivedColumn]);
const liveBadgeData = badgeUpdates.get(`${projectId ?? "default"}:${task.id}`); const liveBadgeData = badgeUpdates.get(`${projectId ?? "default"}:${task.id}`);

View File

@@ -0,0 +1,105 @@
/*
FNXC:WorkflowResolvedColumns 2026-07-31-08:25:
THE "Completed <date>" LINE NEVER APPEARED ON A RENAMED BOARD.
`lifecycleDates` gates its `completed` value on `isCompleteColumn || isArchivedColumn`, both derived
from the `taskColumnFlags` PROP. The board resolves workflow traits asynchronously, so the first
computation runs with the flags undefined, the role helpers fall back to the legacy ids, and on a
board whose complete lane is named anything but `done` the answer is false.
The dependency list held only `task.*` fields, `locale` and `lifecycleNowMs`, so when the flags
arrived nothing invalidated and the card kept rendering no completion date.
WHY THIS SURVIVED THE #3001 SWEEP, which recorded `mergeSignature` as the last live site: this memo
DOES list a dependency that changes — `lifecycleNowMs`. But its timer fires at the viewer's local
midnight, so "eventually recomputes" means once a day. A value that must be correct on first paint is
not covered by a dependency that turns over daily.
Asserted through the rendered `<time>` element rather than the memo, because the memo returning a
value is not the user-visible contract — the date appearing on the card is.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { TaskCard } from "../TaskCard";
vi.mock("../../hooks/useTaskDiffStats", () => ({
useTaskDiffStats: () => ({ stats: undefined, loading: false }),
}));
const noop = () => {};
/** A card that finished BEFORE the board resolved its traits. */
function completedTaskIn(column: string): Task {
return {
id: "KB-LD-1",
title: "a finished card",
description: "t",
column,
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-02T00:00:00.000Z",
executionCompletedAt: "2026-06-02T00:00:00.000Z",
steps: [],
} as unknown as Task;
}
/** The board's own complete lane, as the async flags eventually describe it. */
const SHIPPED_IS_COMPLETE = { complete: true } as const;
function renderCard(column: string, flags: Record<string, boolean> | undefined) {
return render(
<TaskCard
task={completedTaskIn(column)}
onMoveTask={noop as never}
onDeleteTask={noop as never}
onOpenDetail={noop as never}
addToast={noop as never}
taskColumnFlags={flags as never}
/>,
);
}
/* Matched by its own label. A positional selector is wrong here: when only "Created" renders,
`time:last-of-type` returns THAT element and the absence assertion silently passes. */
function completedLine(): HTMLElement | null {
const host = screen.queryByTestId("card-lifecycle-dates");
if (!host) return null;
return [...host.querySelectorAll("time")].find((el) => /Completed/i.test(el.textContent ?? "")) ?? null;
}
describe("TaskCard lifecycle dates on a renamed board", () => {
beforeEach(() => { vi.clearAllMocks(); });
/*
CONTROL. A default board answers `column === "done"` before the flags land, so this case passes
with or without the fix — it is here so a failure below means "renamed board", not "nothing renders".
*/
it("renders the completion date on a default board (control)", () => {
renderCard("done", undefined);
expect(screen.getByTestId("card-lifecycle-dates")).toBeTruthy();
expect(completedLine()).toBeTruthy();
});
it("renders the completion date once the renamed lane's flags arrive", () => {
const { rerender } = renderCard("shipped", undefined);
/* Pre-resolution: the legacy fallback says "shipped" is not complete, so no date yet. */
expect(completedLine()).toBeNull();
rerender(
<TaskCard
task={completedTaskIn("shipped")}
onMoveTask={noop as never}
onDeleteTask={noop as never}
onOpenDetail={noop as never}
addToast={noop as never}
taskColumnFlags={SHIPPED_IS_COMPLETE as never}
/>,
);
/* The flags arrived and `task.column` did not change — only a dep on the derived
flags makes this recompute. Without them the card shows no completion date all session. */
expect(completedLine()).toBeTruthy();
});
});