diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index a279ead2..5a1e4f3d 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -144,6 +144,12 @@ class TaskAutomationRunSerializer(serializers.ModelSerializer): class TaskAutomationSerializer(serializers.ModelSerializer): + """Validate shared automation rules exposed to authenticated task users. + + ``can_manage`` intentionally mirrors the temporary broad API permission. It + should use the dedicated automation permission once that role is introduced. + """ + created_by = EmployeeBriefSerializer(read_only=True) boards = serializers.PrimaryKeyRelatedField( queryset=TaskBoard.objects.filter(is_archived=False), @@ -188,7 +194,7 @@ class TaskAutomationSerializer(serializers.ModelSerializer): def get_can_manage(self, obj): user = getattr(self.context.get("request"), "user", None) - return bool(user and (user.is_staff or user.is_superuser)) + return bool(user and user.is_authenticated) def validate_name(self, value): name = value.strip() @@ -624,6 +630,12 @@ class TaskSerializer(serializers.ModelSerializer): cover = obj.cover except TaskCover.DoesNotExist: cover = None + if ( + cover is not None + and cover.kind == TaskCoverType.CHECKLIST + and not obj.checklist_items.exists() + ): + cover = None payload = ( TaskCoverSerializer(cover, context=self.context).data if cover is not None diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index dd7f783a..3a52c773 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -32,7 +32,7 @@ from procurement.models import ProcurementRequest from requests_app.models import Request as EmployeeRequest from rest_framework import filters, parsers, status, viewsets from rest_framework.decorators import action -from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from schedule.models import Event @@ -169,34 +169,42 @@ def normalize_column_positions(column, row=None): Task.objects.filter(id=task_id).update(position=(index + 1) * 1000) -def send_task_checklist_update(task, event, item_id): +def send_task_checklist_update(task, event, item_id, *, cover_cleared=False): + extra = { + "task_id": task.id, + "checklist_total": task.checklist_items.count(), + "checklist_completed": task.checklist_items.filter( + is_completed=True + ).count(), + } + if cover_cleared: + extra["cover"] = None send_task_board_update( task.board, event, "checklist_item", item_id, - extra={ - "task_id": task.id, - "checklist_total": task.checklist_items.count(), - "checklist_completed": task.checklist_items.filter( - is_completed=True - ).count(), - }, + extra=extra, ) class TaskAutomationViewSet(viewsets.ModelViewSet): + """Manage shared task automation rules. + + Automation management is temporarily available to every authenticated user. + Rules are system-wide and may target multiple boards, so this broad access must + be replaced with a dedicated permission before permission roles are finalized. + """ + + # TODO(tasks-automation-permissions): Replace broad authenticated access with + # a dedicated permission/role for managing shared automation rules. + permission_classes = [IsAuthenticated] serializer_class = TaskAutomationSerializer filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ["name", "description"] ordering_fields = ["position", "name", "created_at", "last_run_at"] ordering = ["kind", "position", "name", "id"] - def get_permissions(self): - if self.action in {"catalog", "buttons", "run"}: - return [IsAuthenticated()] - return [IsAdminUser()] - def get_queryset(self): queryset = ( TaskAutomation.objects.select_related("created_by") @@ -1804,7 +1812,18 @@ class TaskViewSet(viewsets.ModelViewSet): if request.method == "DELETE": item_id = item.id item_title = item.title - item.delete() + with transaction.atomic(): + item.delete() + task._prefetched_objects_cache.pop("checklist_items", None) + cover_cleared = False + if not task.checklist_items.exists(): + cover_cleared, _ = TaskCover.objects.filter( + task=task, + kind=TaskCoverType.CHECKLIST, + ).delete() + cover_cleared = bool(cover_cleared) + if cover_cleared: + task.save(update_fields=["updated_at"]) create_task_activity( task, request.user, @@ -1813,7 +1832,12 @@ class TaskViewSet(viewsets.ModelViewSet): object_id=item_id, metadata={"item_title": item_title}, ) - send_task_checklist_update(task, "checklist_updated", item_id) + send_task_checklist_update( + task, + "checklist_updated", + item_id, + cover_cleared=cover_cleared, + ) return Response(status=status.HTTP_204_NO_CONTENT) serializer = TaskChecklistItemSerializer( diff --git a/backend/tasks/migrations/0034_cleanup_empty_checklist_covers.py b/backend/tasks/migrations/0034_cleanup_empty_checklist_covers.py new file mode 100644 index 00000000..c214c7f1 --- /dev/null +++ b/backend/tasks/migrations/0034_cleanup_empty_checklist_covers.py @@ -0,0 +1,24 @@ +from django.db import migrations + + +def remove_empty_checklist_covers(apps, schema_editor): + TaskChecklistItem = apps.get_model("tasks", "TaskChecklistItem") + TaskCover = apps.get_model("tasks", "TaskCover") + database = schema_editor.connection.alias + task_ids_with_checklists = TaskChecklistItem.objects.using(database).values("task_id") + TaskCover.objects.using(database).filter(kind="checklist").exclude( + task_id__in=task_ids_with_checklists, + ).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0033_universal_task_covers"), + ] + + operations = [ + migrations.RunPython( + remove_empty_checklist_covers, + migrations.RunPython.noop, + ), + ] diff --git a/backend/tests/api/v1/tasks/test_task_automations.py b/backend/tests/api/v1/tasks/test_task_automations.py index 04175162..d19325c1 100644 --- a/backend/tests/api/v1/tasks/test_task_automations.py +++ b/backend/tests/api/v1/tasks/test_task_automations.py @@ -213,7 +213,7 @@ def test_scheduled_automation_runs_relative_to_task_due_date(task_setup): assert run.context["scheduled_for"] == local_run_at.isoformat() -def test_regular_user_can_run_eligible_button_but_cannot_manage_rules( +def test_regular_user_can_manage_rules_and_run_eligible_button( api_client, user_factory, task_setup, @@ -232,6 +232,19 @@ def test_regular_user_can_run_eligible_button_but_cannot_manage_rules( api_client.force_authenticate(employee) list_response = api_client.get(reverse("api:v1:tasks:task-automation-list")) + create_response = api_client.post( + reverse("api:v1:tasks:task-automation-list"), + { + "name": "Правило сотрудника", + "kind": "event", + "trigger": "task_updated", + "conditions": {"operator": "and", "children": []}, + "actions": [{"type": "set_priority", "priority": "medium"}], + "applies_to_all_boards": False, + "boards": [board.id], + }, + format="json", + ) buttons_response = api_client.get( reverse("api:v1:tasks:task-automation-buttons"), {"task": task.id}, @@ -242,7 +255,10 @@ def test_regular_user_can_run_eligible_button_but_cannot_manage_rules( format="json", ) - assert list_response.status_code == status.HTTP_403_FORBIDDEN + assert list_response.status_code == status.HTTP_200_OK + assert create_response.status_code == status.HTTP_201_CREATED + assert create_response.data["created_by"]["id"] == employee.id + assert create_response.data["can_manage"] is True assert buttons_response.status_code == status.HTTP_200_OK assert [item["id"] for item in buttons_response.data] == [button.id] assert run_response.status_code == status.HTTP_200_OK @@ -250,6 +266,12 @@ def test_regular_user_can_run_eligible_button_but_cannot_manage_rules( assert task.priority == "high" +def test_automation_management_requires_authentication(api_client): + response = api_client.get(reverse("api:v1:tasks:task-automation-list")) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_manual_button_rechecks_conditions_on_run(api_client, user_factory, task_setup): owner, board, _, _, task = task_setup employee = user_factory(email="automation-condition@example.com") diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index d03dffeb..06744275 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -1738,6 +1738,29 @@ def test_task_cover_supports_checklist_comment_link_and_linked_object( ], } + delete_checklist_response = api_client.delete( + reverse( + "api:v1:tasks:task-checklist-item-detail", + kwargs={"pk": task.id, "item_id": checklist_item.id}, + ) + ) + assert delete_checklist_response.status_code == status.HTTP_204_NO_CONTENT + assert not TaskCover.objects.filter(task=task).exists() + + TaskCover.objects.create( + task=task, + kind=TaskCoverType.CHECKLIST, + created_by=user, + ) + board_response = api_client.get( + reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}) + ) + assert board_response.status_code == status.HTTP_200_OK + serialized_task = next( + item for item in board_response.data["tasks"] if item["id"] == task.id + ) + assert serialized_task["cover"] is None + comment_response = api_client.post( reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}), {"text": "Комментарий на обложке"}, diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index bc9954c4..3f40a269 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -899,6 +899,19 @@ html[data-bs-theme="dark"] .shadow-xl { box-shadow: var(--shadow-card) !important; } +html[data-bs-theme="dark"] .tasks-board-column-header, +html[data-bs-theme="dark"] .tasks-board-subcolumn-header { + box-shadow: none !important; +} + +html[data-bs-theme="dark"] .tasks-board-column-header-sticky { + background: var(--surface-elevated); +} + +html[data-bs-theme="dark"] .tasks-board-group-header-sticky { + background: var(--surface-elevated); +} + /* Скрытые полосы прокрутки везде */ * { @@ -995,3 +1008,84 @@ html[data-bs-theme="dark"] .shadow-xl { .tasks-column-scroll::-webkit-scrollbar-thumb:hover { background: var(--accent-primary); } + +@media (min-width: 80rem) { + .tasks-board-column-group { + --tasks-board-stack-gap: 0.5rem; + --tasks-board-column-header-height: 4rem; + --tasks-board-subcolumn-header-height: 4rem; + --tasks-board-group-header-stack-height: calc( + var(--tasks-board-column-header-height, 4rem) + + var(--tasks-board-stack-gap, 0.5rem) + + var(--tasks-board-subcolumn-header-height, 4rem) + + 1rem + + 1px + ); + } + + .tasks-board-column-header-sticky { + position: sticky; + top: 0; + z-index: 30; + padding-bottom: var(--tasks-board-stack-gap, 0.5rem); + background: var(--background); + } + + .tasks-board-column-header-sticky-standalone { + padding-bottom: 0; + } + + .tasks-board-column-header { + min-height: var(--tasks-board-column-header-height, 4rem); + background: var(--surface-elevated); + } + + .tasks-board-group-header-sticky { + position: sticky; + top: 0; + z-index: 30; + background: var(--background); + } + + .tasks-board-subcolumn-header-row { + min-height: var(--tasks-board-subcolumn-header-height, 4rem); + } + + .tasks-board-subcolumn-header-surface::after { + position: absolute; + right: -1px; + bottom: -0.75rem; + left: -1px; + height: 0.75rem; + border: 1px solid transparent; + border-top: 0; + border-radius: 0 0 0.75rem 0.75rem; + pointer-events: none; + background: transparent; + content: ""; + } + + .tasks-board-column-group[data-content-bottom-reached="true"] .tasks-board-subcolumn-header-surface::after { + border-color: var(--border-subtle); + border-top-color: transparent; + background: var(--surface-secondary); + } + + .tasks-board-lane-header { + position: sticky; + top: var(--tasks-board-group-header-stack-height, calc(9.5rem + 1px)); + z-index: 15; + background: var(--surface-secondary); + } + + .tasks-board-content-section-continuous { + margin-bottom: -3.9375rem; + } + + .tasks-board-content-section-continuous::after { + display: block; + height: 3.9375rem; + content: ""; + } + +} diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index cd04479f..c7325708 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -584,7 +584,9 @@ function MessageDialogPageContent() { if (!c?.containerRef?.current) return; const viewport = c.containerRef.current; - const onScroll = () => { + let frameId: number | null = null; + const processScrollPosition = () => { + frameId = null; if (!cm.loadingOlder && !cm.messagesLoading && (cm.hasMoreOlder || cm.allowOneOlderProbe) && viewport.scrollTop <= 120) { cm.loadOlderMessages(); } @@ -603,8 +605,17 @@ function MessageDialogPageContent() { } }; + const onScroll = () => { + if (frameId === null) { + frameId = window.requestAnimationFrame(processScrollPosition); + } + }; + viewport.addEventListener("scroll", onScroll, { passive: true }); - return () => viewport.removeEventListener("scroll", onScroll); + return () => { + viewport.removeEventListener("scroll", onScroll); + if (frameId !== null) window.cancelAnimationFrame(frameId); + }; }, [cm, markRead, scroll]); useEffect(() => { diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index fe204022..9a601bcd 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, @@ -67,7 +68,6 @@ import { useMemo, useRef, useState, - type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject, } from "react"; @@ -83,10 +83,12 @@ import { CommentComposer, CommentDeleteButton } from "@/components/shared/Commen import { Modal } from "@/components/ui"; import { useNotifications } from "@/contexts/NotificationsContext"; import { useUser } from "@/contexts/UserContext"; +import { useTaskBoardScroll } from "@/hooks/useTaskBoardScroll"; +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,20 +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, quickTaskComposer, }: { column: TaskColumn; @@ -918,20 +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; quickTaskComposer?: ReactNode; }) { const { @@ -944,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)]"; @@ -973,26 +1002,25 @@ 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()} @@ -1099,41 +1125,233 @@ 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, @@ -1149,6 +1367,8 @@ function BoardColumnGroup({ onColumnMount, contentVisible, onToggleContentVisibility, + subcolumnHeaders, + subcolumnGridTemplate, hideOnMobile = false, }: { column: TaskColumn; @@ -1165,6 +1385,8 @@ function BoardColumnGroup({ onColumnMount: (columnId: number, node: HTMLElement | null) => void; contentVisible: boolean; onToggleContentVisibility: () => void; + subcolumnHeaders: ReactNode; + subcolumnGridTemplate: string; hideOnMobile?: boolean; }) { const { @@ -1192,16 +1414,17 @@ function BoardColumnGroup({ transition, zIndex: isDragging ? 20 : undefined, }} - className={`${hideOnMobile ? "hidden xl:flex" : "flex"} min-w-0 flex-col ${isDragging ? "opacity-70" : ""}`} + className={`${hideOnMobile ? "hidden xl:flex" : "flex"} tasks-board-column-group min-w-0 flex-col ${isDragging ? "opacity-70" : ""}`} > -
-
+
+
+
) : null}
+
+ {contentVisible ? ( +
+
+ {subcolumnHeaders} +
+
+ ) : null}
{contentVisible ? ( -
+
{children}
) : null} @@ -1320,6 +1554,7 @@ function BoardColumnRowView({ onDelete, expanded, onToggle, + hasFollowingSection, }: { row: TaskColumnRow; children: ReactNode; @@ -1329,10 +1564,13 @@ function BoardColumnRowView({ onDelete: () => void; expanded: boolean; onToggle: () => void; + hasFollowingSection: boolean; }) { return ( -
-
+
+
- {user?.auth?.is_staff || user?.auth?.is_superuser ? ( - - ) : null} + {/* TODO(tasks-automation-permissions): Gate this action by the + dedicated automation-management capability once introduced. */} + - {user?.auth?.is_staff || user?.auth?.is_superuser ? ( - - ) : null} +
) : null}
diff --git a/frontend/src/components/requests/SwipeApprovalMode.tsx b/frontend/src/components/requests/SwipeApprovalMode.tsx index 291029e2..54419e3c 100644 --- a/frontend/src/components/requests/SwipeApprovalMode.tsx +++ b/frontend/src/components/requests/SwipeApprovalMode.tsx @@ -54,6 +54,8 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo const [dragY, setDragY] = useState(0); const [dragging, setDragging] = useState(false); const startPos = useRef({ x: 0, y: 0 }); + const pendingDrag = useRef({ x: 0, y: 0 }); + const dragFrame = useRef(null); const cardRef = useRef(null); const [flyOut, setFlyOut] = useState<"left" | "right" | "up" | null>(null); @@ -64,6 +66,18 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo const current = queue[0] || null; + const applyPendingDrag = useCallback(() => { + dragFrame.current = null; + setDragX(pendingDrag.current.x); + setDragY(pendingDrag.current.y); + }, []); + + useEffect(() => () => { + if (dragFrame.current !== null) { + window.cancelAnimationFrame(dragFrame.current); + } + }, []); + /* ── perform action ── */ const performAction = useCallback( @@ -89,6 +103,7 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo // error is handled by parent (actionError state) } finally { setBusy(false); + pendingDrag.current = { x: 0, y: 0 }; setDragX(0); setDragY(0); setFlyOut(null); @@ -118,6 +133,7 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo (e: React.PointerEvent) => { if (busy || detailOpen) return; startPos.current = { x: e.clientX, y: e.clientY }; + pendingDrag.current = { x: 0, y: 0 }; setDragging(true); (e.target as HTMLElement).setPointerCapture?.(e.pointerId); }, @@ -127,30 +143,45 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo const onPointerMove = useCallback( (e: React.PointerEvent) => { if (!dragging) return; - setDragX(e.clientX - startPos.current.x); - setDragY(e.clientY - startPos.current.y); + pendingDrag.current = { + x: e.clientX - startPos.current.x, + y: e.clientY - startPos.current.y, + }; + if (dragFrame.current === null) { + dragFrame.current = window.requestAnimationFrame(applyPendingDrag); + } }, - [dragging] + [applyPendingDrag, dragging] ); - const onPointerUp = useCallback(() => { + const onPointerUp = useCallback((e: React.PointerEvent) => { if (!dragging) return; + const finalDrag = { + x: e.clientX - startPos.current.x, + y: e.clientY - startPos.current.y, + }; + pendingDrag.current = finalDrag; + if (dragFrame.current !== null) { + window.cancelAnimationFrame(dragFrame.current); + dragFrame.current = null; + } setDragging(false); - if (dragX > SWIPE_THRESHOLD) { + if (finalDrag.x > SWIPE_THRESHOLD) { setFlyOut("right"); setTimeout(() => performAction("approved"), 250); - } else if (dragX < -SWIPE_THRESHOLD) { + } else if (finalDrag.x < -SWIPE_THRESHOLD) { setFlyOut("left"); setTimeout(() => performAction("rejected"), 250); - } else if (dragY < -SWIPE_THRESHOLD) { + } else if (finalDrag.y < -SWIPE_THRESHOLD) { setFlyOut("up"); setTimeout(() => performAction("skipped"), 250); } else { + pendingDrag.current = { x: 0, y: 0 }; setDragX(0); setDragY(0); } - }, [dragging, dragX, dragY, performAction]); + }, [dragging, performAction]); /* ── keyboard ── */ useEffect(() => { diff --git a/frontend/src/components/shared/SearchableSelect.tsx b/frontend/src/components/shared/SearchableSelect.tsx index c73841ce..5a33bf03 100644 --- a/frontend/src/components/shared/SearchableSelect.tsx +++ b/frontend/src/components/shared/SearchableSelect.tsx @@ -1,8 +1,8 @@ "use client"; import { ChevronDown } from "lucide-react"; -import type { ReactNode } from "react"; -import { useEffect, useRef, useState } from "react"; +import type { ReactNode, RefObject } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; type DropdownPosition = { @@ -31,6 +31,56 @@ function getDropdownPosition(anchor: HTMLElement): DropdownPosition { }; } +function dropdownPositionsEqual( + current: DropdownPosition | null, + next: DropdownPosition, +) { + return current?.left === next.left + && current.maxHeight === next.maxHeight + && current.top === next.top + && current.width === next.width; +} + +function useDropdownPosition( + open: boolean, + anchorRef: RefObject, +) { + const [position, setPosition] = useState(null); + + const updatePosition = useCallback(() => { + const anchor = anchorRef.current; + if (!anchor) return; + const nextPosition = getDropdownPosition(anchor); + setPosition((currentPosition) => ( + dropdownPositionsEqual(currentPosition, nextPosition) ? currentPosition : nextPosition + )); + }, [anchorRef]); + + useEffect(() => { + if (!open) return undefined; + + let frameId: number | null = null; + const schedulePositionUpdate = () => { + if (frameId !== null) return; + frameId = window.requestAnimationFrame(() => { + frameId = null; + updatePosition(); + }); + }; + + updatePosition(); + window.addEventListener("resize", schedulePositionUpdate); + window.addEventListener("scroll", schedulePositionUpdate, true); + return () => { + window.removeEventListener("resize", schedulePositionUpdate); + window.removeEventListener("scroll", schedulePositionUpdate, true); + if (frameId !== null) window.cancelAnimationFrame(frameId); + }; + }, [open, updatePosition]); + + return { position, updatePosition }; +} + /* ── Single-select dropdown with search ── */ export function SearchableSelectSingle({ label, @@ -49,9 +99,12 @@ export function SearchableSelectSingle({ }) { const [open, setOpen] = useState(false); const [q, setQ] = useState(""); - const [dropdownPosition, setDropdownPosition] = useState(null); const ref = useRef(null); const menuRef = useRef(null); + const { + position: dropdownPosition, + updatePosition: updateDropdownPosition, + } = useDropdownPosition(open, ref); useEffect(() => { const handler = (e: MouseEvent) => { @@ -63,29 +116,10 @@ export function SearchableSelectSingle({ return () => document.removeEventListener("mousedown", handler); }, []); - useEffect(() => { - if (!open) return; - - const updatePosition = () => { - if (ref.current) { - setDropdownPosition(getDropdownPosition(ref.current)); - } - }; - - window.addEventListener("resize", updatePosition); - window.addEventListener("scroll", updatePosition, true); - return () => { - window.removeEventListener("resize", updatePosition); - window.removeEventListener("scroll", updatePosition, true); - }; - }, [open]); - const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase())); const selectedName = items.find((i) => i.id === selectedId)?.name; const toggleOpen = () => { - if (!open && ref.current) { - setDropdownPosition(getDropdownPosition(ref.current)); - } + if (!open) updateDropdownPosition(); setOpen((value) => !value); }; @@ -180,9 +214,12 @@ export function SearchableSelectMulti({ }) { const [open, setOpen] = useState(false); const [q, setQ] = useState(""); - const [dropdownPosition, setDropdownPosition] = useState(null); const ref = useRef(null); const menuRef = useRef(null); + const { + position: dropdownPosition, + updatePosition: updateDropdownPosition, + } = useDropdownPosition(open, ref); useEffect(() => { const handler = (e: MouseEvent) => { @@ -194,31 +231,12 @@ export function SearchableSelectMulti({ return () => document.removeEventListener("mousedown", handler); }, []); - useEffect(() => { - if (!open) return; - - const updatePosition = () => { - if (ref.current) { - setDropdownPosition(getDropdownPosition(ref.current)); - } - }; - - window.addEventListener("resize", updatePosition); - window.addEventListener("scroll", updatePosition, true); - return () => { - window.removeEventListener("resize", updatePosition); - window.removeEventListener("scroll", updatePosition, true); - }; - }, [open]); - const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase())); const selectedItems = items.filter((i) => selectedIds.includes(i.id)); const selectedNames = selectedItems.map((i) => i.name); const isInline = layout === "inline"; const toggleOpen = () => { - if (!open && ref.current) { - setDropdownPosition(getDropdownPosition(ref.current)); - } + if (!open) updateDropdownPosition(); setOpen((value) => !value); }; diff --git a/frontend/src/components/tasks/TaskCoverPreview.tsx b/frontend/src/components/tasks/TaskCoverPreview.tsx index e2330d31..f4731e33 100644 --- a/frontend/src/components/tasks/TaskCoverPreview.tsx +++ b/frontend/src/components/tasks/TaskCoverPreview.tsx @@ -133,7 +133,8 @@ export default function TaskCoverPreview({ if (cover.kind === "checklist" && cover.checklist) { const { completed, items, total } = cover.checklist; - const progress = total > 0 ? Math.round((completed / total) * 100) : 0; + if (total <= 0 || items.length === 0) return null; + const progress = Math.round((completed / total) * 100); return (
diff --git a/frontend/src/hooks/useChatScroll.ts b/frontend/src/hooks/useChatScroll.ts index ea875a65..ceed460c 100644 --- a/frontend/src/hooks/useChatScroll.ts +++ b/frontend/src/hooks/useChatScroll.ts @@ -76,13 +76,6 @@ export function useChatScroll({ const floatingDateTimeoutRef = useRef(null); - // Sync selected date from floating date when navigator is closed - useEffect(() => { - if (floatingDateValue && !isDateNavigatorOpen) { - setSelectedHistoryDate(floatingDateValue); - } - }, [floatingDateValue, isDateNavigatorOpen]); - // Cleanup useEffect(() => () => { if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current); @@ -144,6 +137,9 @@ export function useChatScroll({ setFloatingDate(topDate); setFloatingDateValue(topDateVal); setShowFloatingDate(true); + if (topDateVal && !isDateNavigatorOpen) { + setSelectedHistoryDate(topDateVal); + } if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current); floatingDateTimeoutRef.current = setTimeout(() => setShowFloatingDate(false), 1100); @@ -153,7 +149,7 @@ export function useChatScroll({ setFloatingDate(null); setFloatingDateValue(null); setShowFloatingDate(false); - }, []); + }, [isDateNavigatorOpen]); // Expose updateFloatingDate as a stable ref so the scroll handler can call it const updateFloatingDateRef = useRef(updateFloatingDate); @@ -166,13 +162,22 @@ export function useChatScroll({ if (!c?.containerRef?.current) return; const viewport = c.containerRef.current; - const onScroll = () => updateFloatingDateRef.current(viewport); - const frameId = window.requestAnimationFrame(onScroll); + let frameId: number | null = null; + const updateFromScrollPosition = () => { + frameId = null; + updateFloatingDateRef.current(viewport); + }; + const scheduleUpdate = () => { + if (frameId === null) { + frameId = window.requestAnimationFrame(updateFromScrollPosition); + } + }; - viewport.addEventListener("scroll", onScroll, { passive: true }); + scheduleUpdate(); + viewport.addEventListener("scroll", scheduleUpdate, { passive: true }); return () => { - window.cancelAnimationFrame(frameId); - viewport.removeEventListener("scroll", onScroll); + if (frameId !== null) window.cancelAnimationFrame(frameId); + viewport.removeEventListener("scroll", scheduleUpdate); }; }, [chatId, messageCount, viewportRef]); 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/hooks/useTaskBoardScroll.ts b/frontend/src/hooks/useTaskBoardScroll.ts new file mode 100644 index 00000000..c28bba95 --- /dev/null +++ b/frontend/src/hooks/useTaskBoardScroll.ts @@ -0,0 +1,184 @@ +"use client"; + +import { + useCallback, + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react"; + +import type { TaskBoard } from "@/types/api"; + +type BoardPanState = { + pointerId: number; + startX: number; + startY: number; + currentX: number; + currentY: number; + scrollLeft: number; + scrollTop: number; + frameId: number | null; +}; + +type UseTaskBoardScrollOptions = { + board: TaskBoard | null; + desktopWideMode: boolean; + loading: boolean; +}; + +export function useTaskBoardScroll({ + board, + desktopWideMode, + loading, +}: UseTaskBoardScrollOptions) { + const boardScrollRef = useRef(null); + const boardPanRef = useRef(null); + const [viewportHeight, setViewportHeight] = useState(0); + + const applyBoardPan = useCallback(() => { + const pan = boardPanRef.current; + const container = boardScrollRef.current; + if (!pan || !container) return; + + pan.frameId = null; + container.scrollLeft = pan.scrollLeft - (pan.currentX - pan.startX); + container.scrollTop = pan.scrollTop - (pan.currentY - pan.startY); + }, []); + + const startBoardPan = useCallback((event: ReactPointerEvent) => { + if (event.button !== 1) return; + const container = boardScrollRef.current; + if (!container) return; + + event.preventDefault(); + event.stopPropagation(); + boardPanRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + currentX: event.clientX, + currentY: event.clientY, + scrollLeft: container.scrollLeft, + scrollTop: container.scrollTop, + frameId: null, + }; + container.setPointerCapture(event.pointerId); + container.classList.add("cursor-grabbing", "select-none"); + }, []); + + const moveBoardPan = useCallback((event: ReactPointerEvent) => { + const pan = boardPanRef.current; + if (!pan || pan.pointerId !== event.pointerId) return; + + event.preventDefault(); + event.stopPropagation(); + pan.currentX = event.clientX; + pan.currentY = event.clientY; + if (pan.frameId === null) { + pan.frameId = window.requestAnimationFrame(applyBoardPan); + } + }, [applyBoardPan]); + + const finishBoardPan = useCallback((event: ReactPointerEvent) => { + const pan = boardPanRef.current; + const container = boardScrollRef.current; + if (!pan || pan.pointerId !== event.pointerId) return; + + event.preventDefault(); + event.stopPropagation(); + pan.currentX = event.clientX; + pan.currentY = event.clientY; + if (pan.frameId !== null) { + window.cancelAnimationFrame(pan.frameId); + pan.frameId = null; + } + applyBoardPan(); + boardPanRef.current = null; + container?.classList.remove("cursor-grabbing", "select-none"); + if (container?.hasPointerCapture(event.pointerId)) { + container.releasePointerCapture(event.pointerId); + } + }, [applyBoardPan]); + + useEffect(() => { + const container = boardScrollRef.current; + if (!container || loading) return undefined; + + let scrollFrameId: number | null = null; + + const updateViewportHeight = () => { + const nextHeight = container.clientHeight; + setViewportHeight((currentHeight) => ( + currentHeight === nextHeight ? currentHeight : nextHeight + )); + }; + + const updateContentBottomState = () => { + scrollFrameId = null; + const updates = Array.from( + container.querySelectorAll(".tasks-board-column-group"), + (group) => { + const content = group.querySelector(".tasks-board-column-content"); + const stickyHeader = group.querySelector(".tasks-board-group-header-sticky"); + const reached = content && stickyHeader + ? content.getBoundingClientRect().bottom <= stickyHeader.getBoundingClientRect().bottom + 1 + : false; + return { group, value: reached ? "true" : "false" }; + }, + ); + + updates.forEach(({ group, value }) => { + if (group.dataset.contentBottomReached !== value) { + group.dataset.contentBottomReached = value; + } + }); + }; + + const scheduleContentBottomUpdate = () => { + if (scrollFrameId === null) { + scrollFrameId = window.requestAnimationFrame(updateContentBottomState); + } + }; + + const handleResize = () => { + updateViewportHeight(); + scheduleContentBottomUpdate(); + }; + + updateViewportHeight(); + updateContentBottomState(); + container.addEventListener("scroll", scheduleContentBottomUpdate, { passive: true }); + + const observer = new ResizeObserver(handleResize); + observer.observe(container); + container.querySelectorAll(".tasks-board-column-content").forEach((content) => { + observer.observe(content); + }); + + return () => { + container.removeEventListener("scroll", scheduleContentBottomUpdate); + observer.disconnect(); + if (scrollFrameId !== null) { + window.cancelAnimationFrame(scrollFrameId); + } + }; + }, [board, desktopWideMode, loading]); + + useEffect(() => () => { + const pan = boardPanRef.current; + if (pan?.frameId !== null && pan?.frameId !== undefined) { + window.cancelAnimationFrame(pan.frameId); + } + boardScrollRef.current?.classList.remove("cursor-grabbing", "select-none"); + boardPanRef.current = null; + }, []); + + return { + boardScrollRef, + finishBoardPan, + moveBoardPan, + startBoardPan, + viewportHeight, + }; +} 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, + }; +}