FN-7406: fix All workflows dropdown counts

Ensure All workflows dropdown counters report the shared aggregate without double-counting tasks.

- Reuse computeWorkflowStatusCounts directly in Board instead of re-summing a map that already includes the All workflows sentinel.
- Document the aggregate count invariant and add a published patch changeset.
- Extend Board, List, header, graph, and workflow count tests to cover exact All workflows totals.

Files changed:
 .changeset/fn-7406-workflow-dropdown-counts.md     |  7 +++
 docs/dashboard-guide.md                            |  5 +-
 packages/dashboard/app/components/Board.tsx        | 17 +++---
 .../app/components/__tests__/Board.test.tsx        | 56 ++++++++++++++++++-
 .../__tests__/GraphWorkflowSwitcherSlot.test.tsx   |  8 ++-
 .../__tests__/HeaderWorkflowSwitcherSlot.test.tsx  |  8 ++-
 .../app/components/__tests__/ListView.test.tsx     | 62 ++++++++++++++++++++++
 .../__tests__/workflowStatusCounts.test.ts         | 48 +++++++++++++++--
 8 files changed, 188 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-7406

Fusion-Task-Lineage: bb344c03-c349-47ea-9e8c-c445975f8e02

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 23:15:12 -07:00
parent eedf526e7f
commit 9464f31caf
8 changed files with 188 additions and 23 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix the All workflows dropdown counter so it no longer double-counts tasks.
category: fix
dev: Board now uses the shared workflow status count aggregate directly, with regression coverage for Board/List and header/graph selector count behavior.

View File

@@ -159,9 +159,10 @@ FNXC:TaskCardMobileSelection 2026-07-01-00:00: Mobile Board long-press is a task
FNXC:WorkflowSelection 2026-06-30-00:00: The view preference persists either a real workflow id or the All workflows sentinel so refresh/remount restores the operator's last top-level workflow context without treating the sentinel as a backend workflow id.
FNXC:WorkflowSelection 2026-07-01-00:00: All workflows is available on Board, List, Planning, Missions, and Graph top-level selectors; Planning/Missions task creation receives default/no-specific-workflow behavior instead of the sentinel. -->
<!-- FNXC:WorkflowSelection 2026-06-29-23:58: All workflows quick-create must use a real workflow intake/default column rather than a synthesized lifecycle column, so custom-default boards do not create tasks into invalid or disappearing columns. -->
<!-- FNXC:WorkflowSelection 2026-06-29-23:59: Workflow counts and All workflows grouping resolve each task's effective workflow before evaluating column visibility, so a shared column id hidden in one workflow does not leak that workflow's hidden tasks into another workflow's visible aggregate lane. -->
<!-- FNXC:WorkflowSelection 2026-06-29-23:59: Workflow counts and All workflows grouping resolve each task's effective workflow before evaluating column visibility, so a shared column id hidden in one workflow does not leak that workflow's hidden tasks into another workflow's visible aggregate lane.
FNXC:WorkflowSelection 2026-07-01-23:04: Board/List dropdown counts use computeWorkflowStatusCounts as the single source of truth. The All workflows row reports the helper-owned aggregate exactly once and must not be recomputed by summing the map that already contains the aggregate sentinel. -->
<!-- FNXC:WorkflowSelection 2026-06-29-21:40: Refinement creation from Task Detail and done-task chat must preserve both the source task workflow and the operator's selected Board/List lane, so non-default workflow users do not get bounced back to Coding/default after refinement. -->
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow identity (Fusion icon for built-ins, optional custom icon for custom workflows), name, and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived and board-hidden columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Board and List also show **All workflows** before real workflows as a dashboard-only aggregate view with combined counts and a deterministic union of visible workflow columns; shared column ids use the default workflow label/flags when available, otherwise the first workflow definition that declares the column. Hidden columns stay workflow-scoped in the aggregate: a task whose effective workflow hides a shared column is omitted from that aggregate column even if another workflow exposes the same column id. That option is not editable, persists as top-level workflow view state, and quick-create/Plan/Subtask/Mission handoffs translate it to a real default workflow id or no-specific-workflow behavior so task creation never sends the sentinel. Each real workflow option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. The open listbox grows from the longest workflow name plus its count/edit decorations while remaining viewport-bounded; the closed trigger stays narrow and ellipsized. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow identity (Fusion icon for built-ins, optional custom icon for custom workflows), name, and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived and board-hidden columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Board and List also show **All workflows** before real workflows as a dashboard-only aggregate view with combined counts that sum only the real visible workflow rows exactly once and a deterministic union of visible workflow columns; shared column ids use the default workflow label/flags when available, otherwise the first workflow definition that declares the column. Hidden columns stay workflow-scoped in the aggregate: a task whose effective workflow hides a shared column is omitted from that aggregate column even if another workflow exposes the same column id. That option is not editable, persists as top-level workflow view state, and quick-create/Plan/Subtask/Mission handoffs translate it to a real default workflow id or no-specific-workflow behavior so task creation never sends the sentinel. Each real workflow option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. The open listbox grows from the longest workflow name plus its count/edit decorations while remaining viewport-bounded; the closed trigger stays narrow and ellipsized. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
- When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash. The selected workflow is remembered per project in durable browser storage and restored when returning to Board/List after task refreshes, route changes, respecification flows, or refinement creation from Task Detail and done-task chat; Board, List, Planning/Missions header selectors, and Graph also restore the dashboard-only **All workflows** aggregate view when that was the last selected top-level workflow context. If a saved real workflow is later deleted, Fusion falls back to a valid default/first workflow so tasks remain visible.
- Briefly leaving Board/List for a task detail or another non-task-SSE view preserves the current in-memory task snapshot. Returning to Board/List reuses that fresh snapshot immediately and restores live SSE updates without an extra all-task fetch; Fusion still runs one catch-up fetch when task data is missing, stale, or from a failed refresh.
<!-- FNXC:BoardTaskCache 2026-06-29-20:05: Board/List returns from non-task-SSE views should reuse a fresh in-memory task snapshot to avoid redundant all-task fetches and loading flashes, while stale, missing, or errored snapshots still trigger one catch-up fetch and restore SSE updates. -->

View File

@@ -13,7 +13,7 @@ import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
import { WorkflowSwitcher } from "./WorkflowSwitcher";
import { computeWorkflowStatusCounts, type WorkflowStatusCounts } from "./workflowStatusCounts";
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
import {
@@ -400,16 +400,11 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
const getDraggingTaskId = useCallback(() => draggingTaskIdRef.current, []);
const workflowStatusCounts = useMemo(() => {
const counts = computeWorkflowStatusCounts(tasks, boardWorkflows);
const aggregateCounts: WorkflowStatusCounts = { todo: 0, inProgress: 0, done: 0, merging: 0 };
for (const workflowCounts of counts.values()) {
aggregateCounts.todo += workflowCounts.todo;
aggregateCounts.inProgress += workflowCounts.inProgress;
aggregateCounts.done += workflowCounts.done;
aggregateCounts.merging += workflowCounts.merging;
}
counts.set(ALL_WORKFLOWS_BOARD_VIEW_ID, aggregateCounts);
return counts;
/*
FNXC:WorkflowSwitcher 2026-07-01-23:04:
computeWorkflowStatusCounts already owns the dashboard-only All workflows aggregate sentinel. Board must pass the helper result through directly instead of re-summing every map entry, because the map includes the sentinel and summing it again doubles the dropdown aggregate row.
*/
return computeWorkflowStatusCounts(tasks, boardWorkflows);
}, [boardWorkflows, tasks]);
useEffect(() => {

View File

@@ -1,6 +1,6 @@
import React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react";
import { Board } from "../Board";
import { COLUMNS } from "@fusion/core";
import { ALL_WORKFLOWS_BOARD_VIEW_ID, BOARD_WORKFLOW_SELECTION_STORAGE_KEY } from "../../utils/boardWorkflowSelection";
@@ -1472,6 +1472,60 @@ describe("Board", () => {
}
});
it("renders the all-workflows dropdown count as a single aggregate of real workflow rows", async () => {
enableFlag(
{
"FN-default-todo": "builtin:coding",
"FN-default-active": "builtin:coding",
"FN-default-done": "builtin:coding",
"FN-custom-todo": "wf-custom",
"FN-custom-done": "wf-custom",
"FN-stale": "wf-deleted",
"FN-hidden": "builtin:coding",
"FN-archived": "builtin:coding",
},
[
{
...DEFAULT_WORKFLOW,
columns: [
...DEFAULT_WORKFLOW.columns,
{ id: "quiet", name: "Quiet", flags: { hiddenFromBoard: true } },
],
},
{
...CUSTOM_WORKFLOW,
name: "Coding",
},
],
);
renderBoard({
tasks: [
mkTask({ id: "FN-default-todo", column: "todo" }),
mkTask({ id: "FN-default-active", column: "in-progress", status: "merging" }),
mkTask({ id: "FN-default-done", column: "done" }),
mkTask({ id: "FN-custom-todo", column: "intake" }),
mkTask({ id: "FN-custom-done", column: "done" }),
mkTask({ id: "FN-stale", column: "todo" }),
mkTask({ id: "FN-hidden", column: "quiet" }),
mkTask({ id: "FN-archived", column: "archived" }),
],
});
await openWorkflowSwitcher();
const aggregateOption = screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`);
expect(aggregateOption).toHaveTextContent("All workflows");
expect(within(aggregateOption).getByTitle("Todo: 3")).toBeInTheDocument();
expect(within(aggregateOption).getByTitle("In Progress: 1")).toBeInTheDocument();
expect(within(aggregateOption).getByTitle("Done: 2")).toBeInTheDocument();
expect(within(aggregateOption).getByTitle("1 merging")).toBeInTheDocument();
expect(within(screen.getByTestId("workflow-switcher-option-builtin:coding")).getByTitle("Todo: 2")).toBeInTheDocument();
expect(within(screen.getByTestId("workflow-switcher-option-builtin:coding")).getByTitle("In Progress: 1")).toBeInTheDocument();
expect(within(screen.getByTestId("workflow-switcher-option-builtin:coding")).getByTitle("Done: 1")).toBeInTheDocument();
expect(within(screen.getByTestId("workflow-switcher-option-wf-custom")).getByTitle("Todo: 1")).toBeInTheDocument();
expect(within(screen.getByTestId("workflow-switcher-option-wf-custom")).getByTitle("In Progress: 0")).toBeInTheDocument();
expect(within(screen.getByTestId("workflow-switcher-option-wf-custom")).getByTitle("Done: 1")).toBeInTheDocument();
});
it("renders one selected workflow at a time and switches workflows from the dropdown", async () => {
const onCreateWorkflow = vi.fn();
const onOpenWorkflowEditor = vi.fn();

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../../api";
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../../utils/boardWorkflowSelection";
@@ -220,7 +220,11 @@ describe("GraphWorkflowSwitcherSlot", () => {
render(<GraphWorkflowSwitcherSlot projectId="project-graph-all" onWorkflowSelectionChange={onWorkflowSelectionChange} onOpenWorkflowEditor={onOpenWorkflowEditor} />);
fireEvent.click(await screen.findByTestId("workflow-switcher"));
expect(screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toHaveTextContent("All workflows");
const aggregateOption = screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`);
expect(aggregateOption).toHaveTextContent("All workflows");
expect(within(aggregateOption).getByTitle("Todo: 0")).toBeInTheDocument();
expect(within(aggregateOption).getByTitle("In Progress: 0")).toBeInTheDocument();
expect(within(aggregateOption).getByTitle("Done: 0")).toBeInTheDocument();
expect(screen.queryByTestId(`workflow-switcher-edit-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toBeNull();
fireEvent.click(screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`));

View File

@@ -4,7 +4,7 @@ The shared header workflow slot is the canonical desktop workflow selector for P
*/
import type { ReactNode } from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../../api";
import { HeaderWorkflowSwitcherSlot, type HeaderWorkflowSelection } from "../HeaderWorkflowSwitcherSlot";
@@ -144,7 +144,11 @@ describe("HeaderWorkflowSwitcherSlot", () => {
);
fireEvent.click(await screen.findByTestId("workflow-switcher"));
expect(screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toHaveTextContent("All workflows");
const aggregateOption = screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`);
expect(aggregateOption).toHaveTextContent("All workflows");
expect(within(aggregateOption).getByTitle("Todo: 0")).toBeInTheDocument();
expect(within(aggregateOption).getByTitle("In Progress: 0")).toBeInTheDocument();
expect(within(aggregateOption).getByTitle("Done: 0")).toBeInTheDocument();
expect(screen.queryByTestId(`workflow-switcher-edit-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toBeNull();
fireEvent.click(screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`));

View File

@@ -1201,6 +1201,68 @@ describe("ListView", () => {
mobileSpy.mockRestore();
});
it("shows the same single aggregate all-workflows dropdown count in ListView", async () => {
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [
{
id: "builtin:coding",
name: "Coding",
columns: [
{ id: "triage", name: "Triage", flags: { intake: true } },
{ id: "in-progress", name: "In Progress", flags: { countsTowardWip: true } },
{ id: "done", name: "Done", flags: { complete: true } },
{ id: "archived", name: "Archived", flags: { archived: true } },
],
},
{
id: "wf-custom",
name: "Coding",
columns: [
{ id: "backlog", name: "Backlog", flags: { intake: true } },
{ id: "complete", name: "Complete", flags: { complete: true } },
{ id: "hidden", name: "Hidden", flags: { hiddenFromBoard: true } },
],
},
],
taskWorkflowIds: {
"FN-001": "builtin:coding",
"FN-002": "builtin:coding",
"FN-003": "wf-custom",
"FN-004": "wf-custom",
"FN-005": "missing-workflow",
"FN-006": "wf-custom",
"FN-007": "builtin:coding",
},
});
renderListView({
tasks: [
createMockTask({ id: "FN-001", column: "triage", title: "Coding todo" }),
createMockTask({ id: "FN-002", column: "in-progress", title: "Coding active", status: "merging" }),
createMockTask({ id: "FN-003", column: "backlog", title: "Custom todo" }),
createMockTask({ id: "FN-004", column: "complete", title: "Custom done" }),
createMockTask({ id: "FN-005", column: "done", title: "Stale done" }),
createMockTask({ id: "FN-006", column: "hidden", title: "Hidden custom" }),
createMockTask({ id: "FN-007", column: "archived", title: "Archived coding" }),
],
});
await openWorkflowSwitcher();
const aggregateOption = screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`);
expect(within(aggregateOption).getByTitle("Todo: 2")).toBeInTheDocument();
expect(within(aggregateOption).getByTitle("In Progress: 1")).toBeInTheDocument();
expect(within(aggregateOption).getByTitle("Done: 2")).toBeInTheDocument();
expect(within(aggregateOption).getByTitle("1 merging")).toBeInTheDocument();
expect(within(screen.getByTestId("workflow-switcher-option-builtin:coding")).getByTitle("Todo: 1")).toBeInTheDocument();
expect(within(screen.getByTestId("workflow-switcher-option-builtin:coding")).getByTitle("In Progress: 1")).toBeInTheDocument();
expect(within(screen.getByTestId("workflow-switcher-option-builtin:coding")).getByTitle("Done: 1")).toBeInTheDocument();
expect(within(screen.getByTestId("workflow-switcher-option-wf-custom")).getByTitle("Todo: 1")).toBeInTheDocument();
expect(within(screen.getByTestId("workflow-switcher-option-wf-custom")).getByTitle("In Progress: 0")).toBeInTheDocument();
expect(within(screen.getByTestId("workflow-switcher-option-wf-custom")).getByTitle("Done: 1")).toBeInTheDocument();
});
it("shows all workflows in ListView without submitting the aggregate sentinel", async () => {
const mockOnQuickCreate = vi.fn().mockResolvedValue({ id: "FN-new" });
vi.mocked(fetchBoardWorkflows).mockResolvedValue({

View File

@@ -5,6 +5,7 @@ import {
type Task,
} from "@fusion/core";
import type { BoardWorkflowColumn, BoardWorkflowsPayload } from "../../api";
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../../utils/boardWorkflowSelection";
import { computeWorkflowStatusCounts } from "../workflowStatusCounts";
const boardWorkflows: BoardWorkflowsPayload = {
@@ -110,12 +111,13 @@ describe("computeWorkflowStatusCounts", () => {
expect(computeWorkflowStatusCounts(undefined, undefined).size).toBe(0);
});
it("initializes every workflow with zero counts for empty and duplicate/populated states", () => {
it("initializes every workflow and the aggregate sentinel with zero counts for empty and duplicate/populated states", () => {
const counts = computeWorkflowStatusCounts([], boardWorkflows);
expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 0, merging: 0 });
expect(counts.get("design")).toEqual({ todo: 0, inProgress: 0, done: 0, merging: 0 });
expect(counts.get("empty")).toEqual({ todo: 0, inProgress: 0, done: 0, merging: 0 });
expect(counts.get(ALL_WORKFLOWS_BOARD_VIEW_ID)).toEqual({ todo: 0, inProgress: 0, done: 0, merging: 0 });
});
it("classifies todo, in-progress, and done buckets from workflow column flags", () => {
@@ -176,25 +178,61 @@ describe("computeWorkflowStatusCounts", () => {
expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 1, merging: 0 });
});
it("counts tasks independently for their assigned workflow", () => {
it("counts tasks independently for their assigned workflow and aggregates real rows exactly once", () => {
const duplicateNameWorkflow = {
id: "design-copy",
name: "Design",
columns: [
{ id: "copy-todo", name: "Todo", flags: { intake: true } },
{ id: "copy-active", name: "Active", flags: { countsTowardWip: true } },
{ id: "copy-done", name: "Done", flags: { complete: true } },
],
};
const counts = computeWorkflowStatusCounts(
[
task("FN-default-todo", "todo"),
task("FN-missing-assignment", "ready"),
task("FN-stale-assignment", "done"),
task("FN-hidden", "quiet"),
task("FN-archived", "archived"),
task("FN-unknown-column", "missing"),
task("FN-design-todo", "design-todo"),
task("FN-design-active", "design-active"),
taskWithStatus("FN-design-active", "design-active", "merging"),
task("FN-design-done", "design-done"),
taskWithStatus("FN-copy-active", "copy-active", "merging-fix"),
],
{
...boardWorkflows,
workflows: [
{
...boardWorkflows.workflows[0],
columns: [
...boardWorkflows.workflows[0].columns,
{ id: "quiet", name: "Quiet", flags: { hiddenFromBoard: true } },
],
},
boardWorkflows.workflows[1],
duplicateNameWorkflow,
boardWorkflows.workflows[2],
],
taskWorkflowIds: {
"FN-stale-assignment": "deleted-workflow",
"FN-hidden": "default",
"FN-archived": "default",
"FN-unknown-column": "design",
"FN-design-todo": "design",
"FN-design-active": "design",
"FN-design-done": "design",
"FN-copy-active": "design-copy",
},
}
);
expect(counts.get("design")).toEqual({ todo: 1, inProgress: 1, done: 1, merging: 0 });
expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 0, merging: 0 });
expect(counts.get("default")).toEqual({ todo: 2, inProgress: 0, done: 1, merging: 0 });
expect(counts.get("design")).toEqual({ todo: 1, inProgress: 1, done: 1, merging: 1 });
expect(counts.get("design-copy")).toEqual({ todo: 0, inProgress: 1, done: 0, merging: 1 });
expect(counts.get("empty")).toEqual({ todo: 0, inProgress: 0, done: 0, merging: 0 });
expect(counts.get(ALL_WORKFLOWS_BOARD_VIEW_ID)).toEqual({ todo: 3, inProgress: 2, done: 2, merging: 2 });
});
it("tracks actively merging tasks per workflow separately from bucket counts", () => {