fix(dashboard): the time indicator was gated on a hardcoded legacy lane set (#3014)

## This is a correction to #2996, and that's why it exists

#2996 fixed the **subscription**: `wantsLiveTimeIndicator` kept a
pre-load answer, so the card never joined the shared ticker. I described
it as making renamed-lane cards *"show their live elapsed-time
indicator."*

It made them **eligible to**. They still rendered nothing, because a
second gate rejects them first — and I didn't look past the seam I'd
just fixed.

```ts
const TIME_INDICATOR_COLUMNS = new Set<ColumnId>(["in-progress", "in-review", "done"]);
```

Both the `timeIndicator` memo and the `chipFarRight` layout test
`task.column` against that set directly, so a card in a renamed WIP,
review or completion lane returns `null` whatever its resolved traits
say.

## Why no check saw it

The census counts **comparisons** against legacy ids. This is a `Set`
literal — a **definition**. Nothing in the backlog ever pointed here,
which is the same blind spot that hid `BLOCKER_ESCALATION_COLUMNS` until
someone read the code rather than the report.

## The fix

The gate becomes a role question, with the legacy set kept as the
**no-flags fallback** and marked `DELIBERATE-LITERAL`. A card whose
traits haven't resolved — first paint, or a lane its workflow no longer
declares — behaves exactly as before.

## Measured

| check | result |
|---|---|
| test written first | red for the right reason — control and negative
passed, only the renamed case failed (`expected false to be true`) |
| after the fix | 3 passed |
| reverting the memo gate to the raw set | that case fails again |
| five `TaskCard` suites | **418 tests green** |
| gates | all five green; lint and `tsc` clean |

## The negative case

Resolving traits must not put a live timer on every lane. A card in the
renamed **intake** lane hasn't started, so it stays out — otherwise the
fix trades a missing indicator for a running clock on work that hasn't
begun.

## Worth noting for the pattern

Two of my last four findings came from re-examining my own merged work
rather than from new code: this one, and the `bounded` heuristic
correction in #3012. Fixing one seam and declaring the symptom gone is
its own failure mode — the user-visible behaviour needed *both* halves,
and I only checked the half I'd touched.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 00:36:09 -07:00
committed by GitHub
parent e9f587c363
commit 3b55e2c96c
3 changed files with 119 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Cards in renamed in-progress, review or completion lanes now show their elapsed-time indicator.
category: fix
dev: The `TIME_INDICATOR_COLUMNS` legacy id set gated both the indicator memo and the chip layout; it is now a role question with that set kept as the no-flags fallback.

View File

@@ -1063,6 +1063,26 @@ function TaskCardComponent({
const isCompleteColumn = isCompleteColumnRole(taskColumnFlags, task.column); const isCompleteColumn = isCompleteColumnRole(taskColumnFlags, task.column);
const isArchivedColumn = isArchivedColumnRole(taskColumnFlags, task.column); const isArchivedColumn = isArchivedColumnRole(taskColumnFlags, task.column);
/*
FNXC:WorkflowResolvedColumns 2026-07-31-03:15:
THE TIME-INDICATOR GATE IS A ROLE QUESTION, not membership of a hardcoded id set.
`TIME_INDICATOR_COLUMNS` is `{in-progress, in-review, done}`, so a card in a renamed WIP, review or
completion lane was rejected before its traits were consulted and rendered no elapsed time at all.
#2996 fixed the SUBSCRIPTION for these cards — the memo that decides whether to join the shared
ticker kept a pre-load answer — which made them eligible and still not visible, because this gate
rejects them first. Both halves are needed; that PR's claim covered only one.
The census could not point here: it counts COMPARISONS against legacy ids, and this is a Set
literal, which is a DEFINITION. Same blind spot that hid `BLOCKER_ESCALATION_COLUMNS`.
DELIBERATE-LITERAL — the legacy set stays as the no-flags fallback, so a card whose traits have not
resolved (first paint, or a lane its workflow no longer declares) behaves exactly as before.
*/
const showsTimeIndicator = taskColumnFlags
? isWipColumn || isReviewColumn || isCompleteColumn
: TIME_INDICATOR_COLUMNS.has(task.column);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [showSteps, setShowSteps] = useState( const [showSteps, setShowSteps] = useState(
isWipColumn || isWipColumn ||
@@ -1754,7 +1774,7 @@ function TaskCardComponent({
const timeIndicatorNowMs = useLiveTimeTicker(wantsLiveTimeIndicator); const timeIndicatorNowMs = useLiveTimeTicker(wantsLiveTimeIndicator);
const timeIndicator = useMemo(() => { const timeIndicator = useMemo(() => {
if (!TIME_INDICATOR_COLUMNS.has(task.column)) { if (!showsTimeIndicator) {
return null; return null;
} }
@@ -3129,7 +3149,7 @@ function TaskCardComponent({
return null; return null;
})(); })();
const chipFarRight = TIME_INDICATOR_COLUMNS.has(task.column) const chipFarRight = showsTimeIndicator
&& filesChangedButton == null && filesChangedButton == null
&& showTrackingIndicator && showTrackingIndicator
&& Boolean(githubTrackedIssue); && Boolean(githubTrackedIssue);

View File

@@ -0,0 +1,90 @@
/*
FNXC:WorkflowResolvedColumns 2026-07-31-03:10:
THE TIME INDICATOR IS GATED ON A HARDCODED LEGACY LANE SET, so it never renders on a renamed board.
`TIME_INDICATOR_COLUMNS` is `{in-progress, in-review, done}`. Both the `timeIndicator` memo and the
`chipFarRight` layout test `task.column` against it directly, so a card in a renamed WIP, review or
completion lane returns `null` no matter what its resolved traits say.
WHY THIS IS A SEPARATE DEFECT FROM #2996, and a correction to that PR's claim: #2996 fixed the
SUBSCRIPTION — `wantsLiveTimeIndicator` kept a pre-load answer and the card never joined the shared
ticker. That was real, and it was not sufficient. The card now subscribes and still renders nothing,
because this gate rejects it first. I described that PR as making renamed-lane cards "show their live
elapsed-time indicator"; it made them eligible to.
WHY NO CHECK SAW IT. The census counts COMPARISONS against legacy ids. This is a Set literal — a
DEFINITION — so nothing in the backlog ever pointed here, the same blind spot that hid
`BLOCKER_ESCALATION_COLUMNS` until it was found by hand.
*/
import { describe, it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { TaskCard } from "../TaskCard";
vi.mock("../../hooks/useLiveTimeTicker", () => ({
useLiveTimeTicker: () => Date.parse("2026-06-01T01:00:00.000Z"),
}));
const noop = () => {};
function runningTaskIn(column: string): Task {
return {
id: "KB-1",
title: "a running 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: 120_000,
timedExecutionMs: 120_000,
steps: [],
} as unknown as Task;
}
/** The indicator renders an elapsed duration; any of these means it is present. */
const hasDuration = (root: HTMLElement) => /\d+\s*(s|m|h)\b|\d+:\d\d/.test(root.textContent ?? "");
describe("the card time indicator under a renamed board vocabulary", () => {
/* Control: the legacy vocabulary renders it, with no flags at all. */
it("default vocabulary: a card in `in-progress` shows an elapsed time", () => {
const { container } = render(
<TaskCard task={runningTaskIn("in-progress")} onOpenDetail={noop} addToast={noop} />,
);
expect(hasDuration(container as unknown as HTMLElement)).toBe(true);
});
/* The defect: the renamed WIP lane is not in the hardcoded set, so nothing renders. */
it("renamed vocabulary: a card whose traits say WIP shows an elapsed time", () => {
const { container } = render(
<TaskCard
task={runningTaskIn("building")}
taskColumnFlags={{ countsTowardWip: true }}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(hasDuration(container as unknown as HTMLElement)).toBe(true);
});
/*
The paired negative: resolving traits must not put a live timer on every lane. A card in the
renamed INTAKE lane has not started, so it must stay out — otherwise the fix trades a missing
indicator for a running clock on work that has not begun.
*/
it("renamed vocabulary: a card in the intake lane shows no elapsed time", () => {
const { container } = render(
<TaskCard
task={runningTaskIn("drafting")}
taskColumnFlags={{ intake: true, hold: true }}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(hasDuration(container as unknown as HTMLElement)).toBe(false);
});
});