Merge branch 'develop'

This commit is contained in:
lizerk 2026-08-06 20:22:46 +03:00
commit 9343115345
18 changed files with 1400 additions and 545 deletions

View File

@ -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

View File

@ -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(

View File

@ -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,
),
]

View File

@ -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")

View File

@ -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": "Комментарий на обложке"},

View File

@ -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: "";
}
}

View File

@ -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(() => {

File diff suppressed because it is too large Load Diff

View File

@ -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<number | null>(null);
const cardRef = useRef<HTMLDivElement>(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(() => {

View File

@ -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<HTMLDivElement | null>,
) {
const [position, setPosition] = useState<DropdownPosition | null>(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<T extends string | number>({
label,
@ -49,9 +99,12 @@ export function SearchableSelectSingle<T extends string | number>({
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
const ref = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const {
position: dropdownPosition,
updatePosition: updateDropdownPosition,
} = useDropdownPosition(open, ref);
useEffect(() => {
const handler = (e: MouseEvent) => {
@ -63,29 +116,10 @@ export function SearchableSelectSingle<T extends string | number>({
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<T extends { id: number; name: string }>({
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
const ref = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const {
position: dropdownPosition,
updatePosition: updateDropdownPosition,
} = useDropdownPosition(open, ref);
useEffect(() => {
const handler = (e: MouseEvent) => {
@ -194,31 +231,12 @@ export function SearchableSelectMulti<T extends { id: number; name: string }>({
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);
};

View File

@ -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 (
<div className={`app-surface-muted flex flex-col justify-center overflow-hidden p-2 ${className}`}>
<div className="flex items-center gap-2">

View File

@ -76,13 +76,6 @@ export function useChatScroll({
const floatingDateTimeoutRef = useRef<NodeJS.Timeout | null>(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]);

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,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<HTMLDivElement | null>(null);
const boardPanRef = useRef<BoardPanState | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLElement>(".tasks-board-column-group"),
(group) => {
const content = group.querySelector<HTMLElement>(".tasks-board-column-content");
const stickyHeader = group.querySelector<HTMLElement>(".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<HTMLElement>(".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,
};
}

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