FN-7238: add Done column sort modes

Add Done-column controls for choosing descending completion-date or task-ID order.

- Add a Done-only sort selector shared by legacy and workflow complete columns.
- Extend task display sorting with completion-date and task-ID descending modes.
- Cover board, column, and sorting behavior with regression tests and docs.
- Add a minor changeset for the published CLI package.

Files changed:
 .changeset/fn-7238-done-column-sort.md             |   7 +
 docs/dashboard-guide.md                            |   5 +-
 packages/dashboard/app/components/Board.tsx        |  28 +++-
 packages/dashboard/app/components/Column.tsx       |  33 ++++-
 .../app/components/__tests__/Board.test.tsx        | 133 ++++++++++++++++-
 .../app/components/__tests__/Column.test.tsx       | 158 +++++++++++++++++++++
 .../app/components/__tests__/taskSorting.test.ts   | 114 +++++++++++++++
 packages/dashboard/app/components/taskSorting.ts   |  39 ++++-
 packages/dashboard/app/styles.css                  |  35 +++++
 9 files changed, 538 insertions(+), 14 deletions(-)

Fusion-Task-Id: FN-7238

Fusion-Task-Lineage: 143722e9-0b59-41e5-954d-810263f18ba9

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 15:46:35 -07:00
parent 0ff60a79d1
commit 5b668d297f
9 changed files with 539 additions and 15 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Let operators sort the board Done column by completion date or task ID.
category: feature
dev: Adds Done-column-only descending sort modes while preserving existing completion-date default ordering.

View File

@@ -133,7 +133,10 @@ Features:
- PR/issue badges with live updates - PR/issue badges with live updates
- GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown in the footer with other external-source metadata - GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown in the footer with other external-source metadata
- 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 - 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) <!-- FNXC:BoardDoneSorting 2026-06-29-00:00: The Done board column exposes a local descending sort selector so operators can review either latest completions or highest task IDs without changing other lifecycle columns. -->
<!-- FNXC:BoardDoneSorting 2026-06-29-20:28: Document both Done sort modes as descending-only and Done-column-only so legacy Done and workflow complete-lane operators understand the selector does not change other lifecycle columns. -->
- 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` defaults to most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback) and can be switched from the Done column header to descending task ID. In workflow mode, non-archived columns marked with the `complete` flag use the same Done ordering even when their column ID or label is customized.
- Done-column sorting has two descending modes: **Completion date (newest first)** keeps the default completion-time order, while **Task ID (newest first)** places the highest numeric task IDs first. The selector is only shown on Done/complete columns, including custom workflow completion lanes.
- 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. - 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.
<!-- FNXC:WorkflowSelection 2026-06-29-13:34: Board, List, Header, and Graph workflow selectors now share a durable per-project selection so operators return to the same lane after remounts, task refreshes, or respecification flows; stale saved workflow ids must fall back to a valid default/first workflow instead of hiding all tasks. --> <!-- FNXC:WorkflowSelection 2026-06-29-13:34: Board, List, Header, and Graph workflow selectors now share a durable per-project selection so operators return to the same lane after remounts, task refreshes, or respecification flows; stale saved workflow ids must fall back to a valid default/first workflow instead of hiding all tasks. -->
- 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) 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. Each 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 name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived 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. Each 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`.

View File

@@ -1,6 +1,6 @@
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } from "@fusion/core"; import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } from "@fusion/core";
import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core"; import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core";
import { sortTasksForDisplayColumn } from "./taskSorting"; import { sortTasksForDisplayColumn, type DoneColumnSortMode } from "./taskSorting";
import { Column } from "./Column"; import { Column } from "./Column";
import "./Lane.css"; import "./Lane.css";
import "./Board.css"; import "./Board.css";
@@ -139,6 +139,11 @@ function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) {
export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) { export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true); const [archivedCollapsed, setArchivedCollapsed] = useState(true);
/*
FNXC:DoneColumnSorting 2026-06-29-16:57:
Board owns one Done sort mode so legacy and built-in workflow Done surfaces stay in sync; the default remains completion-date descending to preserve existing first-load ordering.
*/
const [doneSortMode, setDoneSortMode] = useState<DoneColumnSortMode>("completion-date-desc");
const archivedLoadedRef = useRef(false); const archivedLoadedRef = useRef(false);
const boardRef = useRef<HTMLElement | null>(null); const boardRef = useRef<HTMLElement | null>(null);
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => { const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
@@ -223,7 +228,9 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
const stableGrouped = {} as Record<ColumnType, Task[]>; const stableGrouped = {} as Record<ColumnType, Task[]>;
for (const column of COLUMNS) { for (const column of COLUMNS) {
const sortedTasks = sortTasksForDisplayColumn(nextGrouped[column], column); const sortedTasks = column === "done"
? sortTasksForDisplayColumn(nextGrouped[column], column, doneSortMode)
: sortTasksForDisplayColumn(nextGrouped[column], column);
stableGrouped[column] = areTaskArraysEqual(previousGrouped[column], sortedTasks) stableGrouped[column] = areTaskArraysEqual(previousGrouped[column], sortedTasks)
? previousGrouped[column] ? previousGrouped[column]
: sortedTasks; : sortedTasks;
@@ -231,7 +238,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
tasksByColumnCacheRef.current = stableGrouped; tasksByColumnCacheRef.current = stableGrouped;
return stableGrouped; return stableGrouped;
}, [tasks]); }, [tasks, doneSortMode]);
// FN-4574 + FN-001 diagnosis: on iOS Safari, the mobile board can occasionally // FN-4574 + FN-001 diagnosis: on iOS Safari, the mobile board can occasionally
// snap against stale layout/visualViewport metrics before flex columns resolve, // snap against stale layout/visualViewport metrics before flex columns resolve,
@@ -432,10 +439,17 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
(grouped[task.column] ??= []).push(task); (grouped[task.column] ??= []).push(task);
} }
for (const column of selectedWorkflow.columns) { for (const column of selectedWorkflow.columns) {
grouped[column.id] = sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType); /*
FNXC:DoneColumnSorting 2026-06-29-20:20:
Workflow-mode Done sorting follows the workflow trait, not only the built-in `done` id, so custom complete lanes get the same descending completion-date/task-id selector while archived lanes keep their own behavior.
*/
const isWorkflowDoneLikeColumn = column.flags.complete === true && column.flags.archived !== true;
grouped[column.id] = isWorkflowDoneLikeColumn
? sortTasksForDisplayColumn(grouped[column.id] ?? [], "done", doneSortMode)
: sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType);
} }
return grouped; return grouped;
}, [selectedWorkflow, selectedWorkflowTasks]); }, [doneSortMode, selectedWorkflow, selectedWorkflowTasks]);
// Card-placed field defs grouped by workflow id (U13/KTD-14). Only recomputes // Card-placed field defs grouped by workflow id (U13/KTD-14). Only recomputes
// when the board-workflows payload changes, not on every SSE task tick. // when the board-workflows payload changes, not on every SSE task tick.
@@ -535,6 +549,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
> >
{selectedWorkflowColumns.map((columnDef) => { {selectedWorkflowColumns.map((columnDef) => {
const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId; const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId;
const isWorkflowDoneLikeColumn = columnDef.flags.complete === true && columnDef.flags.archived !== true;
return ( return (
<Column <Column
key={columnDef.id} key={columnDef.id}
@@ -579,6 +594,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
{...(isCreateColumn ? { onQuickCreate: handleWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} {...(isCreateColumn ? { onQuickCreate: handleWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})} {...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
{...(isWorkflowDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
/> />
); );
})} })}
@@ -671,7 +687,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
autoMerge={autoMerge} autoMerge={autoMerge}
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} {...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(col === "in-review" ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(col === "in-review" ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
{...(col === "done" ? { onArchiveAllDone } : {})} {...(col === "done" ? { onArchiveAllDone, doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})} {...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
/> />
))} ))}

View File

@@ -14,6 +14,7 @@ import type { ToastType } from "../hooks/useToast";
import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react"; import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react";
import type { ModelInfo, BoardWorkflowColumnFlags } from "../api"; import type { ModelInfo, BoardWorkflowColumnFlags } from "../api";
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout"; import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
import type { DoneColumnSortMode } from "./taskSorting";
const PAGINATED_COLUMN_THRESHOLD = 100; const PAGINATED_COLUMN_THRESHOLD = 100;
const VISIBLE_TASKS_INITIAL = 50; const VISIBLE_TASKS_INITIAL = 50;
@@ -115,6 +116,10 @@ interface ColumnProps {
githubIssueAction?: GithubIssueAction; githubIssueAction?: GithubIssueAction;
}) => Promise<Task>; }) => Promise<Task>;
onArchiveAllDone?: () => Promise<Task[]>; onArchiveAllDone?: () => Promise<Task[]>;
/** Current Done-column display order, supplied only for the board's Done surface. */
doneSortMode?: DoneColumnSortMode;
/** Updates the board-local Done-column display order. */
onDoneSortModeChange?: (mode: DoneColumnSortMode) => void;
collapsed?: boolean; collapsed?: boolean;
onToggleCollapse?: () => void; onToggleCollapse?: () => void;
allTasks?: Task[]; allTasks?: Task[];
@@ -172,7 +177,7 @@ interface ColumnProps {
getDraggingTaskId?: () => string | null; getDraggingTaskId?: () => string | null;
} }
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) { function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
// Anchor the board.rejection.* catalog keys for the i18next extractor (it // Anchor the board.rejection.* catalog keys for the i18next extractor (it
// scopes `t` to the useTranslation binding, so the shared translateRejection // scopes `t` to the useTranslation binding, so the shared translateRejection
@@ -535,6 +540,14 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
} }
}, [tasks, columnLabelText, onMoveTask, addToast, confirm, t]); }, [tasks, columnLabelText, onMoveTask, addToast, confirm, t]);
/*
FNXC:DoneColumnSorting 2026-06-29-20:23:
In workflow mode, the Done-sort control belongs to non-archived complete lanes even when the workflow uses a custom column id such as `shipped`; legacy mode remains limited to the literal Done column.
*/
const isDoneSortColumn = workflowMode ? columnFlags?.complete === true && columnFlags?.archived !== true : column === "done";
const showDoneSortControl = isDoneSortColumn && doneSortMode !== undefined && !!onDoneSortModeChange;
const doneSortControlLabel = t("column.doneSortControlLabel", "Sort Done tasks");
const handleArchiveAll = useCallback(async () => { const handleArchiveAll = useCallback(async () => {
if (!onArchiveAllDone) return; if (!onArchiveAllDone) return;
if (tasks.length === 0) return; if (tasks.length === 0) return;
@@ -582,6 +595,24 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
+ {t("column.newTask", "New Task")} + {t("column.newTask", "New Task")}
</button> </button>
)} )}
{showDoneSortControl && (
<label className="done-sort-control" title={doneSortControlLabel}>
{/*
FNXC:DoneColumnSorting 2026-06-29-18:09:
The Done header needs an accessible, Done-only control that preserves Archive All and other header actions while allowing operators to switch between completion-date-desc and task-id-desc display orders.
*/}
<span className="done-sort-control__label">{t("column.doneSortLabel", "Sort")}</span>
<select
className="done-sort-control__select"
aria-label={doneSortControlLabel}
value={doneSortMode}
onChange={(event) => onDoneSortModeChange(event.target.value as DoneColumnSortMode)}
>
<option value="completion-date-desc">{t("column.doneSortCompletionDateDesc", "Completion date (newest first)")}</option>
<option value="task-id-desc">{t("column.doneSortTaskIdDesc", "Task ID (newest first)")}</option>
</select>
</label>
)}
{column === "done" && onArchiveAllDone && ( {column === "done" && onArchiveAllDone && (
<button <button
className="btn btn-icon btn-sm" className="btn btn-icon btn-sm"

View File

@@ -50,11 +50,44 @@ const columnRenderCounts: Record<string, number> = {};
// Mock child components so we only test Board's own rendering // Mock child components so we only test Board's own rendering
vi.mock("../Column", () => ({ vi.mock("../Column", () => ({
Column: React.memo(({ column, tasks, collapsed, onToggleCollapse, onQuickCreate, onNewTask, onToggleAutoMerge, onArchiveAllDone, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive }: { column: string; tasks: Task[]; collapsed?: boolean; onToggleCollapse?: () => void; onQuickCreate?: unknown; onNewTask?: unknown; onToggleAutoMerge?: () => void; onArchiveAllDone?: unknown; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; isSearchActive?: boolean }) => { Column: React.memo(({
column,
tasks,
collapsed,
onToggleCollapse,
onQuickCreate,
onNewTask,
onToggleAutoMerge,
onArchiveAllDone,
favoriteProviders,
favoriteModels,
onToggleFavorite,
onToggleModelFavorite,
isSearchActive,
doneSortMode,
onDoneSortModeChange,
}: {
column: string;
tasks: Task[];
collapsed?: boolean;
onToggleCollapse?: () => void;
onQuickCreate?: unknown;
onNewTask?: unknown;
onToggleAutoMerge?: () => void;
onArchiveAllDone?: unknown;
favoriteProviders?: string[];
favoriteModels?: string[];
onToggleFavorite?: (provider: string) => void;
onToggleModelFavorite?: (modelId: string) => void;
isSearchActive?: boolean;
doneSortMode?: string;
onDoneSortModeChange?: (mode: "completion-date-desc" | "task-id-desc") => void;
}) => {
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1; columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
return ( return (
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-collapsed={collapsed ? "true" : "false"} data-has-quick-create={onQuickCreate ? "yes" : "no"} data-has-new-task={onNewTask ? "yes" : "no"} data-has-auto-merge-toggle={onToggleAutoMerge ? "yes" : "no"} data-has-archive-all={onArchiveAllDone ? "yes" : "no"} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"}> <div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-collapsed={collapsed ? "true" : "false"} data-has-quick-create={onQuickCreate ? "yes" : "no"} data-has-new-task={onNewTask ? "yes" : "no"} data-has-auto-merge-toggle={onToggleAutoMerge ? "yes" : "no"} data-has-archive-all={onArchiveAllDone ? "yes" : "no"} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"} data-done-sort-mode={doneSortMode ?? ""} data-has-done-sort-handler={onDoneSortModeChange ? "yes" : "no"}>
{onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>} {onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>}
{onDoneSortModeChange && <button type="button" onClick={() => onDoneSortModeChange("task-id-desc")}>sort-{column}-by-id</button>}
</div> </div>
); );
}), }),
@@ -497,6 +530,40 @@ describe("Board", () => {
expect(doneTasks.map((t: Task) => t.id)).toEqual(["FN-012", "FN-011", "FN-010"]); expect(doneTasks.map((t: Task) => t.id)).toEqual(["FN-012", "FN-011", "FN-010"]);
}); });
it("threads Done sort state through the legacy board without altering other columns", () => {
const tasks: Task[] = [
createTask({ id: "FN-003", description: "Old done", column: "done", columnMovedAt: "2024-01-01T09:00:00.000Z" }),
createTask({ id: "FN-001", description: "New done", column: "done", columnMovedAt: "2024-01-01T11:00:00.000Z" }),
createTask({ id: "FN-002", description: "Tie low id", column: "done", columnMovedAt: "2024-01-01T10:00:00.000Z" }),
createTask({ id: "FN-004", description: "Tie high id", column: "done", columnMovedAt: "2024-01-01T10:00:00.000Z" }),
createTask({ id: "FN-050", description: "Todo fifty", column: "todo", priority: "normal", createdAt: "2024-01-01T10:00:00.000Z" }),
createTask({ id: "FN-010", description: "Todo ten", column: "todo", priority: "normal", createdAt: "2024-01-01T10:00:00.000Z" }),
];
renderBoard({ tasks });
const readIds = (column: string) => (JSON.parse(screen.getByTestId(`column-${column}`).getAttribute("data-tasks") || "[]") as Task[]).map((task) => task.id);
expect(screen.getByTestId("column-done")).toHaveAttribute("data-done-sort-mode", "completion-date-desc");
expect(screen.getByTestId("column-done")).toHaveAttribute("data-has-done-sort-handler", "yes");
expect(readIds("done")).toEqual(["FN-001", "FN-002", "FN-004", "FN-003"]);
expect(readIds("todo")).toEqual(["FN-010", "FN-050"]);
expect(screen.getByTestId("column-todo")).toHaveAttribute("data-has-done-sort-handler", "no");
fireEvent.click(screen.getByRole("button", { name: "sort-done-by-id" }));
expect(screen.getByTestId("column-done")).toHaveAttribute("data-done-sort-mode", "task-id-desc");
expect(readIds("done")).toEqual(["FN-004", "FN-003", "FN-002", "FN-001"]);
expect(readIds("todo")).toEqual(["FN-010", "FN-050"]);
});
it("passes Done sort state to an empty legacy Done column", () => {
renderBoard({ tasks: [] });
expect(screen.getByTestId("column-done")).toHaveAttribute("data-tasks", "[]");
expect(screen.getByTestId("column-done")).toHaveAttribute("data-done-sort-mode", "completion-date-desc");
expect(screen.getByTestId("column-done")).toHaveAttribute("data-has-done-sort-handler", "yes");
});
it("orders todo by priority before age", () => { it("orders todo by priority before age", () => {
const tasks: Task[] = [ const tasks: Task[] = [
createTask({ createTask({
@@ -1322,6 +1389,68 @@ describe("Board", () => {
expect(screen.queryByTestId("column-archived")).toBeNull(); expect(screen.queryByTestId("column-archived")).toBeNull();
}); });
it("built-in workflow Done uses the selected Done sort mode", async () => {
const tasks = [
mkTask({ id: "FN-003", column: "done", columnMovedAt: "2024-01-01T09:00:00.000Z" }),
mkTask({ id: "FN-001", column: "done", columnMovedAt: "2024-01-01T11:00:00.000Z" }),
mkTask({ id: "FN-002", column: "done", columnMovedAt: "2024-01-01T10:00:00.000Z" }),
mkTask({ id: "FN-004", column: "done", columnMovedAt: "2024-01-01T10:00:00.000Z" }),
mkTask({ id: "FN-050", column: "todo", priority: "normal", createdAt: "2024-01-01T10:00:00.000Z" }),
mkTask({ id: "FN-010", column: "todo", priority: "normal", createdAt: "2024-01-01T10:00:00.000Z" }),
];
enableFlag(Object.fromEntries(tasks.map((task) => [task.id, "builtin:coding"])));
renderBoard({ tasks });
const readIds = (column: string) => (JSON.parse(screen.getByTestId(`column-${column}`).getAttribute("data-tasks") || "[]") as Task[]).map((task) => task.id);
await waitFor(() => expect(screen.getByTestId("column-done")).toHaveAttribute("data-done-sort-mode", "completion-date-desc"));
expect(readIds("done")).toEqual(["FN-001", "FN-002", "FN-004", "FN-003"]);
expect(readIds("todo")).toEqual(["FN-010", "FN-050"]);
expect(screen.getByTestId("column-todo")).toHaveAttribute("data-has-done-sort-handler", "no");
fireEvent.click(screen.getByRole("button", { name: "sort-done-by-id" }));
expect(screen.getByTestId("column-done")).toHaveAttribute("data-done-sort-mode", "task-id-desc");
expect(readIds("done")).toEqual(["FN-004", "FN-003", "FN-002", "FN-001"]);
expect(readIds("todo")).toEqual(["FN-010", "FN-050"]);
});
it("passes Done sort state to an empty built-in workflow Done column", async () => {
enableFlag({});
renderBoard({ tasks: [] });
await waitFor(() => expect(screen.getByTestId("column-done")).toHaveAttribute("data-tasks", "[]"));
expect(screen.getByTestId("column-done")).toHaveAttribute("data-done-sort-mode", "completion-date-desc");
expect(screen.getByTestId("column-done")).toHaveAttribute("data-has-done-sort-handler", "yes");
});
it("uses the selected Done sort mode for custom complete workflow columns", async () => {
const workflow = {
id: "wf-shipped",
name: "Custom shipped",
columns: [
{ id: "todo", name: "Todo", flags: { intake: true } },
{ id: "shipped", name: "Shipped", flags: { complete: true } },
],
};
const tasks = [
mkTask({ id: "FN-003", column: "shipped", priority: "normal", columnMovedAt: "2024-01-01T09:00:00.000Z" }),
mkTask({ id: "FN-001", column: "shipped", priority: "normal", columnMovedAt: "2024-01-01T11:00:00.000Z" }),
mkTask({ id: "FN-002", column: "shipped", priority: "normal", columnMovedAt: "2024-01-01T10:00:00.000Z" }),
];
enableFlag({ "FN-003": workflow.id, "FN-001": workflow.id, "FN-002": workflow.id }, [workflow]);
renderBoard({ tasks });
const readIds = () => (JSON.parse(screen.getByTestId("column-shipped").getAttribute("data-tasks") || "[]") as Task[]).map((task) => task.id);
await waitFor(() => expect(screen.getByTestId("column-shipped")).toHaveAttribute("data-done-sort-mode", "completion-date-desc"));
expect(screen.getByTestId("column-shipped")).toHaveAttribute("data-has-done-sort-handler", "yes");
expect(readIds()).toEqual(["FN-001", "FN-002", "FN-003"]);
fireEvent.click(screen.getByRole("button", { name: "sort-shipped-by-id" }));
expect(screen.getByTestId("column-shipped")).toHaveAttribute("data-done-sort-mode", "task-id-desc");
expect(readIds()).toEqual(["FN-003", "FN-002", "FN-001"]);
});
it("done column in workflow mode receives onArchiveAllDone prop", async () => { it("done column in workflow mode receives onArchiveAllDone prop", async () => {
const onArchiveAllDone = vi.fn(); const onArchiveAllDone = vi.fn();
enableFlag({ "FN-1": "builtin:coding" }); enableFlag({ "FN-1": "builtin:coding" });

View File

@@ -651,6 +651,164 @@ describe("Column in-progress/in-review bulk actions", () => {
}); });
}); });
describe("Column Done sort control", () => {
it("renders an accessible Done-only sort selector with clear labels", () => {
render(
<Column
{...defaultProps}
column="done"
tasks={[{ ...makeTask("FN-001"), column: "done" }]}
doneSortMode="completion-date-desc"
onDoneSortModeChange={vi.fn()}
/>,
);
const select = screen.getByRole("combobox", { name: "Sort Done tasks" });
expect(select.closest(".done-sort-control")).toHaveAttribute("title", "Sort Done tasks");
expect(screen.getByText("Sort")).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Completion date (newest first)" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Task ID (newest first)" })).toBeInTheDocument();
expect(select.closest(".done-sort-control")).not.toBeNull();
expect(select.closest(".column-header")).not.toBeNull();
});
it("renders the selector for workflow complete columns with custom ids", () => {
render(
<Column
{...defaultProps}
column={"shipped" as ColumnType}
workflowMode
columnDisplayName="Shipped"
columnFlags={{ complete: true }}
tasks={[{ ...makeTask("FN-001"), column: "shipped" as ColumnType }]}
doneSortMode="completion-date-desc"
onDoneSortModeChange={vi.fn()}
/>,
);
expect(screen.getByRole("heading", { name: "Shipped" })).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Sort Done tasks" })).toBeInTheDocument();
});
it("selects task ID descending from the Done header", async () => {
const user = userEvent.setup();
const onDoneSortModeChange = vi.fn();
render(
<Column
{...defaultProps}
column="done"
tasks={[{ ...makeTask("FN-001"), column: "done" }]}
doneSortMode="completion-date-desc"
onDoneSortModeChange={onDoneSortModeChange}
/>,
);
await user.selectOptions(screen.getByRole("combobox", { name: "Sort Done tasks" }), "task-id-desc");
expect(onDoneSortModeChange).toHaveBeenCalledWith("task-id-desc");
});
it("selects completion-date descending from the Done header", async () => {
const user = userEvent.setup();
const onDoneSortModeChange = vi.fn();
render(
<Column
{...defaultProps}
column="done"
tasks={[{ ...makeTask("FN-002"), column: "done" }]}
doneSortMode="task-id-desc"
onDoneSortModeChange={onDoneSortModeChange}
/>,
);
await user.selectOptions(screen.getByRole("combobox", { name: "Sort Done tasks" }), "completion-date-desc");
expect(onDoneSortModeChange).toHaveBeenCalledWith("completion-date-desc");
});
it("keeps the Done sort selector available when Done is empty", () => {
render(
<Column
{...defaultProps}
column="done"
tasks={[]}
doneSortMode="completion-date-desc"
onDoneSortModeChange={vi.fn()}
/>,
);
expect(screen.getByRole("combobox", { name: "Sort Done tasks" })).toBeInTheDocument();
expect(screen.getByText("0")).toHaveClass("column-count");
});
it("coexists with Archive All Done without disabling sort selection", () => {
render(
<Column
{...defaultProps}
column="done"
tasks={[{ ...makeTask("FN-001"), column: "done" }]}
onArchiveAllDone={vi.fn().mockResolvedValue([])}
doneSortMode="completion-date-desc"
onDoneSortModeChange={vi.fn()}
/>,
);
expect(screen.getByRole("combobox", { name: "Sort Done tasks" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Archive all done tasks" })).toBeEnabled();
const header = screen.getByRole("heading", { name: "Done" }).closest(".column-header") as HTMLElement;
expect(header.querySelector(".done-sort-control")).not.toBeNull();
expect(header.querySelector(".btn-icon")).not.toBeNull();
});
it("keeps Done header actions in the wrapping-friendly header structure", () => {
render(
<Column
{...defaultProps}
column="done"
tasks={[{ ...makeTask("FN-001"), column: "done" }]}
onArchiveAllDone={vi.fn().mockResolvedValue([])}
doneSortMode="completion-date-desc"
onDoneSortModeChange={vi.fn()}
/>,
);
const header = screen.getByRole("heading", { name: "Done" }).closest(".column-header") as HTMLElement;
expect(header).toBeInTheDocument();
expect(header.querySelector(".column-count")?.textContent).toBe("1");
expect(screen.getByRole("combobox", { name: "Sort Done tasks" }).closest(".done-sort-control")?.parentElement).toBe(header);
expect(screen.getByRole("button", { name: "Archive all done tasks" }).parentElement).toBe(header);
});
it("hides the sort control and leaves no wrapper on non-Done columns", () => {
const { container } = render(
<Column
{...defaultProps}
column="todo"
tasks={[{ ...makeTask("FN-001"), column: "todo" }]}
doneSortMode="completion-date-desc"
onDoneSortModeChange={vi.fn()}
/>,
);
expect(screen.queryByRole("combobox", { name: "Sort Done tasks" })).toBeNull();
expect(container.querySelector(".done-sort-control")).toBeNull();
expect(container.querySelector("[aria-label='Sort Done tasks']")).toBeNull();
});
it("hides the sort control on Done when sort props are absent", () => {
const { container } = render(
<Column
{...defaultProps}
column="done"
tasks={[{ ...makeTask("FN-001"), column: "done" }]}
/>,
);
expect(screen.queryByRole("combobox", { name: "Sort Done tasks" })).toBeNull();
expect(container.querySelector(".done-sort-control")).toBeNull();
});
});
describe("Column same-column drop", () => { describe("Column same-column drop", () => {
it("does not call onMoveTask when dropping task into its current column", () => { it("does not call onMoveTask when dropping task into its current column", () => {
const onMoveTask = vi.fn().mockResolvedValue({} as Task); const onMoveTask = vi.fn().mockResolvedValue({} as Task);

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import type { Task } from "@fusion/core";
import { sortTasksForDisplayColumn } from "../taskSorting";
function task(overrides: Partial<Task> & { id: string }): Task {
return {
id: overrides.id,
title: overrides.title ?? overrides.id,
description: overrides.description ?? "",
column: overrides.column ?? "done",
status: overrides.status ?? "idle",
priority: overrides.priority ?? "normal",
createdAt: overrides.createdAt ?? "2026-06-01T00:00:00.000Z",
updatedAt: overrides.updatedAt ?? "2026-06-01T00:00:00.000Z",
columnMovedAt: overrides.columnMovedAt,
dependencies: overrides.dependencies ?? [],
...overrides,
} as Task;
}
function ids(tasks: Task[]): string[] {
return tasks.map((entry) => entry.id);
}
describe("sortTasksForDisplayColumn", () => {
it("keeps the shared helper safe for empty done arrays", () => {
expect(sortTasksForDisplayColumn([], "done")).toEqual([]);
});
it("defaults Done to completion-date descending with numeric task-id ascending ties", () => {
const tasks = [
task({ id: "FN-7240", columnMovedAt: "2026-06-01T00:00:00.000Z" }),
task({ id: "FN-7239", columnMovedAt: "2026-06-03T00:00:00.000Z" }),
task({ id: "FN-7238", columnMovedAt: "2026-06-03T00:00:00.000Z" }),
];
expect(ids(sortTasksForDisplayColumn(tasks, "done"))).toEqual(["FN-7238", "FN-7239", "FN-7240"]);
});
it("matches the default when Done completion-date descending is explicit", () => {
const tasks = [
task({ id: "FN-7238", columnMovedAt: "2026-06-01T00:00:00.000Z" }),
task({ id: "FN-7239", columnMovedAt: "2026-06-02T00:00:00.000Z" }),
task({ id: "FN-7240", columnMovedAt: "2026-06-03T00:00:00.000Z" }),
];
expect(ids(sortTasksForDisplayColumn(tasks, "done", "completion-date-desc"))).toEqual([
"FN-7240",
"FN-7239",
"FN-7238",
]);
});
it("sorts Done by numeric task id descending when requested", () => {
const tasks = [
task({ id: "FN-7239", columnMovedAt: "2026-06-03T00:00:00.000Z" }),
task({ id: "FN-7240", columnMovedAt: "2026-06-01T00:00:00.000Z" }),
task({ id: "FN-7238", columnMovedAt: "2026-06-04T00:00:00.000Z" }),
];
expect(ids(sortTasksForDisplayColumn(tasks, "done", "task-id-desc"))).toEqual([
"FN-7240",
"FN-7239",
"FN-7238",
]);
});
it("uses lexical descending fallback for non-numeric Done task ids", () => {
const tasks = [
task({ id: "TASK-alpha" }),
task({ id: "TASK-charlie" }),
task({ id: "TASK-bravo" }),
];
expect(ids(sortTasksForDisplayColumn(tasks, "done", "task-id-desc"))).toEqual([
"TASK-charlie",
"TASK-bravo",
"TASK-alpha",
]);
});
it("falls back from missing or invalid Done dates to deterministic task-id ties", () => {
const tasks = [
task({ id: "FN-7240", createdAt: "not-a-date", updatedAt: undefined, columnMovedAt: undefined }),
task({ id: "FN-7238", createdAt: "also-not-a-date", updatedAt: undefined, columnMovedAt: undefined }),
task({
id: "FN-7239",
createdAt: "2026-06-02T00:00:00.000Z",
updatedAt: undefined,
columnMovedAt: undefined,
}),
];
expect(ids(sortTasksForDisplayColumn(tasks, "done", "completion-date-desc"))).toEqual([
"FN-7239",
"FN-7238",
"FN-7240",
]);
});
it("does not apply the Done sort mode to other display columns", () => {
const tasks = [
task({ id: "FN-7240", column: "todo", priority: "low", createdAt: "2026-06-03T00:00:00.000Z" }),
task({ id: "FN-7238", column: "todo", priority: "urgent", createdAt: "2026-06-02T00:00:00.000Z" }),
task({ id: "FN-7239", column: "todo", priority: "normal", createdAt: "2026-06-01T00:00:00.000Z" }),
];
expect(ids(sortTasksForDisplayColumn(tasks, "todo", "task-id-desc"))).toEqual([
"FN-7238",
"FN-7239",
"FN-7240",
]);
});
});

View File

@@ -1,5 +1,7 @@
import type { Task, Column } from "@fusion/core"; import type { Task, Column } from "@fusion/core";
export type DoneColumnSortMode = "completion-date-desc" | "task-id-desc";
function getTaskPriorityRank(priority: Task["priority"] | null | undefined): number { function getTaskPriorityRank(priority: Task["priority"] | null | undefined): number {
if (priority === "urgent") return 3; if (priority === "urgent") return 3;
if (priority === "high") return 2; if (priority === "high") return 2;
@@ -11,17 +13,35 @@ function compareTaskPriority(a: Task["priority"] | null | undefined, b: Task["pr
return getTaskPriorityRank(b) - getTaskPriorityRank(a); return getTaskPriorityRank(b) - getTaskPriorityRank(a);
} }
function compareTaskIdNumeric(a: string, b: string): number { function getTaskIdNumericToken(id: string): number | null {
const aNum = Number.parseInt(a.slice(a.lastIndexOf("-") + 1), 10); const token = id.slice(id.lastIndexOf("-") + 1);
const bNum = Number.parseInt(b.slice(b.lastIndexOf("-") + 1), 10); if (!/^\d+$/.test(token)) return null;
const parsed = Number.parseInt(token, 10);
return Number.isFinite(parsed) ? parsed : null;
}
if (Number.isFinite(aNum) && Number.isFinite(bNum) && aNum !== bNum) { function compareTaskIdNumeric(a: string, b: string): number {
const aNum = getTaskIdNumericToken(a);
const bNum = getTaskIdNumericToken(b);
if (aNum !== null && bNum !== null && aNum !== bNum) {
return aNum - bNum; return aNum - bNum;
} }
return a.localeCompare(b); return a.localeCompare(b);
} }
function compareTaskIdNumericDesc(a: string, b: string): number {
const aNum = getTaskIdNumericToken(a);
const bNum = getTaskIdNumericToken(b);
if (aNum !== null && bNum !== null && aNum !== bNum) {
return bNum - aNum;
}
return b.localeCompare(a);
}
function getDoneSortTimestamp(task: Task): number { function getDoneSortTimestamp(task: Task): number {
const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt; const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt;
const parsed = Date.parse(timestamp); const parsed = Date.parse(timestamp);
@@ -32,7 +52,11 @@ function isMergeActiveStatus(status: string | null | undefined): boolean {
return status === "merging" || status === "merging-pr" || status === "merging-fix"; return status === "merging" || status === "merging-pr" || status === "merging-fix";
} }
export function sortTasksForDisplayColumn(tasks: readonly Task[], column: Column): Task[] { export function sortTasksForDisplayColumn(
tasks: readonly Task[],
column: Column,
doneSortMode: DoneColumnSortMode = "completion-date-desc",
): Task[] {
if (column === "todo") { if (column === "todo") {
return [...tasks].sort((a, b) => { return [...tasks].sort((a, b) => {
const priorityCmp = compareTaskPriority(a.priority, b.priority); const priorityCmp = compareTaskPriority(a.priority, b.priority);
@@ -44,6 +68,13 @@ export function sortTasksForDisplayColumn(tasks: readonly Task[], column: Column
return [...tasks].sort((a, b) => { return [...tasks].sort((a, b) => {
if (column === "done") { if (column === "done") {
/*
FNXC:DoneColumnSorting 2026-06-29-14:48:
Done keeps completion-date descending as the default for existing board, lane, and list callers while supporting an explicit task-id descending mode for users who need newest FN ids first.
*/
if (doneSortMode === "task-id-desc") {
return compareTaskIdNumericDesc(a.id, b.id);
}
const timestampCmp = getDoneSortTimestamp(b) - getDoneSortTimestamp(a); const timestampCmp = getDoneSortTimestamp(b) - getDoneSortTimestamp(a);
if (timestampCmp !== 0) return timestampCmp; if (timestampCmp !== 0) return timestampCmp;
return compareTaskIdNumeric(a.id, b.id); return compareTaskIdNumeric(a.id, b.id);

View File

@@ -1111,6 +1111,7 @@ body {
.column-header { .column-header {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: var(--space-sm); gap: var(--space-sm);
padding: calc(var(--space-lg) - 2px) calc(var(--space-lg) - 2px) 0; padding: calc(var(--space-lg) - 2px) calc(var(--space-lg) - 2px) 0;
min-width: 0; min-width: 0;
@@ -1173,6 +1174,40 @@ body {
animation: count-flash-bg 1400ms ease-out; animation: count-flash-bg 1400ms ease-out;
} }
/*
FNXC:DoneColumnSorting 2026-06-29-18:11:
The Done sort selector lives in the column header beside Archive All; compact inline-flex styling keeps desktop headers tight while flex wrapping lets narrow/mobile headers move the selector without leaving empty action shells on non-Done columns.
*/
.done-sort-control {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
min-width: 0;
}
.done-sort-control__label {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
}
.done-sort-control__select {
max-width: 150px;
min-height: 28px;
min-width: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-size: 0.75rem;
padding: calc(var(--space-xs) / 2) var(--space-sm);
}
.done-sort-control__select:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.column-desc { .column-desc {
font-size: 0.6875rem; font-size: 0.6875rem;
color: var(--text-dim); color: var(--text-dim);