diff --git a/.changeset/fn-7238-done-column-sort.md b/.changeset/fn-7238-done-column-sort.md new file mode 100644 index 0000000000..448d4cf65a --- /dev/null +++ b/.changeset/fn-7238-done-column-sort.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 7a54012139..9549330e3a 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -133,7 +133,10 @@ Features: - 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 - 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) + + +- 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. - 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`. diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 5d4551b2b5..69d1f48e02 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -1,6 +1,6 @@ import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } 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 "./Lane.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) { 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("completion-date-desc"); const archivedLoadedRef = useRef(false); const boardRef = useRef(null); const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState(() => { @@ -223,7 +228,9 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o const stableGrouped = {} as Record; 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) ? previousGrouped[column] : sortedTasks; @@ -231,7 +238,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o tasksByColumnCacheRef.current = stableGrouped; return stableGrouped; - }, [tasks]); + }, [tasks, doneSortMode]); // FN-4574 + FN-001 diagnosis: on iOS Safari, the mobile board can occasionally // 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); } 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; - }, [selectedWorkflow, selectedWorkflowTasks]); + }, [doneSortMode, selectedWorkflow, selectedWorkflowTasks]); // 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. @@ -535,6 +549,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o > {selectedWorkflowColumns.map((columnDef) => { const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId; + const isWorkflowDoneLikeColumn = columnDef.flags.complete === true && columnDef.flags.archived !== true; return ( ); })} @@ -671,7 +687,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o autoMerge={autoMerge} {...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} {...(col === "in-review" ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} - {...(col === "done" ? { onArchiveAllDone } : {})} + {...(col === "done" ? { onArchiveAllDone, doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})} {...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})} /> ))} diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 01124e1a5a..3da48a01d8 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -14,6 +14,7 @@ import type { ToastType } from "../hooks/useToast"; import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react"; import type { ModelInfo, BoardWorkflowColumnFlags } from "../api"; import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout"; +import type { DoneColumnSortMode } from "./taskSorting"; const PAGINATED_COLUMN_THRESHOLD = 100; const VISIBLE_TASKS_INITIAL = 50; @@ -115,6 +116,10 @@ interface ColumnProps { githubIssueAction?: GithubIssueAction; }) => Promise; onArchiveAllDone?: () => Promise; + /** 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; onToggleCollapse?: () => void; allTasks?: Task[]; @@ -172,7 +177,7 @@ interface ColumnProps { 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"); // Anchor the board.rejection.* catalog keys for the i18next extractor (it // 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]); + /* + 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 () => { if (!onArchiveAllDone) return; if (tasks.length === 0) return; @@ -582,6 +595,24 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree + {t("column.newTask", "New Task")} )} + {showDoneSortControl && ( + + )} {column === "done" && onArchiveAllDone && ( } + {onDoneSortModeChange && } ); }), @@ -497,6 +530,40 @@ describe("Board", () => { 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", () => { const tasks: Task[] = [ createTask({ @@ -1322,6 +1389,68 @@ describe("Board", () => { 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 () => { const onArchiveAllDone = vi.fn(); enableFlag({ "FN-1": "builtin:coding" }); diff --git a/packages/dashboard/app/components/__tests__/Column.test.tsx b/packages/dashboard/app/components/__tests__/Column.test.tsx index 3a153a7658..b2d9482b0c 100644 --- a/packages/dashboard/app/components/__tests__/Column.test.tsx +++ b/packages/dashboard/app/components/__tests__/Column.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.queryByRole("combobox", { name: "Sort Done tasks" })).toBeNull(); + expect(container.querySelector(".done-sort-control")).toBeNull(); + }); +}); + describe("Column same-column drop", () => { it("does not call onMoveTask when dropping task into its current column", () => { const onMoveTask = vi.fn().mockResolvedValue({} as Task); diff --git a/packages/dashboard/app/components/__tests__/taskSorting.test.ts b/packages/dashboard/app/components/__tests__/taskSorting.test.ts new file mode 100644 index 0000000000..12976b55fe --- /dev/null +++ b/packages/dashboard/app/components/__tests__/taskSorting.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import type { Task } from "@fusion/core"; +import { sortTasksForDisplayColumn } from "../taskSorting"; + +function task(overrides: Partial & { 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", + ]); + }); +}); diff --git a/packages/dashboard/app/components/taskSorting.ts b/packages/dashboard/app/components/taskSorting.ts index 33e3ff08e6..b8ce1d804a 100644 --- a/packages/dashboard/app/components/taskSorting.ts +++ b/packages/dashboard/app/components/taskSorting.ts @@ -1,5 +1,7 @@ import type { Task, Column } from "@fusion/core"; +export type DoneColumnSortMode = "completion-date-desc" | "task-id-desc"; + function getTaskPriorityRank(priority: Task["priority"] | null | undefined): number { if (priority === "urgent") return 3; if (priority === "high") return 2; @@ -11,17 +13,35 @@ function compareTaskPriority(a: Task["priority"] | null | undefined, b: Task["pr return getTaskPriorityRank(b) - getTaskPriorityRank(a); } -function compareTaskIdNumeric(a: string, b: string): number { - const aNum = Number.parseInt(a.slice(a.lastIndexOf("-") + 1), 10); - const bNum = Number.parseInt(b.slice(b.lastIndexOf("-") + 1), 10); +function getTaskIdNumericToken(id: string): number | null { + const token = id.slice(id.lastIndexOf("-") + 1); + 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 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 { const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt; 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"; } -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") { return [...tasks].sort((a, b) => { const priorityCmp = compareTaskPriority(a.priority, b.priority); @@ -44,6 +68,13 @@ export function sortTasksForDisplayColumn(tasks: readonly Task[], column: Column return [...tasks].sort((a, b) => { 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); if (timestampCmp !== 0) return timestampCmp; return compareTaskIdNumeric(a.id, b.id); diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index bba4c8ad69..cf3fa68c4f 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -1111,6 +1111,7 @@ body { .column-header { display: flex; align-items: center; + flex-wrap: wrap; gap: var(--space-sm); padding: calc(var(--space-lg) - 2px) calc(var(--space-lg) - 2px) 0; min-width: 0; @@ -1173,6 +1174,40 @@ body { 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 { font-size: 0.6875rem; color: var(--text-dim);