FN-6842: fix trait-less workflow status counts

Restore workflow switcher counts for built-in linear workflows with empty column traits.

- Add canonical lifecycle id fallback after flag-based status classification.
- Cover trait-less built-in workflows and flag-authority edge cases in status-count tests.
- Document the built-in fallback behavior for Board and List workflow switchers.

Files changed:
 docs/dashboard-guide.md                            |   2 +-
 .../__tests__/workflowStatusCounts.test.ts         | 207 +++++++++++++++++++--
 .../app/components/workflowStatusCounts.ts         |  64 +++++--
 3 files changed, 246 insertions(+), 27 deletions(-)

Fusion-Task-Id: FN-6842

Fusion-Task-Lineage: f51eb675-74e7-440f-970f-3eb29de0b3ea
This commit is contained in:
gsxdsm
2026-06-21 10:54:59 -07:00
parent 5e1422edc3
commit 465b7b6263
3 changed files with 246 additions and 27 deletions

View File

@@ -79,7 +79,7 @@ Features:
- Task card header meta badges group priority, fast mode, agent-created provenance, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs
- Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` is ordered by most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback)
- On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll.
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived columns) appear while the dropdown is expanded, including on each workflow option. Each option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. 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 name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived columns) 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. Each option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. 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.
![Board view](./screenshots/dashboard-overview.png)

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import type { Task } from "@fusion/core";
import type { BoardWorkflowsPayload } from "../../api";
import {
getBuiltinWorkflow,
resolveColumnFlags,
type Task,
} from "@fusion/core";
import type { BoardWorkflowColumn, BoardWorkflowsPayload } from "../../api";
import { computeWorkflowStatusCounts } from "../workflowStatusCounts";
const boardWorkflows: BoardWorkflowsPayload = {
@@ -15,7 +19,11 @@ const boardWorkflows: BoardWorkflowsPayload = {
{ id: "todo", name: "Todo", flags: { intake: true } },
{ id: "ready", name: "Ready", flags: {} },
{ id: "active", name: "Active", flags: { countsTowardWip: true } },
{ id: "review", name: "Review", flags: { countsTowardWip: true, mergeBlocker: true } },
{
id: "review",
name: "Review",
flags: { countsTowardWip: true, mergeBlocker: true },
},
{ id: "done", name: "Done", flags: { complete: true } },
{ id: "archived", name: "Archived", flags: { archived: true } },
],
@@ -25,7 +33,11 @@ const boardWorkflows: BoardWorkflowsPayload = {
name: "Design",
columns: [
{ id: "design-todo", name: "Todo", flags: { intake: true } },
{ id: "design-active", name: "Active", flags: { countsTowardWip: true } },
{
id: "design-active",
name: "Active",
flags: { countsTowardWip: true },
},
{ id: "design-done", name: "Done", flags: { complete: true } },
{ id: "design-archived", name: "Archived", flags: { archived: true } },
],
@@ -35,7 +47,11 @@ const boardWorkflows: BoardWorkflowsPayload = {
name: "Empty",
columns: [
{ id: "empty-todo", name: "Todo", flags: { intake: true } },
{ id: "empty-active", name: "Active", flags: { countsTowardWip: true } },
{
id: "empty-active",
name: "Active",
flags: { countsTowardWip: true },
},
{ id: "empty-done", name: "Done", flags: { complete: true } },
],
},
@@ -54,9 +70,36 @@ function task(id: string, column: string): Task {
} as Task;
}
function builtinWorkflowColumns(id: string): BoardWorkflowColumn[] {
const workflow = getBuiltinWorkflow(id);
if (!workflow) throw new Error(`Missing built-in workflow fixture: ${id}`);
if (workflow.ir.version !== "v2")
throw new Error(`Built-in workflow fixture is not v2: ${id}`);
return workflow.ir.columns.map((column) => ({
id: column.id,
name: column.name,
flags: resolveColumnFlags(column),
}));
}
function singleWorkflowPayload(
id: string,
columns: BoardWorkflowColumn[]
): BoardWorkflowsPayload {
return {
flagEnabled: true,
defaultWorkflowId: id,
taskWorkflowIds: {},
workflows: [{ id, name: id, columns }],
};
}
describe("computeWorkflowStatusCounts", () => {
it("returns an empty map when workflow metadata is unavailable", () => {
expect(computeWorkflowStatusCounts([task("FN-1", "todo")], null).size).toBe(0);
expect(computeWorkflowStatusCounts([task("FN-1", "todo")], null).size).toBe(
0
);
expect(computeWorkflowStatusCounts(undefined, undefined).size).toBe(0);
});
@@ -77,21 +120,61 @@ describe("computeWorkflowStatusCounts", () => {
task("FN-review", "review"),
task("FN-done", "done"),
],
boardWorkflows,
boardWorkflows
);
expect(counts.get("default")).toEqual({ todo: 2, inProgress: 2, done: 1 });
});
it("keeps flag-based classification authoritative over canonical lifecycle ids", () => {
const counts = computeWorkflowStatusCounts(
[
task("FN-complete-in-progress", "in-progress"),
task("FN-wip-done", "done"),
task("FN-archived-active", "active"),
],
singleWorkflowPayload("flags-win", [
{
id: "in-progress",
name: "Complete despite id",
flags: { complete: true },
},
{
id: "done",
name: "WIP despite id",
flags: { countsTowardWip: true },
},
{
id: "active",
name: "Archived despite id",
flags: { archived: true },
},
])
);
expect(counts.get("flags-win")).toEqual({
todo: 0,
inProgress: 1,
done: 1,
});
});
it("falls back to the default workflow when a task has no workflow assignment", () => {
const counts = computeWorkflowStatusCounts([task("FN-unassigned", "done")], boardWorkflows);
const counts = computeWorkflowStatusCounts(
[task("FN-unassigned", "done")],
boardWorkflows
);
expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 1 });
});
it("counts tasks independently for their assigned workflow", () => {
const counts = computeWorkflowStatusCounts(
[task("FN-design-todo", "design-todo"), task("FN-design-active", "design-active"), task("FN-design-done", "design-done")],
[
task("FN-design-todo", "design-todo"),
task("FN-design-active", "design-active"),
task("FN-design-done", "design-done"),
],
{
...boardWorkflows,
taskWorkflowIds: {
@@ -99,7 +182,7 @@ describe("computeWorkflowStatusCounts", () => {
"FN-design-active": "design",
"FN-design-done": "design",
},
},
}
);
expect(counts.get("design")).toEqual({ todo: 1, inProgress: 1, done: 1 });
@@ -108,15 +191,115 @@ describe("computeWorkflowStatusCounts", () => {
it("excludes archived-column tasks and ignores unknown workflows or columns", () => {
const counts = computeWorkflowStatusCounts(
[task("FN-archived", "archived"), task("FN-unknown-column", "missing"), task("FN-unknown-workflow", "todo")],
[
task("FN-archived", "archived"),
task("FN-unknown-column", "missing"),
task("FN-unknown-workflow", "todo"),
],
{
...boardWorkflows,
taskWorkflowIds: {
"FN-unknown-workflow": "missing-workflow",
},
},
}
);
expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 0 });
});
it("uses real quick-fix empty-trait columns to count the reported two done and zero in-progress state", () => {
const columns = builtinWorkflowColumns("builtin:quick-fix");
expect(
columns.every((column) => Object.keys(column.flags).length === 0)
).toBe(true);
const counts = computeWorkflowStatusCounts(
[task("FN-done-1", "done"), task("FN-done-2", "done")],
singleWorkflowPayload("builtin:quick-fix", columns)
);
expect(counts.get("builtin:quick-fix")).toEqual({
todo: 0,
inProgress: 0,
done: 2,
});
});
it("falls back to canonical lifecycle ids for every linear built-in with synthesized empty traits", () => {
for (const workflowId of [
"builtin:quick-fix",
"builtin:review-heavy",
"builtin:compound-engineering",
]) {
const columns = builtinWorkflowColumns(workflowId);
expect(
columns.every((column) => Object.keys(column.flags).length === 0)
).toBe(true);
const counts = computeWorkflowStatusCounts(
[
task(`${workflowId}-triage`, "triage"),
task(`${workflowId}-todo`, "todo"),
task(`${workflowId}-in-progress`, "in-progress"),
task(`${workflowId}-in-review`, "in-review"),
task(`${workflowId}-done`, "done"),
task(`${workflowId}-archived`, "archived"),
],
singleWorkflowPayload(workflowId, columns)
);
expect(counts.get(workflowId)).toEqual({
todo: 3,
inProgress: 1,
done: 1,
});
}
});
it("initializes and populates flag-less workflow states including multiple done tasks", () => {
const columns = builtinWorkflowColumns("builtin:quick-fix");
const payload = singleWorkflowPayload("builtin:quick-fix", columns);
expect(
computeWorkflowStatusCounts([], payload).get("builtin:quick-fix")
).toEqual({ todo: 0, inProgress: 0, done: 0 });
const counts = computeWorkflowStatusCounts(
[
task("FN-todo", "todo"),
task("FN-review", "in-review"),
task("FN-active", "in-progress"),
task("FN-done-1", "done"),
task("FN-done-2", "done"),
],
payload
);
expect(counts.get("builtin:quick-fix")).toEqual({
todo: 2,
inProgress: 1,
done: 2,
});
});
it("keeps the trait-bearing built-in coding workflow bucketing unchanged", () => {
const counts = computeWorkflowStatusCounts(
[
task("FN-active", "in-progress"),
task("FN-review", "in-review"),
task("FN-done", "done"),
task("FN-archived", "archived"),
],
singleWorkflowPayload(
"builtin:coding",
builtinWorkflowColumns("builtin:coding")
)
);
expect(counts.get("builtin:coding")).toEqual({
todo: 1,
inProgress: 1,
done: 1,
});
});
});

View File

@@ -7,46 +7,82 @@ export interface WorkflowStatusCounts {
done: number;
}
const EMPTY_COUNTS = (): WorkflowStatusCounts => ({ todo: 0, inProgress: 0, done: 0 });
const EMPTY_COUNTS = (): WorkflowStatusCounts => ({
todo: 0,
inProgress: 0,
done: 0,
});
type WorkflowStatusBucket = keyof WorkflowStatusCounts | "excluded";
/**
* FNXC:WorkflowSwitcher 2026-06-20-00:09:
* The board/list workflow dropdown must show compact Todo, In Progress, and Done task counts for every selectable workflow without duplicating logic across render surfaces.
* Use workflow column flags as the source of truth: archived columns are excluded, complete columns count as Done, active non-intake WIP columns count as In Progress, and all remaining visible work counts as Todo/not-yet-started.
*
* FNXC:WorkflowSwitcher 2026-06-21-00:00:
* Built-in linear workflows synthesize canonical lifecycle columns with empty traits, so their resolved flags cannot identify Done, In Progress, or Archived buckets.
* Fall back to canonical lifecycle column ids only after flag-based classification fails, keeping trait-bearing workflows authoritative while preventing Done tasks in Quick fix-style lanes from being miscounted.
*/
function classifyWorkflowStatusColumn(
column: BoardWorkflowColumn
): WorkflowStatusBucket {
if (column.flags.archived) return "excluded";
if (column.flags.complete) return "done";
if (column.flags.countsTowardWip && !column.flags.intake) return "inProgress";
switch (column.id) {
case "archived":
return "excluded";
case "done":
return "done";
case "in-progress":
return "inProgress";
default:
return "todo";
}
}
export function computeWorkflowStatusCounts(
tasks: readonly Task[] | null | undefined,
boardWorkflows: BoardWorkflowsPayload | null | undefined,
boardWorkflows: BoardWorkflowsPayload | null | undefined
): Map<string, WorkflowStatusCounts> {
const countsByWorkflow = new Map<string, WorkflowStatusCounts>();
if (!boardWorkflows) return countsByWorkflow;
const workflowsById = new Map(boardWorkflows.workflows.map((workflow) => [workflow.id, workflow]));
const columnsByWorkflowId = new Map<string, Map<string, BoardWorkflowColumn>>();
const workflowsById = new Map(
boardWorkflows.workflows.map((workflow) => [workflow.id, workflow])
);
const columnsByWorkflowId = new Map<
string,
Map<string, BoardWorkflowColumn>
>();
for (const workflow of boardWorkflows.workflows) {
countsByWorkflow.set(workflow.id, EMPTY_COUNTS());
columnsByWorkflowId.set(workflow.id, new Map(workflow.columns.map((column) => [column.id, column])));
columnsByWorkflowId.set(
workflow.id,
new Map(workflow.columns.map((column) => [column.id, column]))
);
}
if (!tasks?.length) return countsByWorkflow;
for (const task of tasks) {
const workflowId = boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId;
const workflowId =
boardWorkflows.taskWorkflowIds[task.id] ??
boardWorkflows.defaultWorkflowId;
const workflow = workflowsById.get(workflowId);
if (!workflow) continue;
const column = columnsByWorkflowId.get(workflow.id)?.get(task.column);
if (!column || column.flags.archived) continue;
if (!column) continue;
const bucket = classifyWorkflowStatusColumn(column);
if (bucket === "excluded") continue;
const counts = countsByWorkflow.get(workflow.id) ?? EMPTY_COUNTS();
if (column.flags.complete) {
counts.done += 1;
} else if (column.flags.countsTowardWip && !column.flags.intake) {
counts.inProgress += 1;
} else {
counts.todo += 1;
}
counts[bucket] += 1;
countsByWorkflow.set(workflow.id, counts);
}