fix(dashboard): the live elapsed-time indicator never started on a renamed board (#2996)

## How this was found

By generalizing the memo-dependency defect in the blocker fan-out
(#2993) into a sweep for memoized hooks that read a lane value absent
from their dependency list — rather than treating that one as a one-off.
**13 raw hits, 12 benign** (refs, or values reached through a covered
object). This is the one that's a live defect.

## The defect

`wantsLiveTimeIndicator` decides whether a card subscribes to the shared
time ticker. It reads `isWipColumn`, `isReviewColumn` and
`taskColumnFlags` — all derived from the `taskColumnFlags` **prop** —
while its dependency array listed only `task.*` fields.

Those flags arrive **after first paint**: the board resolves workflow
traits asynchronously. So:

1. first computation runs with flags `undefined`;
2. role helpers fall back to legacy ids — `isWipColumnRole(undefined,
"building")` is **false**;
3. the card declines the ticker;
4. flags arrive, but `task.column` hasn't changed, so nothing in the dep
array changed;
5. the memo never recomputes. **No live elapsed time, for the life of
the mount.**

## Why it survived

On a legacy board the fallback already answers `true` on the very first
paint (`column === "in-progress"`), so the memo's initial value is
correct and the stale list costs nothing. The defect is
**renamed-board-only**.

This repo also has no `react-hooks/exhaustive-deps` rule, so the entire
class is invisible to lint — and a disable directive for that rule fails
CI, so these lists are maintained by hand.

## Measured

The test was written **first** and was red for the right reason before
any fix — the control and the negative passed, and only the renamed case
failed:

| stage | result |
|---|---|
| before the fix | `expected false to be true` (renamed case only) |
| after | 3 passed |
| `TaskCard.test` + `cli-states` + `oversight` + new suite | **456 tests
green** |
| gates | census + FNXC green; lint and `tsc` clean |

The assertion is on `useLiveTimeTicker(enabled)` — `enabled` *is*
`wantsLiveTimeIndicator`, so it observes the subscription itself rather
than a proxy for it.

## The negative case is the one that matters

Recomputing must not degrade into "every card subscribes". A card in the
renamed **complete** lane must stay off the shared ticker, or the fix
trades one stalled indicator for sixty cards waking a backgrounded tab —
the exact cost the shared-ticker refactor documented at this site (it
replaced 60 per-card `setInterval`s precisely because mobile browsers
discard a page that never goes idle).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 23:51:50 -07:00
committed by GitHub
parent ac67b8d585
commit 5ce23b2187
3 changed files with 138 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Cards in renamed in-progress or review lanes now show their live elapsed-time indicator.
category: fix
dev: `wantsLiveTimeIndicator`'s dependency list omitted `isWipColumn`/`isReviewColumn`/`taskColumnFlags`, so the memo kept the pre-load answer computed before workflow traits resolved.

View File

@@ -1732,7 +1732,24 @@ function TaskCardComponent({
} }
return true; return true;
}, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs, task.firstExecutionAt, task.cumulativeActiveMs, task.executionStartedAt, task.executionCompletedAt]); /*
FNXC:WorkflowResolvedColumns 2026-07-30-23:40:
THE LANE ROLES BELONG IN THIS LIST, or the card never subscribes on a renamed board.
`isWipColumn`/`isReviewColumn` derive from the `taskColumnFlags` PROP, which arrives after first
paint — the board resolves workflow traits asynchronously. The first computation therefore runs
with the flags undefined, the role helpers fall back to the legacy ids, and on a renamed board that
answers false. When the flags arrive `task.column` is unchanged, so a list of `task.*` fields alone
never recomputes and the pre-load answer sticks: no live elapsed-time indicator, for the life of
the mount.
A legacy board hid this completely, because there the fallback already answers true on the first
paint and the stale list costs nothing.
This repo has no `react-hooks/exhaustive-deps` rule, so the list is maintained by hand and a
disable directive for that rule fails CI.
*/
}, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs, task.firstExecutionAt, task.cumulativeActiveMs, task.executionStartedAt, task.executionCompletedAt, isWipColumn, isReviewColumn, taskColumnFlags]);
const timeIndicatorNowMs = useLiveTimeTicker(wantsLiveTimeIndicator); const timeIndicatorNowMs = useLiveTimeTicker(wantsLiveTimeIndicator);

View File

@@ -0,0 +1,113 @@
/*
FNXC:WorkflowResolvedColumns 2026-07-30-23:35:
THE LIVE ELAPSED-TIME INDICATOR NEVER STARTED FOR CARDS IN A RENAMED WIP OR REVIEW LANE.
`wantsLiveTimeIndicator` decides whether a card subscribes to the shared ticker. It reads
`isWipColumn` / `isReviewColumn` / `taskColumnFlags` — all derived from the `taskColumnFlags` PROP —
while its dependency array lists only `task.*` fields.
`taskColumnFlags` arrives ASYNCHRONOUSLY: the board resolves workflow traits after first paint, so
the first computation always runs with the flags undefined. The role helpers then fall back to the
legacy ids, and on a renamed board `isWipColumnRole(undefined, "building")` is false. The card
decides it needs no ticker. When the flags arrive, `task.column` has not changed — so nothing in the
dependency array changed, the memo never recomputes, and the card never subscribes.
WHY THE DEFAULT BOARD HID IT. With legacy ids the fallback answers `true` on the very first paint
(`column === "in-progress"`), so the memo's initial value is already correct and the stale dependency
list costs nothing. The defect is renamed-board-only, which is why it survived.
Found by generalizing the memo-dependency defect in the blocker fan-out (#2993) into a sweep for
memoized hooks that read a lane value absent from their deps, rather than treating that one as a
one-off. This repo has no `react-hooks/exhaustive-deps` rule, so the whole class is invisible to lint.
*/
import { describe, it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { TaskCard } from "../TaskCard";
/* The subscription flag is the observable: `enabled` is exactly `wantsLiveTimeIndicator`. */
const tickerCalls: boolean[] = [];
vi.mock("../../hooks/useLiveTimeTicker", () => ({
useLiveTimeTicker: (enabled: boolean) => {
tickerCalls.push(enabled);
return Date.now();
},
}));
const noop = () => {};
function taskIn(column: string): Task {
return {
id: "KB-1",
title: "a card",
description: "t",
column,
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
executionStartedAt: "2026-06-01T00:00:00.000Z",
cumulativeActiveMs: 60_000,
steps: [],
} as unknown as Task;
}
/** Did the card subscribe on its LATEST render? */
const subscribedNow = () => tickerCalls[tickerCalls.length - 1] === true;
describe("the live time indicator when column traits arrive after first paint", () => {
/* Control: on a legacy board the fallback answers correctly on the first paint, so this passes
with or without the fix and proves the harness observes a real subscription. */
it("default vocabulary: a card in `in-progress` subscribes with no flags at all", () => {
tickerCalls.length = 0;
render(<TaskCard task={taskIn("in-progress")} onOpenDetail={noop} addToast={noop} />);
expect(subscribedNow()).toBe(true);
});
/*
The defect. First paint has no flags, so the renamed lane reads as not-WIP and the card declines
the ticker; the flags then arrive and nothing recomputes.
*/
it("renamed vocabulary: a card subscribes once its WIP traits arrive", () => {
tickerCalls.length = 0;
const { rerender } = render(
<TaskCard task={taskIn("building")} onOpenDetail={noop} addToast={noop} />,
);
/* Pre-load: correctly declines, because nothing yet says this lane is WIP. */
expect(subscribedNow()).toBe(false);
rerender(
<TaskCard
task={taskIn("building")}
taskColumnFlags={{ countsTowardWip: true }}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(subscribedNow()).toBe(true);
});
/*
The paired negative: recomputing must not degrade into "every card subscribes". A card whose
resolved traits say terminal is finished work and must stay off the shared ticker, or the fix
trades one stalled indicator for sixty cards waking a backgrounded tab — the exact cost the
shared-ticker refactor documented at this site.
*/
it("renamed vocabulary: a card in the renamed COMPLETE lane does not subscribe", () => {
tickerCalls.length = 0;
const { rerender } = render(
<TaskCard task={taskIn("shipped")} onOpenDetail={noop} addToast={noop} />,
);
rerender(
<TaskCard
task={taskIn("shipped")}
taskColumnFlags={{ complete: true }}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(subscribedNow()).toBe(false);
});
});