"use client"; import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react"; import { trpc } from "~/lib/trpc/client.js"; import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js"; import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.js"; import { captureLivePreviewTargets, clearLivePreview, preserveLivePreview, scheduleLivePreview, type LivePreviewSession, } from "./timelineLivePreview.js"; import { buildAllocationMovedSnapshot } from "./timelineAllocationFinalize.js"; import { finalizeAllocationMultiDrag, isAllocationMultiSelected, startAllocationMultiDrag, updateAllocationMultiDrag, } from "./timelineAllocationMultiDrag.js"; import { resolveAllocationRelease } from "./timelineAllocationRelease.js"; import { createAllocationDragState } from "./timelineAllocationDragState.js"; import { cleanupTimelineDragState } from "./timelineDragCleanup.js"; import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js"; import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js"; import { completeMultiSelectDraft, createMultiSelectState, updateMultiSelectDraft, } from "./timelineMultiSelect.js"; import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js"; import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js"; import { createRangeSelectionState, finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js"; import { createTouchCanvasPointerEvent, createTouchMouseDownEvent, type TouchCanvasPointerEvent, type TouchMouseDownEvent, } from "./timelineTouchAdapters.js"; import { getTouchPoint, resolveTouchDragDecision, } from "./timelineTouch.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, }: { 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; }) { 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 utils = trpc.useUtils(); const invalidateTimeline = useInvalidateTimeline(); const setProjectPreviewTargets = useCallback((projectId: string, currentTarget?: EventTarget | null) => { clearLivePreview(projectPreviewRef.current); const projectTargets = captureLivePreviewTargets( document.querySelectorAll( `[data-timeline-drag-preview~="project-shift"][data-timeline-project-id="${projectId}"]`, ), ); if (projectTargets.length === 0 && currentTarget instanceof HTMLElement) { projectTargets.push(...captureLivePreviewTargets([currentTarget])); } projectPreviewRef.current = projectTargets.length > 0 ? { mode: "move", cellWidth: cellWidthRef.current, targets: projectTargets, pointerDeltaX: 0, daysDelta: 0, frame: null, } : null; }, []); const setAllocationPreviewTarget = useCallback((currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => { clearLivePreview(allocPreviewRef.current); const root = currentTarget instanceof HTMLElement ? currentTarget.closest('[data-timeline-drag-preview~="allocation"]') : null; const targets = root ? captureLivePreviewTargets([root]) : []; allocPreviewRef.current = targets.length > 0 ? { mode, cellWidth: cellWidthRef.current, targets, pointerDeltaX: 0, daysDelta: 0, frame: null, } : null; }, []); 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 drag = dragStateRef.current; if (!drag.isDragging || !drag.originalStartDate || !drag.originalEndDate) return false; const deltaX = clientX - drag.startMouseX; const daysDelta = pixelsToDays(deltaX, cellWidthRef.current); updateLivePreview(projectPreviewRef, deltaX, daysDelta); if (daysDelta === drag.daysDelta) { if (deltaX !== drag.pointerDeltaX) { dragStateRef.current = { ...drag, pointerDeltaX: deltaX }; } return true; } const { start: newStart, end: newEnd } = computeDragDates( "move", drag.originalStartDate, drag.originalEndDate, daysDelta, ); const updated: DragState = { ...drag, currentStartDate: newStart, currentEndDate: newEnd, pointerDeltaX: deltaX, daysDelta, }; dragStateRef.current = updated; setDragState(updated); return true; }, [updateLivePreview], ); const updateAllocationDragPosition = useCallback( (clientX: number) => { const alloc = allocDragRef.current; if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return false; const pointerDeltaX = clientX - alloc.startMouseX; const daysDelta = pixelsToDays(pointerDeltaX, cellWidthRef.current); updateLivePreview(allocPreviewRef, pointerDeltaX, daysDelta); if (daysDelta === alloc.daysDelta) { if (pointerDeltaX !== alloc.pointerDeltaX) { allocDragRef.current = { ...alloc, pointerDeltaX }; } return true; } const { start: newStart, end: newEnd } = computeDragDates( alloc.mode, alloc.originalStartDate, alloc.originalEndDate, daysDelta, ); const updated: AllocDragState = { ...alloc, currentStartDate: newStart, currentEndDate: newEnd, pointerDeltaX, daysDelta, }; allocDragRef.current = updated; setAllocDragState(updated); 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); }, []); // 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 && dragState.daysDelta !== 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 finalizeProjectDrag = useCallback( (clientX: number, mode: "mutate" | "mutateAsync" = "mutate") => { updateProjectDragPosition(clientX); const finalDrag = dragStateRef.current; if (!finalDrag.isDragging) return null; const mutationInput = buildProjectShiftMutationInput(finalDrag); if (finalDrag.daysDelta !== 0) { preserveLivePreview(projectPreviewRef.current); } clearProjectDragSession(); if (!mutationInput) return null; if (mode === "mutateAsync") { return applyShiftMutation.mutateAsync(mutationInput); } applyShiftMutation.mutate(mutationInput); return null; }, [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: () => { 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; e.preventDefault(); e.stopPropagation(); const state = createProjectDragState({ projectId: opts.projectId, projectName: opts.projectName, startDate: opts.startDate, endDate: opts.endDate, startMouseX: e.clientX, }); dragStateRef.current = state; setDragState(state); setProjectPreviewTargets(opts.projectId, e.currentTarget); projectDragCleanupRef.current?.(); function handleMove(ev: MouseEvent) { updateProjectDragPosition(ev.clientX); } function handleUp(ev: MouseEvent) { projectDragCleanupRef.current?.(); projectDragCleanupRef.current = null; void finalizeProjectDrag(ev.clientX); ev.preventDefault(); } projectDragCleanupRef.current = attachDocumentMouseDrag(document, handleMove, handleUp); }, [finalizeProjectDrag, 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) { // ── Multi-drag: move/resize all selected allocations together ── const startMouseX = e.clientX; const dragMode = opts.mode; const initialMultiDragState = startAllocationMultiDrag(ms, dragMode); setMultiSelectState(initialMultiDragState); multiSelectRef.current = initialMultiDragState; multiSelectCleanupRef.current?.(); function handleMultiMove(ev: MouseEvent) { const deltaX = ev.clientX - startMouseX; const daysDelta = pixelsToDays(deltaX, cellWidthRef.current); const updated = updateAllocationMultiDrag(multiSelectRef.current, daysDelta); if (!updated) return; setMultiSelectState(updated); multiSelectRef.current = updated; } function handleMultiUp(ev: MouseEvent) { multiSelectCleanupRef.current?.(); multiSelectCleanupRef.current = null; const finalDelta = pixelsToDays(ev.clientX - startMouseX, cellWidthRef.current); const finalized = finalizeAllocationMultiDrag(multiSelectRef.current); setMultiSelectState(finalized); multiSelectRef.current = finalized; if (finalDelta !== 0) { // Pass IDs from ref to avoid stale closure in the callback const ids = finalized.selectedAllocationIds; onMultiDragCompleteRef.current?.(finalDelta, dragMode, ids); } } multiSelectCleanupRef.current = attachDocumentMouseDrag(document, handleMultiMove, handleMultiUp); return; } // ── Single allocation drag ──────────────────────────────────────────── 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, }); allocDragRef.current = initial; setAllocDragState(initial); setAllocationPreviewTarget(e.currentTarget, opts.mode); allocDragCleanupRef.current?.(); // ── document handlers ──────────────────────────────────────────────── function handleMove(ev: MouseEvent) { updateAllocationDragPosition(ev.clientX); } function handleUp(ev: MouseEvent) { allocDragCleanupRef.current?.(); allocDragCleanupRef.current = null; updateAllocationDragPosition(ev.clientX); const alloc = allocDragRef.current; const release = resolveAllocationRelease(alloc, { clickThresholdPx: DRAG_CLICK_THRESHOLD_PX, wasShift, }); if (release.kind === "ignore") return; if (release.preservePreview) { preserveLivePreview(allocPreviewRef.current); } clearLivePreview(allocPreviewRef.current); allocPreviewRef.current = null; if (release.kind === "shift-click") { onShiftClickAllocRef.current?.(release.allocationId); } else if (release.kind === "click") { onBlockClickRef.current?.(release.clickInfo); } else if (release.kind === "mutation") { const { mutationPlan } = release; const { activeAllocationId, currentStartDate, currentEndDate, baseMutationAllocationId, requiresExtraction, pendingSnapshot, } = mutationPlan; pendingSnapshotRef.current = pendingSnapshot; pendingOptimisticAllocationIdRef.current = activeAllocationId; setOptimisticAllocations((prev) => { const next = new Map(prev); next.set(activeAllocationId, { startDate: currentStartDate, endDate: currentEndDate, }); return next; }); void (async () => { try { let mutationAllocationId = baseMutationAllocationId; if (requiresExtraction) { const extracted = await extractAllocFragmentMutation.mutateAsync({ allocationId: mutationAllocationId, startDate: alloc.originalStartDate!, endDate: alloc.originalEndDate!, }); mutationAllocationId = extracted.extractedAllocationId; } pendingSnapshotRef.current = pendingSnapshotRef.current ? { ...pendingSnapshotRef.current, mutationAllocationId, } : null; updateAllocMutation.mutate({ allocationId: mutationAllocationId, startDate: currentStartDate, endDate: currentEndDate, }); } catch { clearPendingOptimisticAllocation(activeAllocationId); } })(); } allocDragRef.current = INITIAL_ALLOC_DRAG; setAllocDragState(INITIAL_ALLOC_DRAG); } allocDragCleanupRef.current = attachDocumentMouseDrag(document, handleMove, handleUp); }, [ 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 finalizeProjectDrag(e.clientX, "mutateAsync"); } catch { // Validation error — revert visually } return; } // Range select const range = rangeStateRef.current; const selection = finalizeRangeSelection(range, e.clientX, e.clientY); if (selection) { onRangeSelected?.(selection); rangeStateRef.current = INITIAL_RANGE_STATE; setRangeState(INITIAL_RANGE_STATE); } }, [finalizeProjectDrag, 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. if (rangeStateRef.current.isSelecting) { rangeStateRef.current = INITIAL_RANGE_STATE; setRangeState(INITIAL_RANGE_STATE); } }, []); // ── 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; }, ) => { e.preventDefault(); const point = getTouchPoint(e); touchStartRef.current = { x: point.clientX, y: point.clientY, decided: true }; onProjectBarMouseDown(createTouchMouseDownEvent(point, e.currentTarget), 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; }, ) => { e.preventDefault(); const point = getTouchPoint(e); touchStartRef.current = { x: point.clientX, y: point.clientY, decided: true }; onAllocMouseDown(createTouchMouseDownEvent(point, e.currentTarget), opts); }, [onAllocMouseDown], ); const onRowTouchStart = useCallback( ( e: React.TouchEvent, opts: { resourceId: string; startDate: Date; suggestedProjectId?: string; }, ) => { e.preventDefault(); const point = getTouchPoint(e); touchStartRef.current = { x: point.clientX, y: point.clientY, decided: false }; onRowMouseDown(createTouchMouseDownEvent(point, e.currentTarget), opts); }, [onRowMouseDown], ); const onCanvasTouchMove = useCallback( (e: React.TouchEvent) => { const point = getTouchPoint(e); // Scroll vs drag disambiguation: once decided, stick with the decision const decision = resolveTouchDragDecision(touchStartRef.current, point); touchStartRef.current = decision.nextState; if (!decision.shouldHandleDrag) { return; } onCanvasMouseMove(createTouchCanvasPointerEvent(point)); }, [onCanvasMouseMove], ); const onCanvasTouchEnd = useCallback( async (e: React.TouchEvent) => { await onCanvasMouseUp(createTouchCanvasPointerEvent(getTouchPoint(e))); }, [onCanvasMouseUp], ); useEffect(() => { return () => { 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, }); }; }, []); // ── 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, }; }