Keep pinned boards above sorted lists
All checks were successful
Deploy production / deploy (push) Successful in 11m40s

This commit is contained in:
admin i 2026-11-06 14:50:53 +03:00
parent 2d03dfa7cb
commit 4ff1657f3b
3 changed files with 214 additions and 95 deletions

View File

@ -59,20 +59,17 @@ import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu";
import { displayUserName } from "@/lib/shared";
import {
boardGroupPosition,
sortTaskBoards,
taskBoardReorderNeighbours,
type BoardSortValue,
} from "@/lib/task-board-order";
import type { TaskBoardGroup, TaskBoardSummary } from "@/types/api";
type BoardUnreadFilter = "all" | "unread" | "read";
type BoardAccessFilter = "all" | "private" | "shared";
type BoardGroupFilter = "all" | "ungrouped" | number;
type BoardSortValue =
| "priority"
| "priority-desc"
| "name"
| "name-desc"
| "newest"
| "oldest"
| "updated";
const BOARD_PRIORITY_SORT_OPTIONS = [
{ value: "priority", label: "По приоритету ↑", icon: "priority-asc" },
{ value: "priority-desc", label: "По приоритету ↓", icon: "priority-desc" },
@ -97,25 +94,6 @@ function boardGroupIds(board: TaskBoardSummary): number[] {
return board.group_ids || (board.group_id ? [board.group_id] : []);
}
function boardGroupPosition(
board: TaskBoardSummary,
groupId: number,
): number | undefined {
return board.group_positions?.[String(groupId)];
}
function compareBoardNames(
first: TaskBoardSummary,
second: TaskBoardSummary,
): number {
return first.name.localeCompare(second.name, "ru") || first.id - second.id;
}
function boardTimestamp(value: string): number {
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : 0;
}
function sameGroupIds(left: number[], right: number[]): boolean {
const normalizedLeft = [...left].sort((first, second) => first - second);
const normalizedRight = [...right].sort((first, second) => first - second);
@ -562,69 +540,18 @@ export default function TaskBoardNavigator({
return false;
return !normalizedQuery || matchesBoard(board, normalizedQuery);
});
const sortedBoards = matchingBoards.sort((first, second) => {
if (
(effectiveBoardSort === "priority" ||
effectiveBoardSort === "priority-desc") &&
typeof effectiveGroupFilter === "number"
) {
const firstPosition = boardGroupPosition(first, effectiveGroupFilter);
const secondPosition = boardGroupPosition(second, effectiveGroupFilter);
if (
firstPosition !== undefined &&
secondPosition !== undefined &&
firstPosition !== secondPosition
) {
const direction = effectiveBoardSort === "priority-desc" ? -1 : 1;
return (firstPosition - secondPosition) * direction;
}
if (firstPosition !== undefined && secondPosition === undefined)
return -1;
if (firstPosition === undefined && secondPosition !== undefined)
return 1;
}
if (effectiveBoardSort === "name-desc") {
return (
second.name.localeCompare(first.name, "ru") || second.id - first.id
);
}
if (effectiveBoardSort === "newest") {
return (
boardTimestamp(second.created_at) -
boardTimestamp(first.created_at) || compareBoardNames(first, second)
);
}
if (effectiveBoardSort === "oldest") {
return (
boardTimestamp(first.created_at) -
boardTimestamp(second.created_at) ||
compareBoardNames(first, second)
);
}
if (effectiveBoardSort === "updated") {
return (
boardTimestamp(second.updated_at) -
boardTimestamp(first.updated_at) || compareBoardNames(first, second)
);
}
return compareBoardNames(first, second);
});
if (
const optimisticIds =
optimisticBoardOrder &&
typeof effectiveGroupFilter === "number" &&
optimisticBoardOrder.groupId === effectiveGroupFilter
) {
const optimisticIndexes = new Map(
optimisticBoardOrder.boardIds.map((boardId, index) => [boardId, index]),
);
return sortedBoards.sort(
(first, second) =>
(optimisticIndexes.get(first.id) ?? Number.MAX_SAFE_INTEGER) -
(optimisticIndexes.get(second.id) ?? Number.MAX_SAFE_INTEGER),
);
}
return sortedBoards;
? optimisticBoardOrder.boardIds
: undefined;
return sortTaskBoards(
matchingBoards,
effectiveBoardSort,
typeof effectiveGroupFilter === "number" ? effectiveGroupFilter : null,
optimisticIds,
);
}, [
effectiveBoardSort,
effectiveGroupFilter,
@ -712,12 +639,15 @@ export default function TaskBoardNavigator({
const newIndex = visibleBoards.findIndex((item) => item.id === overBoardId);
if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return;
const reorderedBoards = arrayMove(visibleBoards, oldIndex, newIndex);
const movedIndex = reorderedBoards.findIndex(
(item) => item.id === activeBoardId,
const neighbours = taskBoardReorderNeighbours(
visibleBoards,
effectiveGroupFilter,
activeBoardId,
overBoardId,
);
const previousBoardId = reorderedBoards[movedIndex - 1]?.id ?? null;
const nextBoardId = reorderedBoards[movedIndex + 1]?.id ?? null;
if (!neighbours) return;
const reorderedBoards = arrayMove(visibleBoards, oldIndex, newIndex);
setOptimisticBoardOrder({
groupId: effectiveGroupFilter,
boardIds: reorderedBoards.map((item) => item.id),
@ -727,8 +657,8 @@ export default function TaskBoardNavigator({
await onReorderBoard(
effectiveGroupFilter,
activeBoardId,
previousBoardId,
nextBoardId,
neighbours.previousBoardId,
neighbours.nextBoardId,
);
} finally {
setOptimisticBoardOrder(null);

View File

@ -0,0 +1,65 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
sortTaskBoards,
taskBoardReorderNeighbours,
} from "./task-board-order.ts";
const board = (id, name, position, pinned, created, updated = created) => ({
id,
name,
is_pinned: pinned,
group_positions: { 7: position },
created_at: created,
updated_at: updated,
});
const boards = [
board(1, "Альфа", 1, false, "2026-01-01", "2026-01-04"),
board(2, "Бета", 2, true, "2026-01-04", "2026-01-02"),
board(3, "Вега", 3, false, "2026-01-03", "2026-01-01"),
board(4, "Якорь", 4, true, "2026-01-02", "2026-01-03"),
];
const ids = (items) => items.map((item) => item.id);
test("pinned boards precede other boards in every sort order", () => {
const expected = {
priority: [2, 4, 1, 3],
"priority-desc": [4, 2, 3, 1],
name: [2, 4, 1, 3],
"name-desc": [4, 2, 3, 1],
newest: [2, 4, 3, 1],
oldest: [4, 2, 1, 3],
updated: [4, 2, 1, 3],
};
for (const [sort, order] of Object.entries(expected)) {
assert.deepEqual(ids(sortTaskBoards(boards, sort, 7)), order, sort);
}
assert.deepEqual(ids(boards), [1, 2, 3, 4]);
});
test("pinning and unpinning immediately changes only the current ordering", () => {
const initially = sortTaskBoards(boards, "name", 7);
const changed = boards.map((item) =>
item.id === 4 ? { ...item, is_pinned: false } : item,
);
assert.deepEqual(ids(initially), [2, 4, 1, 3]);
assert.deepEqual(ids(sortTaskBoards(changed, "name", 7)), [2, 1, 3, 4]);
assert.deepEqual(
ids(sortTaskBoards(boards, "name", 7, [1, 3, 4, 2])),
[4, 2, 1, 3],
);
});
test("drag neighbours use underlying group priority, including interleaved pins", () => {
const visible = sortTaskBoards(boards, "priority", 7);
assert.deepEqual(taskBoardReorderNeighbours(visible, 7, 4, 2), {
previousBoardId: 1,
nextBoardId: 2,
});
assert.deepEqual(taskBoardReorderNeighbours(visible, 7, 1, 3), {
previousBoardId: 3,
nextBoardId: 4,
});
assert.equal(taskBoardReorderNeighbours(visible, 7, 4, 1), null);
assert.equal(taskBoardReorderNeighbours(visible, 7, 1, 1), null);
});

View File

@ -0,0 +1,124 @@
import type { TaskBoardSummary } from "@/types/api";
export type BoardSortValue =
| "priority"
| "priority-desc"
| "name"
| "name-desc"
| "newest"
| "oldest"
| "updated";
export function boardGroupPosition(board: TaskBoardSummary, groupId: number) {
return board.group_positions?.[String(groupId)];
}
function compareNames(first: TaskBoardSummary, second: TaskBoardSummary) {
return first.name.localeCompare(second.name, "ru") || first.id - second.id;
}
function timestamp(value: string) {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function compareWithoutPin(
first: TaskBoardSummary,
second: TaskBoardSummary,
sort: BoardSortValue,
groupId: number | null,
) {
if ((sort === "priority" || sort === "priority-desc") && groupId !== null) {
const firstPosition = boardGroupPosition(first, groupId);
const secondPosition = boardGroupPosition(second, groupId);
if (
firstPosition !== undefined &&
secondPosition !== undefined &&
firstPosition !== secondPosition
) {
return (
(firstPosition - secondPosition) * (sort === "priority-desc" ? -1 : 1)
);
}
if (firstPosition !== undefined && secondPosition === undefined) return -1;
if (firstPosition === undefined && secondPosition !== undefined) return 1;
}
if (sort === "name-desc")
return second.name.localeCompare(first.name, "ru") || second.id - first.id;
if (sort === "newest")
return (
timestamp(second.created_at) - timestamp(first.created_at) ||
compareNames(first, second)
);
if (sort === "oldest")
return (
timestamp(first.created_at) - timestamp(second.created_at) ||
compareNames(first, second)
);
if (sort === "updated")
return (
timestamp(second.updated_at) - timestamp(first.updated_at) ||
compareNames(first, second)
);
return compareNames(first, second);
}
export function sortTaskBoards(
boards: TaskBoardSummary[],
sort: BoardSortValue,
groupId: number | null,
optimisticIds?: number[],
) {
const optimisticIndexes = optimisticIds
? new Map(optimisticIds.map((id, index) => [id, index]))
: null;
return [...boards].sort((first, second) => {
const pinnedDifference =
Number(Boolean(second.is_pinned)) - Number(Boolean(first.is_pinned));
if (pinnedDifference) return pinnedDifference;
if (optimisticIndexes) {
const firstIndex =
optimisticIndexes.get(first.id) ?? Number.MAX_SAFE_INTEGER;
const secondIndex =
optimisticIndexes.get(second.id) ?? Number.MAX_SAFE_INTEGER;
if (firstIndex !== secondIndex) return firstIndex - secondIndex;
}
return compareWithoutPin(first, second, sort, groupId);
});
}
export function taskBoardReorderNeighbours(
visibleBoards: TaskBoardSummary[],
groupId: number,
activeBoardId: number,
overBoardId: number,
) {
const activeIndex = visibleBoards.findIndex(
(board) => board.id === activeBoardId,
);
const overIndex = visibleBoards.findIndex(
(board) => board.id === overBoardId,
);
if (activeIndex < 0 || overIndex < 0 || activeIndex === overIndex)
return null;
if (
Boolean(visibleBoards[activeIndex].is_pinned) !==
Boolean(visibleBoards[overIndex].is_pinned)
)
return null;
const priorityOrder = visibleBoards
.filter((board) => board.id !== activeBoardId)
.sort((first, second) =>
compareWithoutPin(first, second, "priority", groupId),
);
const targetIndex = priorityOrder.findIndex(
(board) => board.id === overBoardId,
);
if (targetIndex < 0) return null;
const insertIndex = targetIndex + (activeIndex < overIndex ? 1 : 0);
return {
previousBoardId: priorityOrder[insertIndex - 1]?.id ?? null,
nextBoardId: priorityOrder[insertIndex]?.id ?? null,
};
}