feat(FN-3596): unify board/list task ordering, split TaskDetailModal test m
The merge introduces a major TaskDetailModal test refactor (splitting a 6.7K-line monolith into five focused suites), significant merger improvements including autostash race-rescue, deduplication, and advisory logging for destructive operations, a new TUI narrow-mode log-split feature for the dashb Fusion-Task-Id: FN-3596
This commit is contained in:
@@ -95,6 +95,7 @@ Board ordering behavior:
|
|||||||
- `todo` mirrors scheduler dispatch order: priority first (`urgent` → `low`), then oldest `createdAt` within a priority tier, then task ID as deterministic tie-break.
|
- `todo` mirrors scheduler dispatch order: priority first (`urgent` → `low`), then oldest `createdAt` within a priority tier, then task ID as deterministic tie-break.
|
||||||
- `triage`, `in-progress`, and `in-review` remain priority-first with task-ID tie-breaks (`in-review` still pins merge-active statuses above non-merging tasks).
|
- `triage`, `in-progress`, and `in-review` remain priority-first with task-ID tie-breaks (`in-review` still pins merge-active statuses above non-merging tasks).
|
||||||
- The `done` column is recency-ordered by completion time (newest first), using `columnMovedAt` as primary and falling back to `updatedAt` then `createdAt` for legacy tasks.
|
- The `done` column is recency-ordered by completion time (newest first), using `columnMovedAt` as primary and falling back to `updatedAt` then `createdAt` for legacy tasks.
|
||||||
|
- The dashboard **list view default ordering matches these same per-column semantics** until a user clicks a sortable header (manual list sorting still overrides defaults).
|
||||||
|
|
||||||
### Lifecycle commands
|
### Lifecycle commands
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
isTaskPriority,
|
isTaskPriority,
|
||||||
normalizeTaskPriority,
|
normalizeTaskPriority,
|
||||||
sortTasksByPriorityThenAgeAndId,
|
sortTasksByPriorityThenAgeAndId,
|
||||||
|
compareTaskIdNumeric,
|
||||||
|
sortTasksForDisplayColumn,
|
||||||
} from "../task-priority.js";
|
} from "../task-priority.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_TASK_PRIORITY,
|
DEFAULT_TASK_PRIORITY,
|
||||||
@@ -55,9 +57,42 @@ describe("task-priority", () => {
|
|||||||
expect(compareTasksByPriorityThenAgeAndId(tasks[0], tasks[1])).toBeGreaterThan(0);
|
expect(compareTasksByPriorityThenAgeAndId(tasks[0], tasks[1])).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("compares numeric IDs with locale fallback", () => {
|
||||||
|
expect(compareTaskIdNumeric("FN-2", "FN-10")).toBeLessThan(0);
|
||||||
|
expect(compareTaskIdNumeric("TASK-B", "TASK-A")).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies board/list default ordering semantics by column", () => {
|
||||||
|
const base = {
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
columnMovedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const todoSorted = sortTasksForDisplayColumn([
|
||||||
|
{ ...base, id: "FN-003", column: "todo", priority: "low" as TaskPriority, createdAt: "2026-01-01T00:00:00.000Z" },
|
||||||
|
{ ...base, id: "FN-001", column: "todo", priority: "urgent" as TaskPriority, createdAt: "2026-01-02T00:00:00.000Z" },
|
||||||
|
{ ...base, id: "FN-002", column: "todo", priority: "high" as TaskPriority, createdAt: "2026-01-01T12:00:00.000Z" },
|
||||||
|
], "todo");
|
||||||
|
expect(todoSorted.map((task) => task.id)).toEqual(["FN-001", "FN-002", "FN-003"]);
|
||||||
|
|
||||||
|
const inReviewSorted = sortTasksForDisplayColumn([
|
||||||
|
{ ...base, id: "FN-010", column: "in-review", status: "review-ready", priority: "urgent" as TaskPriority },
|
||||||
|
{ ...base, id: "FN-011", column: "in-review", status: "merging-fix", priority: "high" as TaskPriority },
|
||||||
|
], "in-review");
|
||||||
|
expect(inReviewSorted.map((task) => task.id)).toEqual(["FN-011", "FN-010"]);
|
||||||
|
|
||||||
|
const doneSorted = sortTasksForDisplayColumn([
|
||||||
|
{ ...base, id: "FN-020", column: "done", priority: "urgent" as TaskPriority, columnMovedAt: "2026-01-01T08:00:00.000Z" },
|
||||||
|
{ ...base, id: "FN-021", column: "done", priority: "low" as TaskPriority, columnMovedAt: "2026-01-01T09:00:00.000Z" },
|
||||||
|
], "done");
|
||||||
|
expect(doneSorted.map((task) => task.id)).toEqual(["FN-021", "FN-020"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("re-exports priority helpers from the core index", () => {
|
it("re-exports priority helpers from the core index", () => {
|
||||||
expect(core.TASK_PRIORITIES).toEqual(TASK_PRIORITIES);
|
expect(core.TASK_PRIORITIES).toEqual(TASK_PRIORITIES);
|
||||||
expect(core.DEFAULT_TASK_PRIORITY).toBe("normal");
|
expect(core.DEFAULT_TASK_PRIORITY).toBe("normal");
|
||||||
expect(core.normalizeTaskPriority("bogus")).toBe(DEFAULT_TASK_PRIORITY);
|
expect(core.normalizeTaskPriority("bogus")).toBe(DEFAULT_TASK_PRIORITY);
|
||||||
|
expect(typeof core.sortTasksForDisplayColumn).toBe("function");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -309,8 +309,10 @@ export {
|
|||||||
compareTaskPriority,
|
compareTaskPriority,
|
||||||
compareTasksByPriorityThenAgeAndId,
|
compareTasksByPriorityThenAgeAndId,
|
||||||
sortTasksByPriorityThenAgeAndId,
|
sortTasksByPriorityThenAgeAndId,
|
||||||
|
compareTaskIdNumeric,
|
||||||
|
sortTasksForDisplayColumn,
|
||||||
} from "./task-priority.js";
|
} from "./task-priority.js";
|
||||||
export type { TaskPrioritySortable } from "./task-priority.js";
|
export type { TaskPrioritySortable, TaskColumnSortable } from "./task-priority.js";
|
||||||
export {
|
export {
|
||||||
mapFeatureToTaskHandoff,
|
mapFeatureToTaskHandoff,
|
||||||
mapRoadmapToMissionHandoff,
|
mapRoadmapToMissionHandoff,
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ export interface TaskPrioritySortable {
|
|||||||
priority?: TaskPriority | null;
|
priority?: TaskPriority | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskColumnSortable extends TaskPrioritySortable {
|
||||||
|
column: string;
|
||||||
|
status?: string | null;
|
||||||
|
columnMovedAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const PRIORITY_RANK: Record<TaskPriority, number> = {
|
const PRIORITY_RANK: Record<TaskPriority, number> = {
|
||||||
low: 0,
|
low: 0,
|
||||||
normal: 1,
|
normal: 1,
|
||||||
@@ -40,7 +47,7 @@ export function compareTaskPriority(a: unknown, b: unknown): number {
|
|||||||
return getTaskPriorityRank(b) - getTaskPriorityRank(a);
|
return getTaskPriorityRank(b) - getTaskPriorityRank(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareTaskId(a: string, b: string): number {
|
export function compareTaskIdNumeric(a: string, b: string): number {
|
||||||
const aNum = Number.parseInt(a.slice(a.lastIndexOf("-") + 1), 10);
|
const aNum = Number.parseInt(a.slice(a.lastIndexOf("-") + 1), 10);
|
||||||
const bNum = Number.parseInt(b.slice(b.lastIndexOf("-") + 1), 10);
|
const bNum = Number.parseInt(b.slice(b.lastIndexOf("-") + 1), 10);
|
||||||
|
|
||||||
@@ -65,7 +72,7 @@ export function compareTasksByPriorityThenAgeAndId<T extends TaskPrioritySortabl
|
|||||||
return a.createdAt.localeCompare(b.createdAt);
|
return a.createdAt.localeCompare(b.createdAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
return compareTaskId(a.id, b.id);
|
return compareTaskIdNumeric(a.id, b.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,3 +83,47 @@ export function sortTasksByPriorityThenAgeAndId<T extends TaskPrioritySortable>(
|
|||||||
): T[] {
|
): T[] {
|
||||||
return [...tasks].sort(compareTasksByPriorityThenAgeAndId);
|
return [...tasks].sort(compareTasksByPriorityThenAgeAndId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDoneSortTimestamp(task: TaskColumnSortable): number {
|
||||||
|
const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt;
|
||||||
|
const parsed = Date.parse(timestamp);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMergeActiveStatus(status: string | null | undefined): boolean {
|
||||||
|
return status === "merging" || status === "merging-pr" || status === "merging-fix";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column-aware default ordering shared by board and list surfaces.
|
||||||
|
*/
|
||||||
|
export function sortTasksForDisplayColumn<T extends TaskColumnSortable>(tasks: readonly T[], column: string): T[] {
|
||||||
|
if (column === "todo") {
|
||||||
|
return sortTasksByPriorityThenAgeAndId(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...tasks].sort((a, b) => {
|
||||||
|
if (column === "done") {
|
||||||
|
const timestampCmp = getDoneSortTimestamp(b) - getDoneSortTimestamp(a);
|
||||||
|
if (timestampCmp !== 0) {
|
||||||
|
return timestampCmp;
|
||||||
|
}
|
||||||
|
return compareTaskIdNumeric(a.id, b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === "in-review") {
|
||||||
|
const aIsMerging = isMergeActiveStatus(a.status);
|
||||||
|
const bIsMerging = isMergeActiveStatus(b.status);
|
||||||
|
if (aIsMerging !== bIsMerging) {
|
||||||
|
return aIsMerging ? -1 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityCmp = compareTaskPriority(a.priority, b.priority);
|
||||||
|
if (priorityCmp !== 0) {
|
||||||
|
return priorityCmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareTaskIdNumeric(a.id, b.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, TaskPriority } from "@fusion/core";
|
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput } from "@fusion/core";
|
||||||
import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core";
|
import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core";
|
||||||
|
import { sortTasksForDisplayColumn } from "./taskSorting";
|
||||||
import { Column } from "./Column";
|
import { Column } from "./Column";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||||
@@ -53,92 +54,6 @@ interface BoardProps {
|
|||||||
lastFetchTimeMs?: number;
|
lastFetchTimeMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITY_RANK: Record<TaskPriority, number> = {
|
|
||||||
low: 0,
|
|
||||||
normal: 1,
|
|
||||||
high: 2,
|
|
||||||
urgent: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getTaskPriorityRank(priority: Task["priority"] | null | undefined): number {
|
|
||||||
if (!priority || !(priority in PRIORITY_RANK)) {
|
|
||||||
return PRIORITY_RANK.normal;
|
|
||||||
}
|
|
||||||
return PRIORITY_RANK[priority];
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareTaskPriority(a: Task["priority"] | null | undefined, b: Task["priority"] | null | undefined): number {
|
|
||||||
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);
|
|
||||||
|
|
||||||
if (Number.isFinite(aNum) && Number.isFinite(bNum) && aNum !== bNum) {
|
|
||||||
return aNum - bNum;
|
|
||||||
}
|
|
||||||
|
|
||||||
return a.localeCompare(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortTasksByPriorityThenAgeAndId(tasks: Task[]): Task[] {
|
|
||||||
return [...tasks].sort((a, b) => {
|
|
||||||
const priorityCmp = compareTaskPriority(a.priority, b.priority);
|
|
||||||
if (priorityCmp !== 0) {
|
|
||||||
return priorityCmp;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (a.createdAt !== b.createdAt) {
|
|
||||||
return a.createdAt.localeCompare(b.createdAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
return compareTaskIdNumeric(a.id, b.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDoneSortTimestamp(task: Task): number {
|
|
||||||
const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt;
|
|
||||||
const parsed = Date.parse(timestamp);
|
|
||||||
return Number.isFinite(parsed) ? parsed : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortTasksForColumn(tasks: Task[], column: ColumnType): Task[] {
|
|
||||||
if (column === "todo") {
|
|
||||||
// Match scheduler pickup order: priority DESC, createdAt ASC, id ASC.
|
|
||||||
return sortTasksByPriorityThenAgeAndId(tasks);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...tasks].sort((a, b) => {
|
|
||||||
if (column === "done") {
|
|
||||||
const timestampCmp = getDoneSortTimestamp(b) - getDoneSortTimestamp(a);
|
|
||||||
if (timestampCmp !== 0) {
|
|
||||||
return timestampCmp;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deterministic tie-breaker when completion timestamps match.
|
|
||||||
return compareTaskIdNumeric(a.id, b.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In the in-review column, merging tasks stay pinned above non-merging tasks.
|
|
||||||
if (column === "in-review") {
|
|
||||||
const aIsMerging = a.status === "merging" || a.status === "merging-pr" || a.status === "merging-fix";
|
|
||||||
const bIsMerging = b.status === "merging" || b.status === "merging-pr" || b.status === "merging-fix";
|
|
||||||
if (aIsMerging !== bIsMerging) {
|
|
||||||
return aIsMerging ? -1 : 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Primary sort for non-done/non-todo columns: priority descending.
|
|
||||||
const priorityCmp = compareTaskPriority(a.priority, b.priority);
|
|
||||||
if (priorityCmp !== 0) {
|
|
||||||
return priorityCmp;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Secondary sort: numeric task ID ascending (lower number first).
|
|
||||||
return compareTaskIdNumeric(a.id, b.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
|
function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
|
||||||
if (previous.length !== next.length) return false;
|
if (previous.length !== next.length) return false;
|
||||||
@@ -207,7 +122,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
|||||||
const stableGrouped = {} as Record<ColumnType, Task[]>;
|
const stableGrouped = {} as Record<ColumnType, Task[]>;
|
||||||
|
|
||||||
for (const column of COLUMNS) {
|
for (const column of COLUMNS) {
|
||||||
const sortedTasks = sortTasksForColumn(nextGrouped[column], column);
|
const sortedTasks = sortTasksForDisplayColumn(nextGrouped[column], column);
|
||||||
stableGrouped[column] = areTaskArraysEqual(previousGrouped[column], sortedTasks)
|
stableGrouped[column] = areTaskArraysEqual(previousGrouped[column], sortedTasks)
|
||||||
? previousGrouped[column]
|
? previousGrouped[column]
|
||||||
: sortedTasks;
|
: sortedTasks;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "rea
|
|||||||
import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap } from "lucide-react";
|
import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap } from "lucide-react";
|
||||||
import type { Task, TaskDetail, Column, TaskCreateInput, MergeResult } from "@fusion/core";
|
import type { Task, TaskDetail, Column, TaskCreateInput, MergeResult } from "@fusion/core";
|
||||||
import { COLUMN_LABELS, COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core";
|
import { COLUMN_LABELS, COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core";
|
||||||
|
import { sortTasksForDisplayColumn } from "./taskSorting";
|
||||||
import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail } from "../api";
|
import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail } from "../api";
|
||||||
import { TaskDetailContent } from "./TaskDetailModal";
|
import { TaskDetailContent } from "./TaskDetailModal";
|
||||||
import type { ModelInfo, NodeInfo } from "../api";
|
import type { ModelInfo, NodeInfo } from "../api";
|
||||||
@@ -27,7 +28,7 @@ const COLUMN_COLOR_MAP: Record<Column, string> = {
|
|||||||
|
|
||||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
|
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
|
||||||
|
|
||||||
type SortField = "id" | "title" | "status" | "column";
|
type SortField = "title" | "status" | "column";
|
||||||
|
|
||||||
function getTaskStatusLabel(status: string): string {
|
function getTaskStatusLabel(status: string): string {
|
||||||
if (status === "merging-fix") return "Merging fixes…";
|
if (status === "merging-fix") return "Merging fixes…";
|
||||||
@@ -250,8 +251,8 @@ export function ListView({
|
|||||||
lastFetchTimeMs,
|
lastFetchTimeMs,
|
||||||
prAuthAvailable,
|
prAuthAvailable,
|
||||||
}: ListViewProps) {
|
}: ListViewProps) {
|
||||||
const [sortField, setSortField] = useState<SortField>("id");
|
const [sortField, setSortField] = useState<SortField | null>(null);
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
||||||
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
||||||
const [dragOverColumn, setDragOverColumn] = useState<Column | null>(null);
|
const [dragOverColumn, setDragOverColumn] = useState<Column | null>(null);
|
||||||
const [selectedColumn, setSelectedColumn] = useState<Column | null>(null);
|
const [selectedColumn, setSelectedColumn] = useState<Column | null>(null);
|
||||||
@@ -454,10 +455,11 @@ export function ListView({
|
|||||||
const handleSort = useCallback((field: SortField) => {
|
const handleSort = useCallback((field: SortField) => {
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
|
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||||
} else {
|
return;
|
||||||
setSortField(field);
|
|
||||||
setSortDirection("asc");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection("asc");
|
||||||
}, [sortField]);
|
}, [sortField]);
|
||||||
|
|
||||||
const handleColumnFilter = useCallback((column: Column) => {
|
const handleColumnFilter = useCallback((column: Column) => {
|
||||||
@@ -502,38 +504,43 @@ export function ListView({
|
|||||||
? filtered.filter((t) => t.column === selectedColumn)
|
? filtered.filter((t) => t.column === selectedColumn)
|
||||||
: filtered;
|
: filtered;
|
||||||
|
|
||||||
const sorted = [...columnFiltered].sort((a, b) => {
|
|
||||||
let comparison = 0;
|
|
||||||
switch (sortField) {
|
|
||||||
case "id":
|
|
||||||
comparison = a.id.localeCompare(b.id);
|
|
||||||
break;
|
|
||||||
case "title":
|
|
||||||
comparison = (a.title || a.description).localeCompare(b.title || b.description);
|
|
||||||
break;
|
|
||||||
case "status":
|
|
||||||
comparison = (a.status || "").localeCompare(b.status || "");
|
|
||||||
break;
|
|
||||||
case "column":
|
|
||||||
comparison = a.column.localeCompare(b.column);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return sortDirection === "asc" ? comparison : -comparison;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Group by column while preserving sort order within each group
|
|
||||||
const groups: Record<Column, Task[]> = {
|
const groups: Record<Column, Task[]> = {
|
||||||
triage: [],
|
triage: [],
|
||||||
todo: [],
|
todo: [],
|
||||||
"in-progress": [],
|
"in-progress": [],
|
||||||
"in-review": [],
|
"in-review": [],
|
||||||
done: [],
|
done: [],
|
||||||
archived: []
|
archived: [],
|
||||||
};
|
};
|
||||||
sorted.forEach((task) => {
|
|
||||||
|
columnFiltered.forEach((task) => {
|
||||||
const column = isColumn(task.column) ? task.column : DEFAULT_COLUMN;
|
const column = isColumn(task.column) ? task.column : DEFAULT_COLUMN;
|
||||||
groups[column].push(task);
|
groups[column].push(task);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const column of COLUMNS) {
|
||||||
|
if (!sortField) {
|
||||||
|
groups[column] = sortTasksForDisplayColumn(groups[column], column);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
groups[column] = [...groups[column]].sort((a, b) => {
|
||||||
|
let comparison = 0;
|
||||||
|
switch (sortField) {
|
||||||
|
case "title":
|
||||||
|
comparison = (a.title || a.description).localeCompare(b.title || b.description);
|
||||||
|
break;
|
||||||
|
case "status":
|
||||||
|
comparison = (a.status || "").localeCompare(b.status || "");
|
||||||
|
break;
|
||||||
|
case "column":
|
||||||
|
comparison = a.column.localeCompare(b.column);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return sortDirection === "asc" ? comparison : -comparison;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
}, [tasks, searchQuery, sortField, sortDirection, hideDoneTasks, selectedColumn]);
|
}, [tasks, searchQuery, sortField, sortDirection, hideDoneTasks, selectedColumn]);
|
||||||
|
|
||||||
@@ -933,7 +940,7 @@ export function ListView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getSortIcon = (field: SortField) => {
|
const getSortIcon = (field: SortField) => {
|
||||||
if (sortField !== field) return <ArrowUpDown size={14} className="sort-icon" />;
|
if (!sortField || sortField !== field) return <ArrowUpDown size={14} className="sort-icon" />;
|
||||||
return sortDirection === "asc" ? (
|
return sortDirection === "asc" ? (
|
||||||
<ArrowUp size={14} className="sort-icon active" />
|
<ArrowUp size={14} className="sort-icon active" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ describe("Board", () => {
|
|||||||
expect(columnRenderCounts.done).toBeGreaterThanOrEqual(initialDoneRenders);
|
expect(columnRenderCounts.done).toBeGreaterThanOrEqual(initialDoneRenders);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sortTasksForColumn priority ordering", () => {
|
describe("column default ordering priority semantics", () => {
|
||||||
it("orders done tasks by most recent completion regardless of priority", () => {
|
it("orders done tasks by most recent completion regardless of priority", () => {
|
||||||
const tasks: Task[] = [
|
const tasks: Task[] = [
|
||||||
createTask({
|
createTask({
|
||||||
@@ -491,7 +491,7 @@ describe("Board", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sortTasksForColumn merging pinning", () => {
|
describe("column default ordering merging pinning", () => {
|
||||||
it("pins merging tasks to top of in-review even when newer non-merging tasks exist", () => {
|
it("pins merging tasks to top of in-review even when newer non-merging tasks exist", () => {
|
||||||
const tasks: Task[] = [
|
const tasks: Task[] = [
|
||||||
createTask({
|
createTask({
|
||||||
|
|||||||
@@ -90,6 +90,24 @@ const showAllColumnsByDefault = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSectionTaskIds = (sectionName: string): string[] => {
|
||||||
|
const allRows = screen.getAllByRole("row");
|
||||||
|
const sectionStart = allRows.findIndex(
|
||||||
|
(row) => row.className.includes("list-section-header") && row.textContent?.includes(sectionName),
|
||||||
|
);
|
||||||
|
if (sectionStart < 0) return [];
|
||||||
|
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let index = sectionStart + 1; index < allRows.length; index += 1) {
|
||||||
|
const row = allRows[index];
|
||||||
|
if (row.className.includes("list-section-header")) break;
|
||||||
|
const id = row.getAttribute("data-id");
|
||||||
|
if (id) ids.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
|
||||||
function ensureMatchMedia() {
|
function ensureMatchMedia() {
|
||||||
if (!window.matchMedia) {
|
if (!window.matchMedia) {
|
||||||
Object.defineProperty(window, "matchMedia", {
|
Object.defineProperty(window, "matchMedia", {
|
||||||
@@ -1186,6 +1204,40 @@ describe("ListView", () => {
|
|||||||
expect(screen.queryByText("FN-002")).toBeNull();
|
expect(screen.queryByText("FN-002")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults todo section to board-consistent priority then oldest ordering", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "FN-100", column: "todo", priority: "low", createdAt: "2024-01-01T08:00:00.000Z" }),
|
||||||
|
createMockTask({ id: "FN-101", column: "todo", priority: "urgent", createdAt: "2024-01-01T10:00:00.000Z" }),
|
||||||
|
createMockTask({ id: "FN-102", column: "todo", priority: "high", createdAt: "2024-01-01T07:00:00.000Z" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
expect(getSectionTaskIds("Todo")).toEqual(["FN-101", "FN-102", "FN-100"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults done section to board-consistent completion recency", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "FN-200", column: "done", priority: "urgent", columnMovedAt: "2024-01-01T08:00:00.000Z" }),
|
||||||
|
createMockTask({ id: "FN-201", column: "done", priority: "low", columnMovedAt: "2024-01-01T10:00:00.000Z" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
expect(getSectionTaskIds("Done")).toEqual(["FN-201", "FN-200"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults in-review section to board-consistent merge-active pinning", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "FN-300", column: "in-review", status: "review-ready", priority: "urgent" }),
|
||||||
|
createMockTask({ id: "FN-301", column: "in-review", status: "merging-fix", priority: "normal" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
expect(getSectionTaskIds("In Review")).toEqual(["FN-301", "FN-300"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("maintains sort order within each section", () => {
|
it("maintains sort order within each section", () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
createMockTask({ id: "FN-003", title: "Charlie", column: "triage" }),
|
createMockTask({ id: "FN-003", title: "Charlie", column: "triage" }),
|
||||||
@@ -1195,20 +1247,10 @@ describe("ListView", () => {
|
|||||||
|
|
||||||
renderListView({ tasks });
|
renderListView({ tasks });
|
||||||
|
|
||||||
// Sort by title
|
|
||||||
const titleHeader = screen.getByRole("columnheader", { name: /title/i });
|
const titleHeader = screen.getByRole("columnheader", { name: /title/i });
|
||||||
fireEvent.click(titleHeader);
|
fireEvent.click(titleHeader);
|
||||||
|
|
||||||
// Get only data rows within the triage section
|
expect(getSectionTaskIds("Planning")).toEqual(["FN-001", "FN-002", "FN-003"]);
|
||||||
const allRows = screen.getAllByRole("row");
|
|
||||||
const triageSectionStart = allRows.findIndex(r => r.className.includes("list-section-header") && r.textContent?.includes("Planning"));
|
|
||||||
|
|
||||||
// The next 3 rows after the section header should be the sorted tasks
|
|
||||||
const dataRows = allRows.slice(triageSectionStart + 1, triageSectionStart + 4).filter(r => r.getAttribute("data-id"));
|
|
||||||
|
|
||||||
expect(dataRows[0].textContent).toContain("FN-001"); // Alpha
|
|
||||||
expect(dataRows[1].textContent).toContain("FN-002"); // Bravo
|
|
||||||
expect(dataRows[2].textContent).toContain("FN-003"); // Charlie
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
62
packages/dashboard/app/components/taskSorting.ts
Normal file
62
packages/dashboard/app/components/taskSorting.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Task, Column } from "@fusion/core";
|
||||||
|
|
||||||
|
function getTaskPriorityRank(priority: Task["priority"] | null | undefined): number {
|
||||||
|
if (priority === "urgent") return 3;
|
||||||
|
if (priority === "high") return 2;
|
||||||
|
if (priority === "low") return 0;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareTaskPriority(a: Task["priority"] | null | undefined, b: Task["priority"] | null | undefined): number {
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (Number.isFinite(aNum) && Number.isFinite(bNum) && aNum !== bNum) {
|
||||||
|
return aNum - bNum;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.localeCompare(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDoneSortTimestamp(task: Task): number {
|
||||||
|
const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt;
|
||||||
|
const parsed = Date.parse(timestamp);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[] {
|
||||||
|
if (column === "todo") {
|
||||||
|
return [...tasks].sort((a, b) => {
|
||||||
|
const priorityCmp = compareTaskPriority(a.priority, b.priority);
|
||||||
|
if (priorityCmp !== 0) return priorityCmp;
|
||||||
|
if (a.createdAt !== b.createdAt) return a.createdAt.localeCompare(b.createdAt);
|
||||||
|
return compareTaskIdNumeric(a.id, b.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...tasks].sort((a, b) => {
|
||||||
|
if (column === "done") {
|
||||||
|
const timestampCmp = getDoneSortTimestamp(b) - getDoneSortTimestamp(a);
|
||||||
|
if (timestampCmp !== 0) return timestampCmp;
|
||||||
|
return compareTaskIdNumeric(a.id, b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === "in-review") {
|
||||||
|
const aIsMerging = isMergeActiveStatus(a.status);
|
||||||
|
const bIsMerging = isMergeActiveStatus(b.status);
|
||||||
|
if (aIsMerging !== bIsMerging) return aIsMerging ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityCmp = compareTaskPriority(a.priority, b.priority);
|
||||||
|
if (priorityCmp !== 0) return priorityCmp;
|
||||||
|
return compareTaskIdNumeric(a.id, b.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user