"use client"; import { useCallback, useDeferredValue, useEffect, useRef, useState, type MutableRefObject } from "react"; import { trpc } from "~/lib/trpc/client.js"; import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js"; import { pixelsToDays } from "~/components/timeline/dragMath.js"; import { clearLivePreview, preserveLivePreview, scheduleLivePreview, type LivePreviewSession, } from "./timelineLivePreview.js"; import { finalizeAllocationMultiDrag, isAllocationMultiSelected, startAllocationMultiDrag, updateAllocationMultiDrag, } from "./timelineAllocationMultiDrag.js"; import { beginAllocationMultiDragSession } from "./timelineAllocationMultiDragSession.js"; import { createAllocationDragState } from "./timelineAllocationDragState.js"; import { beginAllocationDragSession } from "./timelineAllocationDragSession.js"; import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEffects.js"; import { cancelTransientMultiSelectState, cleanupTimelineDragState } from "./timelineDragCleanup.js"; import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js"; import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js"; import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js"; import { createProjectDragState } from "./timelineProjectDrag.js"; import { beginProjectDragSession } from "./timelineProjectDragSession.js"; import { forwardCanvasTouchEnd, forwardCanvasTouchMove, forwardTouchStartAsMouseDown, } from "./timelineTouchEvents.js"; import { completeMultiSelectDraft, createMultiSelectState, updateMultiSelectDraft, } from "./timelineMultiSelect.js"; import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js"; import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js"; import { createAllocationPreviewSession, createProjectPreviewSession } from "./timelinePreviewSession.js"; import { resolveRangeSelectionCancel, resolveRangeSelectionRelease } from "./timelineRangeRelease.js"; import { createRangeSelectionState, updateRangeSelectionDraft } from "./timelineRangeSelection.js"; import { type TouchCanvasPointerEvent, type TouchMouseDownEvent } from "./timelineTouchAdapters.js"; const DRAG_CLICK_THRESHOLD_PX = 5; // ─── Project-shift drag state ─────────────────────────────────────────────── export interface DragState { isDragging: boolean; projectId: string | null; projectName: string | null; allocationId: string | null; originalStartDate: Date | null; originalEndDate: Date | null; currentStartDate: Date | null; currentEndDate: Date | null; startMouseX: number; pointerDeltaX: number; originalLeft: number; blockWidth: number; daysDelta: number; } export interface BlockClickInfo { allocationId: string; projectId: string; projectName: string; startDate: Date; endDate: Date; } export interface ShiftPreviewData { valid: boolean; deltaCents: number; wouldExceedBudget: boolean; budgetUtilizationAfter: number; conflictCount: number; errors: string[]; warnings: string[]; } const INITIAL_DRAG_STATE: DragState = { isDragging: false, projectId: null, projectName: null, allocationId: null, originalStartDate: null, originalEndDate: null, currentStartDate: null, currentEndDate: null, startMouseX: 0, pointerDeltaX: 0, originalLeft: 0, blockWidth: 0, daysDelta: 0, }; // ─── Per-allocation drag state ────────────────────────────────────────────── export type AllocDragMode = "move" | "resize-start" | "resize-end"; export type AllocDragScope = "allocation" | "segment"; export interface AllocDragState { isActive: boolean; mode: AllocDragMode; scope: AllocDragScope; allocationId: string | null; mutationAllocationId: string | null; projectId: string | null; projectName: string | null; resourceId: string | null; allocationStartDate: Date | null; allocationEndDate: Date | null; originalStartDate: Date | null; originalEndDate: Date | null; currentStartDate: Date | null; currentEndDate: Date | null; startMouseX: number; pointerDeltaX: number; daysDelta: number; } const INITIAL_ALLOC_DRAG: AllocDragState = { isActive: false, mode: "move", scope: "allocation", allocationId: null, mutationAllocationId: null, projectId: null, projectName: null, resourceId: null, allocationStartDate: null, allocationEndDate: null, originalStartDate: null, originalEndDate: null, currentStartDate: null, currentEndDate: null, startMouseX: 0, pointerDeltaX: 0, daysDelta: 0, }; // ─── Range-select state ───────────────────────────────────────────────────── export interface RangeState { isSelecting: boolean; resourceId: string | null; startDate: Date | null; currentDate: Date | null; suggestedProjectId: string | null; startClientX: number; } export interface RangeSelectedInfo { resourceId: string; startDate: Date; endDate: Date; suggestedProjectId: string | null; anchorX: number; anchorY: number; } const INITIAL_RANGE_STATE: RangeState = { isSelecting: false, resourceId: null, startDate: null, currentDate: null, suggestedProjectId: null, startClientX: 0, }; // ─── Multi-select state ──────────────────────────────────────────────────── export interface MultiSelectState { isSelecting: boolean; startX: number; startY: number; currentX: number; currentY: number; selectedAllocationIds: string[]; selectedResourceIds: string[]; dateRange: { start: Date; end: Date } | null; /** When multi-dragging, the number of days all selected allocations are shifted */ multiDragDaysDelta: number; /** Whether a multi-drag is currently in progress */ isMultiDragging: boolean; /** The drag mode during multi-drag (move, resize-start, resize-end) */ multiDragMode: AllocDragMode; } const INITIAL_MULTI_SELECT: MultiSelectState = { isSelecting: false, startX: 0, startY: 0, currentX: 0, currentY: 0, selectedAllocationIds: [], selectedResourceIds: [], dateRange: null, multiDragDaysDelta: 0, isMultiDragging: false, multiDragMode: "move", }; // ─── Hook ─────────────────────────────────────────────────────────────────── export interface AllocationMovedSnapshot { allocationId: string; mutationAllocationId: string; projectName: string; before: { startDate: Date; endDate: Date }; after: { startDate: Date; endDate: Date }; } export interface OptimisticTimelineEntry { id: string; startDate: Date | string; endDate: Date | string; } export interface OptimisticTimelineOverride { startDate: Date; endDate: Date; } export function useTimelineDrag({ cellWidthRef, onShiftApplied, onBlockClick, onRangeSelected, onAllocationMoved, onShiftClickAlloc, onMultiDragComplete, onMutationError, }: { cellWidthRef: MutableRefObject; onShiftApplied?: (projectId: string) => void; onBlockClick?: (info: BlockClickInfo) => void; onRangeSelected?: (info: RangeSelectedInfo) => void; onAllocationMoved?: (snapshot: AllocationMovedSnapshot) => void; onShiftClickAlloc?: (allocationId: string) => void; onMultiDragComplete?: (daysDelta: number, mode: AllocDragMode, selectedIds?: string[]) => void; onMutationError?: (message: string) => void; }) { const [dragState, setDragState] = useState(INITIAL_DRAG_STATE); const [allocDragState, setAllocDragState] = useState(INITIAL_ALLOC_DRAG); const [rangeState, setRangeState] = useState(INITIAL_RANGE_STATE); const [multiSelectState, setMultiSelectState] = useState(INITIAL_MULTI_SELECT); const dragStateRef = useRef(INITIAL_DRAG_STATE); const allocDragRef = useRef(INITIAL_ALLOC_DRAG); const rangeStateRef = useRef(INITIAL_RANGE_STATE); const multiSelectRef = useRef(INITIAL_MULTI_SELECT); const projectPreviewRef = useRef(null); const allocPreviewRef = useRef(null); const projectDragCleanupRef = useRef<(() => void) | null>(null); const allocDragCleanupRef = useRef<(() => void) | null>(null); const multiSelectCleanupRef = useRef<(() => void) | null>(null); // Keep ref in sync with state so document-level handlers read the latest selection multiSelectRef.current = multiSelectState; // Touch disambiguation: track initial touch position to distinguish horizontal drag from vertical scroll const touchStartRef = useRef<{ x: number; y: number; decided: boolean }>({ x: 0, y: 0, decided: false, }); const onBlockClickRef = useRef(onBlockClick); onBlockClickRef.current = onBlockClick; const onAllocationMovedRef = useRef(onAllocationMoved); onAllocationMovedRef.current = onAllocationMoved; const onShiftClickAllocRef = useRef(onShiftClickAlloc); onShiftClickAllocRef.current = onShiftClickAlloc; const onMultiDragCompleteRef = useRef(onMultiDragComplete); onMultiDragCompleteRef.current = onMultiDragComplete; const onMutationErrorRef = useRef(onMutationError); onMutationErrorRef.current = onMutationError; const utils = trpc.useUtils(); const invalidateTimeline = useInvalidateTimeline(); const setProjectPreviewTargets = useCallback((projectId: string, currentTarget?: EventTarget | null) => { clearLivePreview(projectPreviewRef.current); projectPreviewRef.current = createProjectPreviewSession({ projectId, currentTarget, cellWidth: cellWidthRef.current, }); }, []); const setAllocationPreviewTarget = useCallback((currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => { clearLivePreview(allocPreviewRef.current); allocPreviewRef.current = createAllocationPreviewSession({ currentTarget, mode, cellWidth: cellWidthRef.current, }); }, []); const updateLivePreview = useCallback( (previewRef: MutableRefObject, pointerDeltaX: number, daysDelta: number) => { const preview = previewRef.current; if (!preview) return; preview.cellWidth = cellWidthRef.current; preview.pointerDeltaX = pointerDeltaX; preview.daysDelta = daysDelta; scheduleLivePreview(preview); }, [], ); const updateProjectDragPosition = useCallback( (clientX: number) => { const result = resolveProjectDragPosition(dragStateRef.current, clientX, cellWidthRef.current); if (!result.handled) return false; updateLivePreview(projectPreviewRef, result.pointerDeltaX, result.daysDelta); dragStateRef.current = result.nextState; if (result.shouldSyncState) { setDragState(result.nextState); } return true; }, [updateLivePreview], ); const updateAllocationDragPosition = useCallback( (clientX: number) => { const result = resolveAllocationDragPosition(allocDragRef.current, clientX, cellWidthRef.current); if (!result.handled) return false; updateLivePreview(allocPreviewRef, result.pointerDeltaX, result.daysDelta); allocDragRef.current = result.nextState; if (result.shouldSyncState) { setAllocDragState(result.nextState); } return true; }, [updateLivePreview], ); const clearProjectDragSession = useCallback(() => { projectDragCleanupRef.current?.(); projectDragCleanupRef.current = null; clearLivePreview(projectPreviewRef.current); projectPreviewRef.current = null; dragStateRef.current = INITIAL_DRAG_STATE; setDragState(INITIAL_DRAG_STATE); }, []); const cancelActiveInteractions = useCallback(() => { const hasActiveInteraction = dragStateRef.current.isDragging || allocDragRef.current.isActive || rangeStateRef.current.isSelecting || multiSelectRef.current.isSelecting || multiSelectRef.current.isMultiDragging; if (!hasActiveInteraction) return; const nextMultiSelectState = cancelTransientMultiSelectState( multiSelectRef.current, INITIAL_MULTI_SELECT, ); cleanupTimelineDragState({ projectDragCleanupRef, allocDragCleanupRef, multiSelectCleanupRef, projectPreviewRef, allocPreviewRef, dragStateRef, allocDragRef, rangeStateRef, multiSelectRef, initialDragState: INITIAL_DRAG_STATE, initialAllocDragState: INITIAL_ALLOC_DRAG, initialRangeState: INITIAL_RANGE_STATE, initialMultiSelectState: nextMultiSelectState, clearPreview: clearLivePreview, }); setDragState(INITIAL_DRAG_STATE); setAllocDragState(INITIAL_ALLOC_DRAG); setRangeState(INITIAL_RANGE_STATE); setMultiSelectState(nextMultiSelectState); }, []); // Defer daysDelta to avoid firing the preview query every pixel during drag const deferredDaysDelta = useDeferredValue(dragState.daysDelta); // Project-shift preview const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery( { projectId: dragState.projectId ?? "", newStartDate: dragState.currentStartDate ?? new Date(), newEndDate: dragState.currentEndDate ?? new Date(), }, { enabled: dragState.isDragging && dragState.projectId !== null && deferredDaysDelta !== 0 && dragState.currentStartDate !== null, staleTime: 0, }, ); // eslint-disable-next-line @typescript-eslint/no-explicit-any const applyShiftMutation = (trpc.timeline.applyShift.useMutation as any)({ onSuccess: (data: { project: { id: string } }) => { invalidateTimeline(); void utils.project.list.invalidate(); onShiftApplied?.(data.project.id); }, }) as { isPending: boolean; mutate: (...args: unknown[]) => void; mutateAsync: (...args: unknown[]) => Promise; }; const finalizeActiveProjectDrag = useCallback( (clientX: number, mode: "mutate" | "mutateAsync" = "mutate") => finalizeProjectDrag({ clientX, mode, dragRef: dragStateRef, previewRef: projectPreviewRef, updatePosition: updateProjectDragPosition, clearSession: clearProjectDragSession, mutate: applyShiftMutation.mutate, mutateAsync: applyShiftMutation.mutateAsync, }), [applyShiftMutation, clearProjectDragSession, updateProjectDragPosition], ); const pendingSnapshotRef = useRef(null); const pendingOptimisticAllocationIdRef = useRef(null); const [optimisticAllocations, setOptimisticAllocations] = useState>( () => new Map(), ); const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({ onSuccess: () => { invalidateTimeline(); const snap = pendingSnapshotRef.current; if (snap) { onAllocationMovedRef.current?.(snap); pendingSnapshotRef.current = null; } }, onError: (error) => { console.error("[timeline] updateAllocationInline failed:", error); const message = (error as { message?: string }).message ?? "Zuweisung konnte nicht verschoben werden."; onMutationErrorRef.current?.(message); clearPendingOptimisticAllocation(); }, }); const extractAllocFragmentMutation = trpc.timeline.extractAllocationFragment.useMutation({ onSuccess: () => { invalidateTimeline(); }, }); const clearPendingOptimisticAllocation = useCallback((allocationId?: string | null) => { pendingSnapshotRef.current = null; const optimisticAllocationId = allocationId ?? pendingOptimisticAllocationIdRef.current; if (!optimisticAllocationId) { pendingOptimisticAllocationIdRef.current = null; return; } setOptimisticAllocations((prev) => { if (!prev.has(optimisticAllocationId)) return prev; const next = new Map(prev); next.delete(optimisticAllocationId); return next; }); pendingOptimisticAllocationIdRef.current = null; }, []); const reconcileOptimisticAllocations = useCallback((entries: readonly OptimisticTimelineEntry[]) => { setOptimisticAllocations((prev) => { const result = reconcileOptimisticEntries(prev, entries, pendingOptimisticAllocationIdRef.current); pendingOptimisticAllocationIdRef.current = result.pendingOptimisticAllocationId; return result.changed ? result.optimisticAllocations : prev; }); }, []); // ── Project-bar drag (shifts all allocations) ────────────────────────────── const onProjectBarMouseDown = useCallback( ( e: TouchMouseDownEvent, opts: { projectId: string; projectName: string; startDate: Date; endDate: Date; }, ) => { if (e.button !== 0) return; multiSelectRef.current = INITIAL_MULTI_SELECT; setMultiSelectState(INITIAL_MULTI_SELECT); e.preventDefault(); e.stopPropagation(); const state = createProjectDragState({ projectId: opts.projectId, projectName: opts.projectName, startDate: opts.startDate, endDate: opts.endDate, startMouseX: e.clientX, }); setProjectPreviewTargets(opts.projectId, e.currentTarget); beginProjectDragSession({ state, cleanupRef: projectDragCleanupRef, stateRef: dragStateRef, setState: setDragState, documentTarget: document, attachDrag: attachDocumentMouseDrag, updatePosition: updateProjectDragPosition, finalize: (clientX) => { void finalizeActiveProjectDrag(clientX); }, }); }, [finalizeActiveProjectDrag, setProjectPreviewTargets, updateProjectDragPosition], ); // Legacy — kept for backward compat (triggers project shift from allocation block) const onBlockMouseDown = useCallback( ( e: TouchMouseDownEvent, opts: { projectId: string; projectName: string; allocationId?: string; startDate: Date; endDate: Date; blockLeft: number; blockWidth: number; }, ) => { if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); const state = createProjectDragState({ projectId: opts.projectId, projectName: opts.projectName, allocationId: opts.allocationId ?? null, startDate: opts.startDate, endDate: opts.endDate, startMouseX: e.clientX, originalLeft: opts.blockLeft, blockWidth: opts.blockWidth, }); dragStateRef.current = state; setDragState(state); }, [], ); // ── Per-allocation drag — document-level listeners ───────────────────────── // // Uses document.addEventListener instead of React canvas events so the drag // works reliably even when the cursor leaves the canvas boundary (e.g. while // moving quickly or scrolling into the sticky header area). const onAllocMouseDown = useCallback( ( e: TouchMouseDownEvent, opts: { mode: AllocDragMode; allocationId: string; mutationAllocationId?: string; projectId: string; projectName: string; resourceId: string | null; startDate: Date; endDate: Date; allocationStartDate?: Date; allocationEndDate?: Date; scope?: AllocDragScope; }, ) => { if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); const wasShift = e.shiftKey; // Check if this allocation is part of a multi-selection → multi-drag mode const ms = multiSelectRef.current; const isMultiSelected = isAllocationMultiSelected(ms, opts.allocationId); if (isMultiSelected) { beginAllocationMultiDragSession({ startMouseX: e.clientX, dragMode: opts.mode, documentTarget: document, cleanupRef: multiSelectCleanupRef, stateRef: multiSelectRef, setState: setMultiSelectState, startState: startAllocationMultiDrag, updateState: updateAllocationMultiDrag, finalizeState: finalizeAllocationMultiDrag, toDaysDelta: (deltaX) => pixelsToDays(deltaX, cellWidthRef.current), onComplete: (daysDelta, finalized) => { onMultiDragCompleteRef.current?.(daysDelta, opts.mode, finalized.selectedAllocationIds); }, attachDrag: attachDocumentMouseDrag, }); return; } // ── Single allocation drag ──────────────────────────────────────────── multiSelectRef.current = INITIAL_MULTI_SELECT; setMultiSelectState(INITIAL_MULTI_SELECT); const initial = createAllocationDragState({ mode: opts.mode, scope: opts.scope, allocationId: opts.allocationId, mutationAllocationId: opts.mutationAllocationId, projectId: opts.projectId, projectName: opts.projectName, resourceId: opts.resourceId, allocationStartDate: opts.allocationStartDate, allocationEndDate: opts.allocationEndDate, startDate: opts.startDate, endDate: opts.endDate, startMouseX: e.clientX, }); setAllocationPreviewTarget(e.currentTarget, opts.mode); beginAllocationDragSession({ state: initial, cleanupRef: allocDragCleanupRef, stateRef: allocDragRef, setState: setAllocDragState, documentTarget: document, attachDrag: attachDocumentMouseDrag, updatePosition: updateAllocationDragPosition, finalize: (clientX) => { void finalizeAllocationReleaseEffects({ clientX, allocRef: allocDragRef, previewRef: allocPreviewRef, updatePosition: updateAllocationDragPosition, clickThresholdPx: DRAG_CLICK_THRESHOLD_PX, wasShift, onShiftClick: onShiftClickAllocRef.current, onBlockClick: onBlockClickRef.current, pendingSnapshotRef, pendingOptimisticAllocationIdRef, setOptimisticAllocations, extractAllocationFragment: extractAllocFragmentMutation.mutateAsync, updateAllocation: updateAllocMutation.mutate, clearPendingOptimisticAllocation, onError: (msg) => onMutationErrorRef.current?.(msg), }); allocDragRef.current = INITIAL_ALLOC_DRAG; setAllocDragState(INITIAL_ALLOC_DRAG); }, }); }, [ clearPendingOptimisticAllocation, extractAllocFragmentMutation, setAllocationPreviewTarget, updateAllocationDragPosition, updateAllocMutation, ], ); // ── Range-select ──────────────────────────────────────────────────────────── const onRowMouseDown = useCallback( ( e: TouchMouseDownEvent, opts: { resourceId: string; startDate: Date; suggestedProjectId?: string; }, ) => { if (dragStateRef.current.isDragging || allocDragRef.current.isActive) return; if (e.button !== 0) return; e.preventDefault(); const state = createRangeSelectionState( opts.resourceId, opts.startDate, e.clientX, opts.suggestedProjectId, ); rangeStateRef.current = state; setRangeState(state); }, [], ); // ── Canvas-level handlers (project shift + range select only) ────────────── const onCanvasMouseMove = useCallback( (e: TouchCanvasPointerEvent) => { if (updateProjectDragPosition(e.clientX)) { return; } // Range select const range = rangeStateRef.current; const updated = updateRangeSelectionDraft(range, e.clientX, cellWidthRef.current); if (updated) { rangeStateRef.current = updated; setRangeState(updated); } }, [updateProjectDragPosition], ); const onCanvasMouseUp = useCallback( async (e: TouchCanvasPointerEvent) => { // Project shift const drag = dragStateRef.current; if (drag.isDragging) { try { await finalizeActiveProjectDrag(e.clientX, "mutateAsync"); } catch { // Validation error — revert visually } return; } // Range select const release = resolveRangeSelectionRelease(rangeStateRef.current, e.clientX, e.clientY, INITIAL_RANGE_STATE); if (release.kind !== "complete") return; onRangeSelected?.(release.selection); rangeStateRef.current = release.nextState; setRangeState(release.nextState); }, [finalizeActiveProjectDrag, onRangeSelected], ); const onCanvasMouseLeave = useCallback(() => { // Only cancel project-shift and range-select on canvas leave. // Alloc drag is managed by document-level listeners and must NOT be cancelled here. const cancellation = resolveRangeSelectionCancel(rangeStateRef.current, INITIAL_RANGE_STATE); if (!cancellation.didReset) return; rangeStateRef.current = cancellation.nextState; setRangeState(cancellation.nextState); }, []); // ── Multi-select (right-click drag) ───────────────────────────────────────── const onCanvasRightMouseDown = useCallback((e: React.MouseEvent) => { if (e.button !== 2) return; e.preventDefault(); beginCanvasMultiSelectSession({ clientX: e.clientX, clientY: e.clientY, documentTarget: document, cleanupRef: multiSelectCleanupRef, stateRef: multiSelectRef, setState: setMultiSelectState, createInitialState: (clientX, clientY) => createMultiSelectState(clientX, clientY, { selectedAllocationIds: [], selectedResourceIds: [], dateRange: null, multiDragDaysDelta: 0, isMultiDragging: false, multiDragMode: "move", }), updateState: updateMultiSelectDraft, completeState: completeMultiSelectDraft, initialState: INITIAL_MULTI_SELECT, attachDrag: attachDocumentMouseDrag, }); }, []); const clearMultiSelect = useCallback(() => { multiSelectRef.current = INITIAL_MULTI_SELECT; setMultiSelectState(INITIAL_MULTI_SELECT); }, []); // ── Touch support ─────────────────────────────────────────────────────────── const onProjectBarTouchStart = useCallback( ( e: React.TouchEvent, opts: { projectId: string; projectName: string; startDate: Date; endDate: Date; }, ) => { forwardTouchStartAsMouseDown({ event: e, touchStartRef, decided: true, onMouseDown: onProjectBarMouseDown, opts, }); }, [onProjectBarMouseDown], ); const onAllocTouchStart = useCallback( ( e: React.TouchEvent, opts: { mode: AllocDragMode; allocationId: string; mutationAllocationId?: string; projectId: string; projectName: string; resourceId: string | null; startDate: Date; endDate: Date; allocationStartDate?: Date; allocationEndDate?: Date; scope?: AllocDragScope; }, ) => { forwardTouchStartAsMouseDown({ event: e, touchStartRef, decided: true, onMouseDown: onAllocMouseDown, opts, }); }, [onAllocMouseDown], ); const onRowTouchStart = useCallback( ( e: React.TouchEvent, opts: { resourceId: string; startDate: Date; suggestedProjectId?: string; }, ) => { forwardTouchStartAsMouseDown({ event: e, touchStartRef, decided: false, onMouseDown: onRowMouseDown, opts, }); }, [onRowMouseDown], ); const onCanvasTouchMove = useCallback( (e: React.TouchEvent) => { forwardCanvasTouchMove({ event: e, touchStartRef, onCanvasMouseMove, }); }, [onCanvasMouseMove], ); const onCanvasTouchEnd = useCallback( async (e: React.TouchEvent) => { await forwardCanvasTouchEnd({ event: e, onCanvasMouseUp }); }, [onCanvasMouseUp], ); useEffect(() => { function handleWindowBlur() { cancelActiveInteractions(); } function handleVisibilityChange() { if (document.visibilityState === "hidden") { cancelActiveInteractions(); } } window.addEventListener("blur", handleWindowBlur); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { window.removeEventListener("blur", handleWindowBlur); document.removeEventListener("visibilitychange", handleVisibilityChange); cleanupTimelineDragState({ projectDragCleanupRef, allocDragCleanupRef, multiSelectCleanupRef, projectPreviewRef, allocPreviewRef, dragStateRef, allocDragRef, rangeStateRef, multiSelectRef, initialDragState: INITIAL_DRAG_STATE, initialAllocDragState: INITIAL_ALLOC_DRAG, initialRangeState: INITIAL_RANGE_STATE, initialMultiSelectState: INITIAL_MULTI_SELECT, clearPreview: clearLivePreview, }); }; }, [cancelActiveInteractions]); // ── Derived ───────────────────────────────────────────────────────────────── const shiftPreview: ShiftPreviewData | null = dragState.isDragging && dragState.daysDelta !== 0 && previewData ? { valid: previewData.valid, deltaCents: previewData.costImpact.deltaCents, wouldExceedBudget: previewData.costImpact.wouldExceedBudget, budgetUtilizationAfter: previewData.costImpact.budgetUtilizationAfter, conflictCount: previewData.conflictDetails.length, errors: previewData.errors.map((e) => e.message), warnings: previewData.warnings.map((w) => w.message), } : null; return { dragState, allocDragState, rangeState, multiSelectState, setMultiSelectState, optimisticAllocations, reconcileOptimisticAllocations, shiftPreview, isPreviewLoading, isApplying: applyShiftMutation.isPending, isAllocSaving: updateAllocMutation.isPending, onProjectBarMouseDown, onBlockMouseDown, onAllocMouseDown, onRowMouseDown, onCanvasMouseMove, onCanvasMouseUp, onCanvasMouseLeave, onCanvasRightMouseDown, clearMultiSelect, // Touch equivalents onProjectBarTouchStart, onAllocTouchStart, onRowTouchStart, onCanvasTouchMove, onCanvasTouchEnd, }; }