refactor(tasks): separate board layout and realtime state

This commit is contained in:
lizerk 2026-08-06 17:06:40 +03:00
parent 78c54aac0b
commit 0f8ae2095d
6 changed files with 710 additions and 273 deletions

View File

@ -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<HTMLDivElement | null>;
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) => (
<TaskCardView
key={task.id}
task={task}
onOpen={onOpenTask}
onEdit={onEditTask}
onDelete={onDeleteTask}
currentUserId={currentUserId}
claiming={claimingTaskId === task.id}
onClaim={onClaimTask}
completing={completingTaskId === task.id}
onComplete={onCompleteTask}
menuOpen={openMenuTaskId === task.id}
menuRef={menuRef}
onToggleMenu={() => onToggleTaskMenu(task.id)}
/>
))
) : quickTaskComposer ? null : (
<div className="app-surface rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-5 text-center">
<p className="app-text-muted text-xs">Нет задач</p>
</div>
)}
</>
);
}
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" : ""
}`}
>
<div
className={`${showHeader ? "flex" : "hidden"} items-center justify-between gap-3 rounded-xl border bg-[var(--surface-primary)] px-3 py-3 shadow-sm transition ${headerSticky ? `xl:sticky ${nested ? "xl:top-[4.75rem]" : "xl:top-0"} xl:z-10` : ""} ${columnBorderClass} ${
className={`flex items-center justify-between gap-3 rounded-xl border bg-[var(--surface-primary)] px-3 py-3 shadow-sm transition xl:sticky xl:top-0 xl:z-10 ${columnBorderClass} ${
isDragging ? "shadow-xl" : ""
}`}
>
<div
ref={setActivatorNodeRef}
className={`min-w-0 flex-1 overflow-hidden ${
columnSortable ? "cursor-grab touch-none active:cursor-grabbing" : ""
}`}
title={columnSortable ? "Перетащите, чтобы изменить порядок колонок" : undefined}
{...(columnSortable ? attributes : {})}
{...(columnSortable ? listeners : {})}
className="min-w-0 flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing"
title="Перетащите, чтобы изменить порядок колонок"
{...attributes}
{...listeners}
>
<div className="flex items-center gap-2">
<span
@ -1010,30 +1035,28 @@ function BoardColumn({
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{showVisibilityToggle ? (
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleContentVisibility?.();
}}
className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg"
title={contentVisible ? "Скрыть содержимое колонки" : "Показать содержимое колонки"}
aria-label={contentVisible
? `Скрыть содержимое колонки ${column.name}`
: `Показать содержимое колонки ${column.name}`}
aria-pressed={!contentVisible}
>
{contentVisible ? <Eye size={15} /> : <EyeOff size={15} />}
</button>
) : null}
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onCreateTask(column.id, taskRowId);
onToggleContentVisibility?.();
}}
className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg"
title={contentVisible ? "Скрыть содержимое колонки" : "Показать содержимое колонки"}
aria-label={contentVisible
? `Скрыть содержимое колонки ${column.name}`
: `Показать содержимое колонки ${column.name}`}
aria-pressed={!contentVisible}
>
{contentVisible ? <Eye size={15} /> : <EyeOff size={15} />}
</button>
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onCreateTask(column.id, null);
}}
className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg"
title="Создать задачу"
@ -1041,7 +1064,7 @@ function BoardColumn({
>
<Plus size={16} />
</button>
{showColumnActions ? <div
<div
ref={columnMenuOpen ? columnMenuRef : null}
className="relative"
onPointerDown={(event) => event.stopPropagation()}
@ -1101,41 +1124,230 @@ function BoardColumn({
</button>
</div>
) : null}
</div> : null}
</div>
</div>
</div>
{showBody && contentVisible ? <div
className={`tasks-column-scroll ${showHeader ? "mt-2" : ""} min-h-0 flex-1 space-y-2 overflow-y-auto rounded-xl border bg-[var(--surface-primary)] p-3 transition xl:flex-none xl:overflow-visible ${columnBorderClass}`}
{contentVisible ? <div
className={`tasks-column-scroll mt-2 min-h-0 flex-1 space-y-2 overflow-y-auto rounded-xl border bg-[var(--surface-primary)] p-3 transition xl:flex-none xl:overflow-visible ${columnBorderClass}`}
>
{quickTaskComposer}
{tasks.length > 0 ? (
tasks.map((task) => (
<TaskCardView
key={task.id}
task={task}
onOpen={onOpenTask}
onEdit={onEditTask}
onDelete={onDeleteTask}
currentUserId={currentUserId}
claiming={claimingTaskId === task.id}
onClaim={onClaimTask}
completing={completingTaskId === task.id}
onComplete={onCompleteTask}
menuOpen={openMenuTaskId === task.id}
menuRef={menuRef}
onToggleMenu={() => onToggleTaskMenu(task.id)}
/>
))
) : quickTaskComposer ? null : (
<div className="app-surface rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-5 text-center">
<p className="app-text-muted text-xs">Нет задач</p>
</div>
)}
<TaskColumnCards
tasks={tasks}
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
currentUserId={currentUserId}
claimingTaskId={claimingTaskId}
onClaimTask={onClaimTask}
completingTaskId={completingTaskId}
onCompleteTask={onCompleteTask}
openMenuTaskId={openMenuTaskId}
menuRef={menuRef}
onToggleTaskMenu={onToggleTaskMenu}
quickTaskComposer={quickTaskComposer}
/>
</div> : null}
</section>
);
}
type BoardSubcolumnHeaderProps = {
column: TaskColumn;
tasksCount: number;
sortable: boolean;
onCreateTask: () => void;
columnMenuOpen: boolean;
columnMenuRef: RefObject<HTMLDivElement | null>;
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 (
<section
ref={setHeaderNodeRef}
style={{
transform: sortable ? CSS.Transform.toString(transform) : undefined,
transition: sortable ? transition : undefined,
zIndex: isDragging ? 20 : undefined,
}}
className={`flex w-full min-w-0 flex-col transition ${isDragging ? "opacity-70" : ""}`}
>
<div
className={`flex items-center justify-between gap-3 rounded-xl border bg-[var(--surface-primary)] px-3 py-3 shadow-sm transition ${columnBorderClass} ${
isDragging ? "shadow-xl" : ""
}`}
>
<div
ref={setActivatorNodeRef}
className={`min-w-0 flex-1 overflow-hidden ${
sortable ? "cursor-grab touch-none active:cursor-grabbing" : ""
}`}
title={sortable ? "Перетащите, чтобы изменить порядок подколонок" : undefined}
{...(sortable ? attributes : {})}
{...(sortable ? listeners : {})}
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: column.color || "#38bdf8" }}
/>
<h2 className="min-w-0 truncate text-sm font-semibold text-[var(--foreground)]">
{column.name}
</h2>
</div>
<p className="app-text-muted mt-0.5 text-xs">{tasksCount} задач</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onCreateTask();
}}
className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg"
title="Создать задачу"
aria-label="Создать задачу"
>
<Plus size={16} />
</button>
{sortable ? (
<div
ref={columnMenuOpen ? columnMenuRef : null}
className="relative"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={onToggleColumnMenu}
className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg"
title="Действия с подколонкой"
aria-label={`Действия с подколонкой ${column.name}`}
aria-expanded={columnMenuOpen}
aria-haspopup="menu"
>
<ChevronDown
size={14}
className={`transition-transform ${columnMenuOpen ? "" : "-rotate-90"}`}
/>
</button>
{columnMenuOpen ? (
<div className="app-menu absolute right-0 top-full z-30 mt-2 w-44 rounded-lg p-1.5">
<button
type="button"
onClick={() => onEditColumn(column)}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} className="app-text-muted" />
Редактировать
</button>
<button
type="button"
onClick={() => onDeleteColumn(column)}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)]"
>
<Trash2 size={14} />
Удалить
</button>
</div>
) : null}
</div>
) : null}
</div>
</div>
</section>
);
}
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 (
<section
ref={setNodeRef}
className={`flex w-full min-w-0 flex-col transition ${
rowId ? "min-h-[10rem]" : "min-h-[28rem]"
} xl:w-full ${fillAvailableHeight ? "xl:min-h-full" : ""}`}
>
<div
className={`tasks-column-scroll min-h-0 flex-1 space-y-2 overflow-y-auto rounded-xl border bg-[var(--surface-primary)] p-3 transition xl:flex-none xl:overflow-visible ${columnBorderClass}`}
>
<TaskColumnCards {...cardsProps} />
</div>
</section>
);
}
function BoardColumnGroup({
column,
tasksCount,
@ -3162,7 +3374,6 @@ function TasksPageContent() {
const selectedBoardIdRef = useRef<number | null>(null);
const boardViewStateRef = useRef<TaskBoardViewState>(EMPTY_BOARD_VIEW_STATE);
const pendingBoardViewStateRef = useRef<Map<string, boolean>>(new Map());
const taskBoardSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const boardScrollRef = useRef<HTMLDivElement | null>(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<string, TaskCard[]>();
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<number, TaskColumn[]>();
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<number, TaskColumnRow[]>();
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<number, number>();
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,
) => (
<BoardColumn
key={`${cellColumn.id}:${rowId ?? "base"}`}
<BoardTaskCell
key={`cell-${cellColumn.id}:${rowId ?? "base"}`}
column={cellColumn}
rowId={rowId}
tasks={tasksByPlacement.get(taskPlacementKey(cellColumn.id, rowId)) || []}
displayTasksCount={columnCounts.get(cellColumn.id) || 0}
onCreateTask={startQuickTask}
onOpenTask={openTaskView}
onEditTask={openEditTask}
onDeleteTask={deleteTask}
@ -6936,25 +7015,30 @@ function TasksPageContent() {
openMenuTaskId={cardMenuTaskId}
menuRef={cardMenuRef}
onToggleTaskMenu={toggleCardTaskMenu}
linkedColumnHighlight={activeColumnDragId === cellColumn.id}
synchronizedColumnDrag={activeColumnDragId !== null}
fillAvailableHeight={desktopWideMode && rowId === null}
quickTaskComposer={renderQuickTaskComposer(cellColumn, rowId)}
/>
);
const renderSubcolumnHeader = (
cellColumn: TaskColumn,
sortable: boolean,
) => (
<BoardSubcolumnHeader
key={`header-${cellColumn.id}`}
column={cellColumn}
tasksCount={columnCounts.get(cellColumn.id) || 0}
sortable={sortable}
onCreateTask={() => 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)
))}
</BoardBaseColumnsView>
);
@ -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))}
</SortableContext>
) : renderCell(column, null, false, true, false, false);
) : renderSubcolumnHeader(column, false);
return (
<div className="flex min-w-0 flex-col xl:w-max">
@ -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))}
</BoardColumnRowView>
);
})}

View File

@ -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<ReturnType<typeof setTimeout> | 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;
}
};
}, []);
}

View File

@ -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);
});

View File

@ -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);
});

View File

@ -0,0 +1,96 @@
import type { TaskCard, TaskColumn, TaskColumnRow } from "@/types/api";
export type TaskBoardLayout = {
activeColumns: TaskColumn[];
topLevelColumns: TaskColumn[];
subcolumnsByParent: Map<number, TaskColumn[]>;
rowsByColumn: Map<number, TaskColumnRow[]>;
leafColumns: TaskColumn[];
columnCounts: Map<number, number>;
tasksByPlacement: Map<string, TaskCard[]>;
};
function byPositionThenId<T extends { id: number; position: number }>(
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<number, TaskColumn[]>();
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<number, TaskColumnRow[]>();
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<string, TaskCard[]>();
const columnCounts = new Map<number, number>();
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,
};
}

View File

@ -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,
};
}