"use client"; import { useCallback, useRef, useState } from "react"; import { trpc } from "~/lib/trpc/client.js"; import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js"; import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.js"; // ─── 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; 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, originalLeft: 0, blockWidth: 0, daysDelta: 0, }; // ─── Per-allocation drag state ────────────────────────────────────────────── export type AllocDragMode = "move" | "resize-start" | "resize-end"; export interface AllocDragState { isActive: boolean; mode: AllocDragMode; allocationId: string | null; mutationAllocationId: string | null; projectId: string | null; projectName: string | null; resourceId: string | null; originalStartDate: Date | null; originalEndDate: Date | null; currentStartDate: Date | null; currentEndDate: Date | null; startMouseX: number; daysDelta: number; } const INITIAL_ALLOC_DRAG: AllocDragState = { isActive: false, mode: "move", allocationId: null, mutationAllocationId: null, projectId: null, projectName: null, resourceId: null, originalStartDate: null, originalEndDate: null, currentStartDate: null, currentEndDate: null, startMouseX: 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 function useTimelineDrag({ cellWidth, onShiftApplied, onBlockClick, onRangeSelected, onAllocationMoved, onShiftClickAlloc, onMultiDragComplete, }: { cellWidth: number; 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); // Keep ref in sync with state so document-level handlers read the latest selection multiSelectRef.current = multiSelectState; // Keep always-current refs for values used inside document event handlers const cellWidthRef = useRef(cellWidth); cellWidthRef.current = cellWidth; // 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(); // 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 pendingSnapshotRef = useRef(null); const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({ onSuccess: () => { invalidateTimeline(); const snap = pendingSnapshotRef.current; if (snap) { onAllocationMovedRef.current?.(snap); pendingSnapshotRef.current = null; } }, }); // ── Project-bar drag (shifts all allocations) ────────────────────────────── const onProjectBarMouseDown = useCallback( ( e: React.MouseEvent, opts: { projectId: string; projectName: string; startDate: Date; endDate: Date; }, ) => { if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); const state: DragState = { isDragging: true, projectId: opts.projectId, projectName: opts.projectName, allocationId: null, originalStartDate: opts.startDate, originalEndDate: opts.endDate, currentStartDate: opts.startDate, currentEndDate: opts.endDate, startMouseX: e.clientX, originalLeft: 0, blockWidth: 0, daysDelta: 0, }; dragStateRef.current = state; setDragState(state); }, [], ); // Legacy — kept for backward compat (triggers project shift from allocation block) const onBlockMouseDown = useCallback( ( e: React.MouseEvent, 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: DragState = { isDragging: true, projectId: opts.projectId, projectName: opts.projectName, allocationId: opts.allocationId ?? null, originalStartDate: opts.startDate, originalEndDate: opts.endDate, currentStartDate: opts.startDate, currentEndDate: opts.endDate, startMouseX: e.clientX, originalLeft: opts.blockLeft, blockWidth: opts.blockWidth, daysDelta: 0, }; 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: React.MouseEvent, opts: { mode: AllocDragMode; allocationId: string; mutationAllocationId?: string; projectId: string; projectName: string; resourceId: string | null; startDate: Date; endDate: Date; }, ) => { 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 = ms.selectedAllocationIds.length > 1 && ms.selectedAllocationIds.includes(opts.allocationId); if (isMultiSelected) { // ── Multi-drag: move/resize all selected allocations together ── const startMouseX = e.clientX; let currentDaysDelta = 0; const dragMode = opts.mode; setMultiSelectState((prev) => ({ ...prev, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode })); multiSelectRef.current = { ...ms, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode }; function handleMultiMove(ev: MouseEvent) { const deltaX = ev.clientX - startMouseX; const daysDelta = pixelsToDays(deltaX, cellWidthRef.current); if (daysDelta === currentDaysDelta) return; currentDaysDelta = daysDelta; setMultiSelectState((prev) => ({ ...prev, multiDragDaysDelta: daysDelta })); multiSelectRef.current = { ...multiSelectRef.current, multiDragDaysDelta: daysDelta }; } function handleMultiUp() { document.removeEventListener("mousemove", handleMultiMove); document.removeEventListener("mouseup", handleMultiUp); const finalDelta = currentDaysDelta; setMultiSelectState((prev) => ({ ...prev, isMultiDragging: false, multiDragDaysDelta: 0 })); multiSelectRef.current = { ...multiSelectRef.current, isMultiDragging: false, multiDragDaysDelta: 0 }; if (finalDelta !== 0) { // Pass IDs from ref to avoid stale closure in the callback const ids = multiSelectRef.current.selectedAllocationIds; onMultiDragCompleteRef.current?.(finalDelta, dragMode, ids); } } document.addEventListener("mousemove", handleMultiMove); document.addEventListener("mouseup", handleMultiUp); return; } // ── Single allocation drag ──────────────────────────────────────────── const initial: AllocDragState = { isActive: true, mode: opts.mode, allocationId: opts.allocationId, mutationAllocationId: opts.mutationAllocationId ?? opts.allocationId, projectId: opts.projectId, projectName: opts.projectName, resourceId: opts.resourceId, originalStartDate: opts.startDate, originalEndDate: opts.endDate, currentStartDate: opts.startDate, currentEndDate: opts.endDate, startMouseX: e.clientX, daysDelta: 0, }; allocDragRef.current = initial; setAllocDragState(initial); // ── document handlers ──────────────────────────────────────────────── function handleMove(ev: MouseEvent) { const alloc = allocDragRef.current; if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return; const deltaX = ev.clientX - alloc.startMouseX; const daysDelta = pixelsToDays(deltaX, cellWidthRef.current); if (daysDelta === alloc.daysDelta) return; const { start: newStart, end: newEnd } = computeDragDates( alloc.mode, alloc.originalStartDate, alloc.originalEndDate, daysDelta, ); const updated: AllocDragState = { ...alloc, currentStartDate: newStart, currentEndDate: newEnd, daysDelta, }; allocDragRef.current = updated; setAllocDragState(updated); } function handleUp() { document.removeEventListener("mousemove", handleMove); document.removeEventListener("mouseup", handleUp); const alloc = allocDragRef.current; if (!alloc.isActive) return; if (alloc.daysDelta === 0 && alloc.allocationId) { // No movement → treat as click if (wasShift) { // Shift+Click → toggle multi-selection for this allocation onShiftClickAllocRef.current?.(alloc.allocationId); } else { // Normal click → open alloc popover onBlockClickRef.current?.({ allocationId: alloc.allocationId, projectId: alloc.projectId ?? "", projectName: alloc.projectName ?? "", startDate: alloc.originalStartDate!, endDate: alloc.originalEndDate!, }); } } else if (alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) { pendingSnapshotRef.current = { allocationId: alloc.allocationId, mutationAllocationId: alloc.mutationAllocationId ?? alloc.allocationId, projectName: alloc.projectName ?? "", before: { startDate: alloc.originalStartDate!, endDate: alloc.originalEndDate! }, after: { startDate: alloc.currentStartDate, endDate: alloc.currentEndDate }, }; updateAllocMutation.mutate({ allocationId: alloc.mutationAllocationId ?? alloc.allocationId, startDate: alloc.currentStartDate, endDate: alloc.currentEndDate, }); } allocDragRef.current = INITIAL_ALLOC_DRAG; setAllocDragState(INITIAL_ALLOC_DRAG); } document.addEventListener("mousemove", handleMove); document.addEventListener("mouseup", handleUp); }, [updateAllocMutation.mutate], // mutate is stable across renders (React Query guarantee) ); // ── Range-select ──────────────────────────────────────────────────────────── const onRowMouseDown = useCallback( ( e: React.MouseEvent, opts: { resourceId: string; startDate: Date; suggestedProjectId?: string; }, ) => { if (dragStateRef.current.isDragging || allocDragRef.current.isActive) return; if (e.button !== 0) return; e.preventDefault(); const state: RangeState = { isSelecting: true, resourceId: opts.resourceId, startDate: opts.startDate, currentDate: opts.startDate, suggestedProjectId: opts.suggestedProjectId ?? null, startClientX: e.clientX, }; rangeStateRef.current = state; setRangeState(state); }, [], ); // ── Canvas-level handlers (project shift + range select only) ────────────── const onCanvasMouseMove = useCallback( (e: React.MouseEvent) => { // Project shift const drag = dragStateRef.current; if (drag.isDragging && drag.originalStartDate && drag.originalEndDate) { const deltaX = e.clientX - drag.startMouseX; const daysDelta = pixelsToDays(deltaX, cellWidth); if (daysDelta !== drag.daysDelta) { const { start: newStart, end: newEnd } = computeDragDates( "move", drag.originalStartDate, drag.originalEndDate, daysDelta, ); const updated: DragState = { ...drag, currentStartDate: newStart, currentEndDate: newEnd, daysDelta, }; dragStateRef.current = updated; setDragState(updated); } return; } // Range select const range = rangeStateRef.current; if (range.isSelecting && range.startDate) { const deltaX = e.clientX - range.startClientX; const daysDelta = pixelsToDays(deltaX, cellWidth); const currentDate = new Date(range.startDate); currentDate.setDate(currentDate.getDate() + daysDelta); const prevDelta = range.currentDate ? Math.round((range.currentDate.getTime() - range.startDate.getTime()) / 86400000) : 0; if (daysDelta === prevDelta) return; const updated: RangeState = { ...range, currentDate }; rangeStateRef.current = updated; setRangeState(updated); } }, [cellWidth], ); const onCanvasMouseUp = useCallback( async (e: React.MouseEvent) => { // Project shift const drag = dragStateRef.current; if (drag.isDragging) { if (drag.daysDelta === 0) { if (drag.projectId && drag.originalStartDate && drag.originalEndDate) { onBlockClick?.({ allocationId: drag.allocationId ?? "", projectId: drag.projectId, projectName: drag.projectName ?? "", startDate: drag.originalStartDate, endDate: drag.originalEndDate, }); } } else if (drag.projectId && drag.currentStartDate && drag.currentEndDate) { try { await applyShiftMutation.mutateAsync({ projectId: drag.projectId, newStartDate: drag.currentStartDate, newEndDate: drag.currentEndDate, }); } catch { // Validation error — revert visually } } dragStateRef.current = INITIAL_DRAG_STATE; setDragState(INITIAL_DRAG_STATE); return; } // Range select const range = rangeStateRef.current; if (range.isSelecting && range.resourceId && range.startDate) { const endDate = range.currentDate ?? range.startDate; const [startDate, finalEnd] = range.startDate <= endDate ? [range.startDate, endDate] : [endDate, range.startDate]; onRangeSelected?.({ resourceId: range.resourceId, startDate, endDate: finalEnd, suggestedProjectId: range.suggestedProjectId, anchorX: e.clientX, anchorY: e.clientY, }); rangeStateRef.current = INITIAL_RANGE_STATE; setRangeState(INITIAL_RANGE_STATE); } }, [applyShiftMutation, onBlockClick, 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 (dragStateRef.current.isDragging) { dragStateRef.current = INITIAL_DRAG_STATE; setDragState(INITIAL_DRAG_STATE); } 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(); const initial: MultiSelectState = { isSelecting: true, startX: e.clientX, startY: e.clientY, currentX: e.clientX, currentY: e.clientY, selectedAllocationIds: [], selectedResourceIds: [], dateRange: null, multiDragDaysDelta: 0, isMultiDragging: false, multiDragMode: "move", }; multiSelectRef.current = initial; setMultiSelectState(initial); function handleMove(ev: MouseEvent) { const ms = multiSelectRef.current; if (!ms.isSelecting) return; const updated: MultiSelectState = { ...ms, currentX: ev.clientX, currentY: ev.clientY, }; multiSelectRef.current = updated; setMultiSelectState(updated); } function handleUp(ev: MouseEvent) { document.removeEventListener("mousemove", handleMove); document.removeEventListener("mouseup", handleUp); const ms = multiSelectRef.current; if (!ms.isSelecting) return; const distance = Math.hypot(ev.clientX - ms.startX, ev.clientY - ms.startY); if (distance < 5) { // Minimal movement → not a drag selection, reset. // Let existing onContextMenu handlers on allocation blocks handle right-click. multiSelectRef.current = INITIAL_MULTI_SELECT; setMultiSelectState(INITIAL_MULTI_SELECT); return; } // Keep the rectangle coordinates for the parent to compute intersection. // isSelecting is set to false to indicate the drag is done, but the // rectangle data (startX/Y, currentX/Y) is preserved so TimelineView // can resolve which allocations/resources fall within the selection. const finished: MultiSelectState = { ...ms, isSelecting: false, currentX: ev.clientX, currentY: ev.clientY, }; multiSelectRef.current = finished; setMultiSelectState(finished); } document.addEventListener("mousemove", handleMove); document.addEventListener("mouseup", handleUp); }, []); const clearMultiSelect = useCallback(() => { multiSelectRef.current = INITIAL_MULTI_SELECT; setMultiSelectState(INITIAL_MULTI_SELECT); }, []); // ── Touch support ─────────────────────────────────────────────────────────── // Helper: extract clientX from a touch event (first active touch, then changedTouches as fallback) function toClientX(e: React.TouchEvent): number { return e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0; } const onProjectBarTouchStart = useCallback( ( e: React.TouchEvent, opts: { projectId: string; projectName: string; startDate: Date; endDate: Date; }, ) => { e.preventDefault(); touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: true }; onProjectBarMouseDown( { clientX: toClientX(e), preventDefault: () => {}, stopPropagation: () => {}, } as unknown as React.MouseEvent, 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; }, ) => { e.preventDefault(); touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: true }; onAllocMouseDown( { clientX: toClientX(e), preventDefault: () => {}, stopPropagation: () => {}, } as unknown as React.MouseEvent, opts, ); }, [onAllocMouseDown], ); const onRowTouchStart = useCallback( ( e: React.TouchEvent, opts: { resourceId: string; startDate: Date; suggestedProjectId?: string; }, ) => { e.preventDefault(); touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: false }; onRowMouseDown( { clientX: toClientX(e), preventDefault: () => {}, stopPropagation: () => {}, } as unknown as React.MouseEvent, opts, ); }, [onRowMouseDown], ); const onCanvasTouchMove = useCallback( (e: React.TouchEvent) => { const touch = e.touches[0]; if (!touch) return; // Scroll vs drag disambiguation: once decided, stick with the decision if (!touchStartRef.current.decided) { const dx = Math.abs(touch.clientX - touchStartRef.current.x); const dy = Math.abs(touch.clientY - touchStartRef.current.y); if (dx > 8 || dy > 8) { touchStartRef.current.decided = true; if (dy > dx) return; // vertical scroll wins — don't intercept } else { return; // haven't moved enough to decide yet } } onCanvasMouseMove({ clientX: touch.clientX } as React.MouseEvent); }, [onCanvasMouseMove], ); const onCanvasTouchEnd = useCallback( async (e: React.TouchEvent) => { const clientX = e.changedTouches[0]?.clientX ?? 0; const clientY = e.changedTouches[0]?.clientY ?? 0; await onCanvasMouseUp({ clientX, clientY } as React.MouseEvent); }, [onCanvasMouseUp], ); // ── 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, shiftPreview, isPreviewLoading, isApplying: applyShiftMutation.isPending, isAllocSaving: updateAllocMutation.isPending, onProjectBarMouseDown, onBlockMouseDown, onAllocMouseDown, onRowMouseDown, onCanvasMouseMove, onCanvasMouseUp, onCanvasMouseLeave, onCanvasRightMouseDown, clearMultiSelect, // Touch equivalents onProjectBarTouchStart, onAllocTouchStart, onRowTouchStart, onCanvasTouchMove, onCanvasTouchEnd, }; }