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:
7
.changeset/taskcard-lifecycle-dates-renamed-lanes.md
Normal file
7
.changeset/taskcard-lifecycle-dates-renamed-lanes.md
Normal 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.
|
||||
@@ -1863,7 +1863,25 @@ function TaskCardComponent({
|
||||
? formatCompactLifecycleDate(completionSource, locale, new Date(lifecycleNowMs))
|
||||
: null;
|
||||
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}`);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user