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:
7
.changeset/taskcard-time-indicator-renamed-lanes.md
Normal file
7
.changeset/taskcard-time-indicator-renamed-lanes.md
Normal 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.
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user