diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index de52eafa..f2acd7e3 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -9,6 +9,7 @@ import { closestCorners, pointerWithin, useDraggable, + useDroppable, useSensor, useSensors, type CollisionDetection, @@ -83,10 +84,11 @@ import { CommentComposer, CommentDeleteButton } from "@/components/shared/Commen import { Modal } from "@/components/ui"; import { useNotifications } from "@/contexts/NotificationsContext"; import { useUser } from "@/contexts/UserContext"; +import { useTaskBoardRealtime } from "@/hooks/useTaskBoardRealtime"; import { apiClient } from "@/lib/api"; import { displayUserName, formatDate, formatDateTime, formatMoney } from "@/lib/shared"; +import { buildTaskBoardLayout, taskPlacementKey } from "@/lib/tasks/boardLayout"; import { resolveMediaUrl } from "@/lib/url"; -import wsManager from "@/lib/websocketManager"; import type { CalendarEvent, Department, @@ -197,17 +199,6 @@ type QuickTaskTarget = { rowId: number | null; }; -type TaskBoardSocketEvent = { - type: string; - data?: { - board_id?: number; - event?: string; - model?: string; - object_id?: number | null; - task_id?: number; - }; -}; - const DESKTOP_WIDE_MODE_STORAGE_PREFIX = "tasks.desktop-wide-mode"; const TASK_SUBCOLUMN_WIDTH = "18rem"; const EMPTY_BOARD_VIEW_STATE: TaskBoardViewState = { @@ -251,10 +242,6 @@ function updateBoardViewStateIds( else values.delete(objectId); return { ...state, [key]: Array.from(values) }; } -const taskPlacementKey = (columnId: number, rowId: number | null) => ( - `${columnId}:${rowId ?? "base"}` -); - const emptyForm: TaskFormState = { id: null, title: "", @@ -855,6 +842,67 @@ function QuickTaskComposer({ ); } +type TaskColumnCardsProps = { + tasks: TaskCard[]; + onOpenTask: (task: TaskCard) => void; + onEditTask: (task: TaskCard) => void; + onDeleteTask: (task: TaskCard) => void; + currentUserId?: number; + claimingTaskId: number | null; + onClaimTask: (task: TaskCard) => void; + completingTaskId: number | null; + onCompleteTask: (task: TaskCard) => void; + openMenuTaskId: number | null; + menuRef: RefObject; + onToggleTaskMenu: (taskId: number) => void; + quickTaskComposer?: ReactNode; +}; + +function TaskColumnCards({ + tasks, + onOpenTask, + onEditTask, + onDeleteTask, + currentUserId, + claimingTaskId, + onClaimTask, + completingTaskId, + onCompleteTask, + openMenuTaskId, + menuRef, + onToggleTaskMenu, + quickTaskComposer, +}: TaskColumnCardsProps) { + return ( + <> + {quickTaskComposer} + {tasks.length > 0 ? ( + tasks.map((task) => ( + onToggleTaskMenu(task.id)} + /> + )) + ) : quickTaskComposer ? null : ( +
+

Нет задач

+
+ )} + + ); +} + function BoardColumn({ column, tasks, @@ -879,21 +927,12 @@ function BoardColumn({ onCreateSubcolumn, onCreateRow, onColumnMount, - taskRowId = null, - columnSortable = true, - showColumnActions = true, - showHeader = true, - showBody = true, - showVisibilityToggle = false, contentVisible = true, onToggleContentVisibility, - sharedGridCell = false, linkedColumnHighlight = false, synchronizedColumnDrag = false, fillAvailableHeight = false, hideOnMobile = false, - nested = false, - headerSticky = true, quickTaskComposer, }: { column: TaskColumn; @@ -919,21 +958,12 @@ function BoardColumn({ onCreateSubcolumn: (column: TaskColumn) => void; onCreateRow?: (column: TaskColumn) => void; onColumnMount: (columnId: number, node: HTMLElement | null) => void; - taskRowId?: number | null; - columnSortable?: boolean; - showColumnActions?: boolean; - showHeader?: boolean; - showBody?: boolean; - showVisibilityToggle?: boolean; contentVisible?: boolean; onToggleContentVisibility?: () => void; - sharedGridCell?: boolean; linkedColumnHighlight?: boolean; synchronizedColumnDrag?: boolean; fillAvailableHeight?: boolean; hideOnMobile?: boolean; - nested?: boolean; - headerSticky?: boolean; quickTaskComposer?: ReactNode; }) { const { @@ -946,26 +976,23 @@ function BoardColumn({ isDragging, isOver, } = useSortable({ - id: columnSortable - ? `column-${column.id}` - : `task-zone-${column.id}-row-${taskRowId ?? "base"}`, + id: `column-${column.id}`, data: { - type: columnSortable ? "column" : "task-zone", + type: "column", columnId: column.id, - rowId: taskRowId, + rowId: null, acceptsTasks: true, }, - disabled: !columnSortable, }); const style = { - transform: columnSortable ? CSS.Transform.toString(transform) : undefined, - transition: columnSortable ? transition : undefined, + transform: CSS.Transform.toString(transform), + transition, zIndex: isDragging ? 20 : undefined, }; const setColumnNodeRef = useCallback((node: HTMLElement | null) => { setNodeRef(node); - if (columnSortable) onColumnMount(column.id, node); - }, [column.id, columnSortable, onColumnMount, setNodeRef]); + onColumnMount(column.id, node); + }, [column.id, onColumnMount, setNodeRef]); const columnBorderClass = linkedColumnHighlight || (isOver && !synchronizedColumnDrag) ? "border-[var(--accent-primary)]" : "border-[var(--border-subtle)]"; @@ -975,26 +1002,24 @@ function BoardColumn({ ref={setColumnNodeRef} style={style} className={`${hideOnMobile ? "hidden xl:flex" : "flex"} max-h-[calc(100dvh-15rem)] ${ - showBody && contentVisible ? (taskRowId ? "min-h-[10rem]" : "min-h-[28rem]") : "" - } w-full min-w-0 flex-col transition xl:max-h-none ${ - sharedGridCell ? "xl:w-full xl:min-w-0" : "xl:w-auto xl:min-w-[18rem]" - } ${fillAvailableHeight ? "xl:min-h-full" : ""} ${ + contentVisible ? "min-h-[28rem]" : "" + } w-full min-w-0 flex-col transition xl:max-h-none xl:w-auto xl:min-w-[18rem] ${ + fillAvailableHeight ? "xl:min-h-full" : "" + } ${ isDragging ? "opacity-70" : "" }`} >
- {showVisibilityToggle ? ( - - ) : null} + - {showColumnActions ?
event.stopPropagation()} @@ -1101,41 +1124,230 @@ function BoardColumn({
) : null} -
: null} +
- {showBody && contentVisible ?
- {quickTaskComposer} - {tasks.length > 0 ? ( - tasks.map((task) => ( - onToggleTaskMenu(task.id)} - /> - )) - ) : quickTaskComposer ? null : ( -
-

Нет задач

-
- )} +
: null} ); } +type BoardSubcolumnHeaderProps = { + column: TaskColumn; + tasksCount: number; + sortable: boolean; + onCreateTask: () => void; + columnMenuOpen: boolean; + columnMenuRef: RefObject; + onToggleColumnMenu: () => void; + onEditColumn: (column: TaskColumn) => void; + onDeleteColumn: (column: TaskColumn) => void; + onColumnMount: (columnId: number, node: HTMLElement | null) => void; + linkedColumnHighlight: boolean; + synchronizedColumnDrag: boolean; +}; + +function BoardSubcolumnHeader({ + column, + tasksCount, + sortable, + onCreateTask, + columnMenuOpen, + columnMenuRef, + onToggleColumnMenu, + onEditColumn, + onDeleteColumn, + onColumnMount, + linkedColumnHighlight, + synchronizedColumnDrag, +}: BoardSubcolumnHeaderProps) { + const { + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + isDragging, + isOver, + } = useSortable({ + id: `column-${column.id}`, + data: { + type: "column", + columnId: column.id, + acceptsTasks: true, + }, + disabled: !sortable, + }); + const setHeaderNodeRef = useCallback((node: HTMLElement | null) => { + setNodeRef(node); + if (sortable) onColumnMount(column.id, node); + }, [column.id, onColumnMount, setNodeRef, sortable]); + const columnBorderClass = linkedColumnHighlight || (isOver && !synchronizedColumnDrag) + ? "border-[var(--accent-primary)]" + : "border-[var(--border-subtle)]"; + + return ( +
+
+
+
+ +

+ {column.name} +

+
+

{tasksCount} задач

+
+
+ + {sortable ? ( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + + {columnMenuOpen ? ( +
+ + +
+ ) : null} +
+ ) : null} +
+
+
+ ); +} + +type BoardTaskCellProps = TaskColumnCardsProps & { + column: TaskColumn; + rowId: number | null; + linkedColumnHighlight: boolean; + synchronizedColumnDrag: boolean; + fillAvailableHeight: boolean; +}; + +function BoardTaskCell({ + column, + rowId, + linkedColumnHighlight, + synchronizedColumnDrag, + fillAvailableHeight, + ...cardsProps +}: BoardTaskCellProps) { + const { setNodeRef, isOver } = useDroppable({ + id: `task-zone-${column.id}-row-${rowId ?? "base"}`, + data: { + type: "task-zone", + columnId: column.id, + rowId, + acceptsTasks: true, + }, + }); + const columnBorderClass = linkedColumnHighlight || (isOver && !synchronizedColumnDrag) + ? "border-[var(--accent-primary)]" + : "border-[var(--border-subtle)]"; + + return ( +
+
+ +
+
+ ); +} + function BoardColumnGroup({ column, tasksCount, @@ -3162,7 +3374,6 @@ function TasksPageContent() { const selectedBoardIdRef = useRef(null); const boardViewStateRef = useRef(EMPTY_BOARD_VIEW_STATE); const pendingBoardViewStateRef = useRef>(new Map()); - const taskBoardSyncTimerRef = useRef | null>(null); const boardScrollRef = useRef(null); const boardPanRef = useRef<{ pointerId: number; @@ -3693,73 +3904,16 @@ function TasksPageContent() { } }, [loadBoard, loadBoards]); - useEffect(() => { - if (typeof window === "undefined") return undefined; - - const unsubscribe = wsManager.subscribe((event) => { - const data = event as TaskBoardSocketEvent; - if (data.type !== "task_board_update") return; - - const boardId = Number(data.data?.board_id); - if (!Number.isFinite(boardId) || boardId <= 0) return; - - const eventTaskId = Number( - data.data?.task_id ?? ( - data.data?.model === "task" ? data.data?.object_id : undefined - ), - ); - if ( - data.data?.model === "checklist_item" && - checklistOpen && - viewTaskId && - eventTaskId === viewTaskId - ) { - void loadTaskChecklist(viewTaskId); - } - - if ( - commentsOpen && - viewTaskId && - eventTaskId === viewTaskId && - ( - data.data?.event === "commented" || - data.data?.event === "comment_edited" || - data.data?.event === "comment_deleted" - ) - ) { - void loadTaskComments(viewTaskId); - } - - if (activityOpen && viewTaskId && eventTaskId === viewTaskId) { - void loadTaskActivity(viewTaskId); - } - - if (taskBoardSyncTimerRef.current) { - clearTimeout(taskBoardSyncTimerRef.current); - } - taskBoardSyncTimerRef.current = setTimeout(() => { - taskBoardSyncTimerRef.current = null; - void syncTaskBoardState(boardId); - }, 250); - }); - - return () => { - unsubscribe(); - if (taskBoardSyncTimerRef.current) { - clearTimeout(taskBoardSyncTimerRef.current); - taskBoardSyncTimerRef.current = null; - } - }; - }, [ - activityOpen, + useTaskBoardRealtime({ + viewTaskId, checklistOpen, commentsOpen, - loadTaskActivity, - loadTaskChecklist, - loadTaskComments, - syncTaskBoardState, - viewTaskId, - ]); + activityOpen, + onChecklistChange: (taskId) => void loadTaskChecklist(taskId), + onCommentsChange: (taskId) => void loadTaskComments(taskId), + onActivityChange: (taskId) => void loadTaskActivity(taskId), + onBoardChange: (boardId) => void syncTaskBoardState(boardId), + }); useEffect(() => { let mounted = true; @@ -4015,88 +4169,19 @@ function TasksPageContent() { [onlyMine, searchedTasks, user?.id], ); - const tasksByPlacement = useMemo(() => { - const grouped = new Map(); - for (const task of filteredTasks) { - const key = taskPlacementKey(task.column, task.row || null); - const list = grouped.get(key) || []; - list.push(task); - grouped.set(key, list); - } - for (const list of grouped.values()) { - list.sort((left, right) => left.position - right.position || left.id - right.id); - } - return grouped; - }, [filteredTasks]); - - const activeColumns = useMemo( - () => (board?.columns || []).filter((column) => !column.is_archived), - [board?.columns], + const { + activeColumns, + topLevelColumns, + subcolumnsByParent, + rowsByColumn, + leafColumns, + columnCounts, + tasksByPlacement, + } = useMemo( + () => buildTaskBoardLayout(board?.columns, board?.column_rows, filteredTasks), + [board?.column_rows, board?.columns, filteredTasks], ); - const topLevelColumns = useMemo( - () => activeColumns - .filter((column) => !column.parent) - .sort((left, right) => left.position - right.position || left.id - right.id), - [activeColumns], - ); - - const subcolumnsByParent = useMemo(() => { - const grouped = new Map(); - for (const column of activeColumns) { - if (!column.parent) continue; - const siblings = grouped.get(column.parent) || []; - siblings.push(column); - grouped.set(column.parent, siblings); - } - for (const siblings of grouped.values()) { - siblings.sort((left, right) => left.position - right.position || left.id - right.id); - } - return grouped; - }, [activeColumns]); - - const rowsByColumn = useMemo(() => { - const grouped = new Map(); - for (const row of board?.column_rows || []) { - const rows = grouped.get(row.column) || []; - rows.push(row); - grouped.set(row.column, rows); - } - for (const rows of grouped.values()) { - rows.sort((left, right) => left.position - right.position || left.id - right.id); - } - return grouped; - }, [board?.column_rows]); - - const leafColumns = useMemo( - () => topLevelColumns.flatMap((column) => { - const subcolumns = subcolumnsByParent.get(column.id) || []; - return subcolumns.length > 0 ? subcolumns : [column]; - }), - [subcolumnsByParent, topLevelColumns], - ); - - const columnCounts = useMemo(() => { - const counts = new Map(); - for (const column of activeColumns) counts.set(column.id, 0); - for (const task of filteredTasks) { - counts.set(task.column, (counts.get(task.column) || 0) + 1); - } - for (const column of topLevelColumns) { - const subcolumns = subcolumnsByParent.get(column.id) || []; - if (subcolumns.length > 0) { - counts.set( - column.id, - (counts.get(column.id) || 0) + subcolumns.reduce( - (total, subcolumn) => total + (counts.get(subcolumn.id) || 0), - 0, - ), - ); - } - } - return counts; - }, [activeColumns, filteredTasks, subcolumnsByParent, topLevelColumns]); - const mineCount = useMemo( () => searchedTasks.filter((task) => task.assignee?.id === user?.id).length, [searchedTasks, user?.id], @@ -6856,7 +6941,6 @@ function TasksPageContent() { onCreateSubcolumn={(parent) => openCreateColumn(parent.id)} onCreateRow={(parent) => openCreateColumnRow(parent.id)} onColumnMount={registerColumnNode} - showVisibilityToggle contentVisible={!boardViewState.hidden_column_ids.includes(column.id)} onToggleContentVisibility={() => toggleBoardViewState("column", column.id)} linkedColumnHighlight={activeColumnDragId === column.id} @@ -6911,20 +6995,15 @@ function TasksPageContent() { 0, ); const gridTemplateColumns = `repeat(${cellColumns.length}, ${TASK_SUBCOLUMN_WIDTH})`; - const renderCell = ( + const renderTaskCell = ( cellColumn: TaskColumn, rowId: number | null, - sortable: boolean, - showHeaderValue = rowId === null, - showBodyValue = true, - headerStickyValue = true, ) => ( - + ); + const renderSubcolumnHeader = ( + cellColumn: TaskColumn, + sortable: boolean, + ) => ( + startQuickTask(cellColumn.id)} columnMenuOpen={sortable && columnMenuId === cellColumn.id} columnMenuRef={columnMenuRef} onToggleColumnMenu={() => toggleColumnMenu(cellColumn.id)} onEditColumn={openEditColumn} onDeleteColumn={deleteColumn} - onCreateSubcolumn={(parent) => openCreateColumn(parent.id)} onColumnMount={registerColumnNode} - taskRowId={rowId} - columnSortable={sortable} - showColumnActions={sortable} - showHeader={showHeaderValue} - showBody={showBodyValue} - sharedGridCell linkedColumnHighlight={activeColumnDragId === cellColumn.id} synchronizedColumnDrag={activeColumnDragId !== null} - fillAvailableHeight={desktopWideMode && rowId === null} - nested={Boolean(cellColumn.parent)} - headerSticky={headerStickyValue} - quickTaskComposer={renderQuickTaskComposer(cellColumn, rowId)} /> ); const baseColumns = ( @@ -6967,7 +7051,7 @@ function TasksPageContent() { attachedToHeaders > {previewCellColumns.map((item) => ( - renderCell(item, null, false, false, true) + renderTaskCell(item, null) ))} ); @@ -6977,9 +7061,9 @@ function TasksPageContent() { items={subcolumns.map((item) => `column-${item.id}`)} strategy={horizontalListSortingStrategy} > - {subcolumns.map((item) => renderCell(item, null, true, true, false, false))} + {subcolumns.map((item) => renderSubcolumnHeader(item, true))} - ) : renderCell(column, null, false, true, false, false); + ) : renderSubcolumnHeader(column, false); return (
@@ -7006,7 +7090,7 @@ function TasksPageContent() { expanded={!boardViewState.collapsed_row_ids.includes(row.id)} onToggle={() => toggleBoardViewState("row", row.id)} > - {previewCellColumns.map((item) => renderCell(item, row.id, false))} + {previewCellColumns.map((item) => renderTaskCell(item, row.id))} ); })} diff --git a/frontend/src/hooks/useTaskBoardRealtime.ts b/frontend/src/hooks/useTaskBoardRealtime.ts new file mode 100644 index 00000000..9c056c9e --- /dev/null +++ b/frontend/src/hooks/useTaskBoardRealtime.ts @@ -0,0 +1,65 @@ +import { useEffect, useRef } from "react"; + +import { parseTaskBoardSocketUpdate } from "@/lib/tasks/taskBoardEvents"; +import wsManager from "@/lib/websocketManager"; + +type TaskBoardRealtimeOptions = { + viewTaskId: number | null; + checklistOpen: boolean; + commentsOpen: boolean; + activityOpen: boolean; + onChecklistChange: (taskId: number) => void; + onCommentsChange: (taskId: number) => void; + onActivityChange: (taskId: number) => void; + onBoardChange: (boardId: number) => void; + debounceMs?: number; +}; + +const COMMENT_EVENTS = new Set([ + "commented", + "comment_edited", + "comment_deleted", +]); + +export function useTaskBoardRealtime(options: TaskBoardRealtimeOptions): void { + const optionsRef = useRef(options); + const syncTimerRef = useRef | null>(null); + + useEffect(() => { + optionsRef.current = options; + }, [options]); + + useEffect(() => { + const unsubscribe = wsManager.subscribe((event) => { + const update = parseTaskBoardSocketUpdate(event); + if (!update) return; + + const current = optionsRef.current; + if (update.taskId && update.taskId === current.viewTaskId) { + if (update.model === "checklist_item" && current.checklistOpen) { + current.onChecklistChange(update.taskId); + } + if (current.commentsOpen && COMMENT_EVENTS.has(update.event)) { + current.onCommentsChange(update.taskId); + } + if (current.activityOpen) { + current.onActivityChange(update.taskId); + } + } + + if (syncTimerRef.current) clearTimeout(syncTimerRef.current); + syncTimerRef.current = setTimeout(() => { + syncTimerRef.current = null; + optionsRef.current.onBoardChange(update.boardId); + }, current.debounceMs ?? 250); + }); + + return () => { + unsubscribe(); + if (syncTimerRef.current) { + clearTimeout(syncTimerRef.current); + syncTimerRef.current = null; + } + }; + }, []); +} diff --git a/frontend/src/lib/task-board-events.test.js b/frontend/src/lib/task-board-events.test.js new file mode 100644 index 00000000..35d51363 --- /dev/null +++ b/frontend/src/lib/task-board-events.test.js @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { parseTaskBoardSocketUpdate } from "./tasks/taskBoardEvents.ts"; + +test("task board event parser normalizes task updates", () => { + assert.deepEqual(parseTaskBoardSocketUpdate({ + type: "task_board_update", + data: { + board_id: "7", + model: "task", + object_id: "42", + event: "updated", + }, + }), { + boardId: 7, + taskId: 42, + model: "task", + event: "updated", + }); +}); + +test("task board event parser keeps explicit parent task ids", () => { + assert.deepEqual(parseTaskBoardSocketUpdate({ + type: "task_board_update", + data: { + board_id: 3, + model: "checklist_item", + object_id: 91, + task_id: 12, + event: "updated", + }, + }), { + boardId: 3, + taskId: 12, + model: "checklist_item", + event: "updated", + }); +}); + +test("task board event parser rejects unrelated and invalid events", () => { + assert.equal(parseTaskBoardSocketUpdate({ type: "notification" }), null); + assert.equal(parseTaskBoardSocketUpdate({ + type: "task_board_update", + data: { board_id: 0 }, + }), null); +}); diff --git a/frontend/src/lib/task-board-layout.test.js b/frontend/src/lib/task-board-layout.test.js new file mode 100644 index 00000000..8556a1c0 --- /dev/null +++ b/frontend/src/lib/task-board-layout.test.js @@ -0,0 +1,101 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildTaskBoardLayout, + taskPlacementKey, +} from "./tasks/boardLayout.ts"; + +const column = (id, position, parent = null, isArchived = false) => ({ + id, + board: 1, + parent, + name: `column-${id}`, + position, + is_done: false, + is_archived: isArchived, + created_at: "", + updated_at: "", +}); + +const row = (id, columnId, position) => ({ + id, + column: columnId, + name: `row-${id}`, + position, + created_at: "", + updated_at: "", +}); + +const task = (id, columnId, rowId, position) => ({ + id, + board: 1, + column: columnId, + row: rowId, + title: `task-${id}`, + priority: "medium", + position, + created_at: "", + updated_at: "", +}); + +test("board layout orders columns, subcolumns and rows without mutating source arrays", () => { + const columns = [ + column(2, 2000), + column(1, 1000), + column(12, 2000, 1), + column(11, 1000, 1), + column(99, 0, null, true), + ]; + const rows = [row(2, 1, 2000), row(1, 1, 1000)]; + + const layout = buildTaskBoardLayout(columns, rows, []); + + assert.deepEqual(layout.activeColumns.map(({ id }) => id), [2, 1, 12, 11]); + assert.deepEqual(layout.topLevelColumns.map(({ id }) => id), [1, 2]); + assert.deepEqual(layout.subcolumnsByParent.get(1)?.map(({ id }) => id), [11, 12]); + assert.deepEqual(layout.rowsByColumn.get(1)?.map(({ id }) => id), [1, 2]); + assert.deepEqual(layout.leafColumns.map(({ id }) => id), [11, 12, 2]); + assert.deepEqual(columns.map(({ id }) => id), [2, 1, 12, 11, 99]); + assert.deepEqual(rows.map(({ id }) => id), [2, 1]); +}); + +test("board layout groups and orders tasks by subcolumn and lane", () => { + const tasks = [ + task(3, 11, null, 2000), + task(2, 11, 7, 1000), + task(1, 11, null, 1000), + ]; + + const layout = buildTaskBoardLayout( + [column(1, 1000), column(11, 1000, 1)], + [row(7, 1, 1000)], + tasks, + ); + + assert.deepEqual( + layout.tasksByPlacement.get(taskPlacementKey(11, null))?.map(({ id }) => id), + [1, 3], + ); + assert.deepEqual( + layout.tasksByPlacement.get(taskPlacementKey(11, 7))?.map(({ id }) => id), + [2], + ); +}); + +test("top-level column count includes direct tasks and all subcolumn tasks", () => { + const layout = buildTaskBoardLayout( + [column(1, 1000), column(11, 1000, 1), column(12, 2000, 1)], + [], + [ + task(1, 1, null, 1000), + task(2, 11, null, 1000), + task(3, 12, null, 1000), + task(4, 12, null, 2000), + ], + ); + + assert.equal(layout.columnCounts.get(1), 4); + assert.equal(layout.columnCounts.get(11), 1); + assert.equal(layout.columnCounts.get(12), 2); +}); diff --git a/frontend/src/lib/tasks/boardLayout.ts b/frontend/src/lib/tasks/boardLayout.ts new file mode 100644 index 00000000..b777cd54 --- /dev/null +++ b/frontend/src/lib/tasks/boardLayout.ts @@ -0,0 +1,96 @@ +import type { TaskCard, TaskColumn, TaskColumnRow } from "@/types/api"; + +export type TaskBoardLayout = { + activeColumns: TaskColumn[]; + topLevelColumns: TaskColumn[]; + subcolumnsByParent: Map; + rowsByColumn: Map; + leafColumns: TaskColumn[]; + columnCounts: Map; + tasksByPlacement: Map; +}; + +function byPositionThenId( + left: T, + right: T, +): number { + return left.position - right.position || left.id - right.id; +} + +export function taskPlacementKey(columnId: number, rowId: number | null): string { + return `${columnId}:${rowId ?? "base"}`; +} + +export function buildTaskBoardLayout( + columns: TaskColumn[] | null | undefined, + rows: TaskColumnRow[] | null | undefined, + tasks: TaskCard[] | null | undefined, +): TaskBoardLayout { + const activeColumns = (columns || []).filter((column) => !column.is_archived); + const topLevelColumns = activeColumns + .filter((column) => !column.parent) + .sort(byPositionThenId); + + const subcolumnsByParent = new Map(); + for (const column of activeColumns) { + if (!column.parent) continue; + const siblings = subcolumnsByParent.get(column.parent) || []; + siblings.push(column); + subcolumnsByParent.set(column.parent, siblings); + } + for (const siblings of subcolumnsByParent.values()) { + siblings.sort(byPositionThenId); + } + + const rowsByColumn = new Map(); + for (const row of rows || []) { + const columnRows = rowsByColumn.get(row.column) || []; + columnRows.push(row); + rowsByColumn.set(row.column, columnRows); + } + for (const columnRows of rowsByColumn.values()) { + columnRows.sort(byPositionThenId); + } + + const leafColumns = topLevelColumns.flatMap((column) => { + const subcolumns = subcolumnsByParent.get(column.id) || []; + return subcolumns.length > 0 ? subcolumns : [column]; + }); + + const tasksByPlacement = new Map(); + const columnCounts = new Map(); + for (const column of activeColumns) columnCounts.set(column.id, 0); + + for (const task of tasks || []) { + const key = taskPlacementKey(task.column, task.row || null); + const placementTasks = tasksByPlacement.get(key) || []; + placementTasks.push(task); + tasksByPlacement.set(key, placementTasks); + columnCounts.set(task.column, (columnCounts.get(task.column) || 0) + 1); + } + for (const placementTasks of tasksByPlacement.values()) { + placementTasks.sort(byPositionThenId); + } + + for (const column of topLevelColumns) { + const subcolumns = subcolumnsByParent.get(column.id) || []; + if (subcolumns.length === 0) continue; + columnCounts.set( + column.id, + (columnCounts.get(column.id) || 0) + subcolumns.reduce( + (total, subcolumn) => total + (columnCounts.get(subcolumn.id) || 0), + 0, + ), + ); + } + + return { + activeColumns, + topLevelColumns, + subcolumnsByParent, + rowsByColumn, + leafColumns, + columnCounts, + tasksByPlacement, + }; +} diff --git a/frontend/src/lib/tasks/taskBoardEvents.ts b/frontend/src/lib/tasks/taskBoardEvents.ts new file mode 100644 index 00000000..92070d4b --- /dev/null +++ b/frontend/src/lib/tasks/taskBoardEvents.ts @@ -0,0 +1,44 @@ +export type TaskBoardSocketUpdate = { + boardId: number; + taskId: number | null; + event: string; + model: string; +}; + +type TaskBoardSocketEvent = { + type?: unknown; + data?: { + board_id?: unknown; + event?: unknown; + model?: unknown; + object_id?: unknown; + task_id?: unknown; + }; +}; + +export function parseTaskBoardSocketUpdate( + event: unknown, +): TaskBoardSocketUpdate | null { + const socketEvent = event as TaskBoardSocketEvent; + if (socketEvent?.type !== "task_board_update") return null; + + const boardId = Number(socketEvent.data?.board_id); + if (!Number.isFinite(boardId) || boardId <= 0) return null; + + const model = typeof socketEvent.data?.model === "string" + ? socketEvent.data.model + : ""; + const eventName = typeof socketEvent.data?.event === "string" + ? socketEvent.data.event + : ""; + const rawTaskId = socketEvent.data?.task_id + ?? (model === "task" ? socketEvent.data?.object_id : undefined); + const taskId = Number(rawTaskId); + + return { + boardId, + taskId: Number.isFinite(taskId) && taskId > 0 ? taskId : null, + event: eventName, + model, + }; +}