"use client"; import { clsx } from "clsx"; import { useEffect, useRef, useState } from "react"; import { useAllocationHistory } from "~/hooks/useAllocationHistory.js"; import { useProjectDragContext } from "~/hooks/useProjectDragContext.js"; import { useTimelineDrag } from "~/hooks/useTimelineDrag.js"; import { useTimelineLayout } from "~/hooks/useTimelineLayout.js"; import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js"; import { AllocationPopover } from "./AllocationPopover.js"; import { NewAllocationPopover } from "./NewAllocationPopover.js"; import { ProjectPanel } from "./ProjectPanel.js"; import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js"; import { TimelineHeader } from "./TimelineHeader.js"; import { TimelineToolbar } from "./TimelineToolbar.js"; import { addDays } from "./utils.js"; import { HEADER_DAY_HEIGHT, HEADER_MONTH_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js"; import { formatDateShort } from "~/lib/format.js"; import { TimelineProvider, useTimelineContext, type TimelineAssignmentEntry, } from "./TimelineContext.js"; import { TimelineResourcePanel } from "./TimelineResourcePanel.js"; import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProjectPanel.js"; // ─── Entry point ──────────────────────────────────────────────────────────── // Two-layer mount: the outer shell creates drag state + project context, // then wraps children with TimelineProvider. The inner content consumes context. export function TimelineView() { const mousePosRef = useRef({ x: 0, y: 0 }); const { push: pushHistory, undo, redo, canUndo, canRedo } = useAllocationHistory(); const pushHistoryRef = useRef(pushHistory); pushHistoryRef.current = pushHistory; const [popover, setPopover] = useState<{ allocationId: string; projectId: string; x: number; y: number; } | null>(null); const [newAllocPopover, setNewAllocPopover] = useState<{ resourceId: string; startDate: Date; endDate: Date; suggestedProjectId: string | null; anchorX: number; anchorY: number; } | null>(null); // cellWidth placeholder — the real value comes from useTimelineLayout inside the content. // useTimelineDrag only needs cellWidth for pixel→day conversion during drag. // We start with 40 (day zoom default) and update via a ref. const cellWidthRef = useRef(40); const { dragState, allocDragState, rangeState, shiftPreview, isPreviewLoading, isApplying, isAllocSaving, onProjectBarMouseDown, onAllocMouseDown, onRowMouseDown, onCanvasMouseMove, onCanvasMouseUp, onCanvasMouseLeave, onProjectBarTouchStart, onAllocTouchStart, onRowTouchStart, onCanvasTouchMove, onCanvasTouchEnd, } = useTimelineDrag({ cellWidth: cellWidthRef.current, onBlockClick: (info) => { setPopover({ allocationId: info.allocationId, projectId: info.projectId, x: mousePosRef.current.x, y: mousePosRef.current.y, }); }, onRangeSelected: (info) => { setNewAllocPopover({ resourceId: info.resourceId, startDate: info.startDate, endDate: info.endDate, suggestedProjectId: info.suggestedProjectId, anchorX: info.anchorX, anchorY: info.anchorY, }); }, onAllocationMoved: (snapshot) => { pushHistoryRef.current(snapshot); }, }); const [openPanelProjectId, setOpenPanelProjectId] = useState(null); const dragProjectId = dragState.isDragging ? dragState.projectId : null; const contextProjectId = dragProjectId ?? openPanelProjectId; const { contextResourceIds, contextAllocations } = useProjectDragContext(contextProjectId); return ( ); } // ─── Content (inside TimelineProvider — has context access) ───────────────── function TimelineViewContent({ mousePosRef, cellWidthRef, dragState, allocDragState, rangeState, shiftPreview, isPreviewLoading, isApplying, isAllocSaving, onProjectBarMouseDown, onAllocMouseDown, onRowMouseDown, onCanvasMouseMove, onCanvasMouseUp, onCanvasMouseLeave, onProjectBarTouchStart, onAllocTouchStart, onRowTouchStart, onCanvasTouchMove, onCanvasTouchEnd, contextResourceIds, popover, setPopover, newAllocPopover, setNewAllocPopover, openPanelProjectId, setOpenPanelProjectId, canUndo, canRedo, undo, redo, }: { mousePosRef: React.RefObject<{ x: number; y: number }>; cellWidthRef: React.RefObject; dragState: ReturnType["dragState"]; allocDragState: ReturnType["allocDragState"]; rangeState: ReturnType["rangeState"]; shiftPreview: ReturnType["shiftPreview"]; isPreviewLoading: boolean; isApplying: boolean; isAllocSaving: boolean; onProjectBarMouseDown: ReturnType["onProjectBarMouseDown"]; onAllocMouseDown: ReturnType["onAllocMouseDown"]; onRowMouseDown: ReturnType["onRowMouseDown"]; onCanvasMouseMove: ReturnType["onCanvasMouseMove"]; onCanvasMouseUp: ReturnType["onCanvasMouseUp"]; onCanvasMouseLeave: ReturnType["onCanvasMouseLeave"]; onProjectBarTouchStart: ReturnType["onProjectBarTouchStart"]; onAllocTouchStart: ReturnType["onAllocTouchStart"]; onRowTouchStart: ReturnType["onRowTouchStart"]; onCanvasTouchMove: ReturnType["onCanvasTouchMove"]; onCanvasTouchEnd: ReturnType["onCanvasTouchEnd"]; contextResourceIds: string[]; popover: { allocationId: string; projectId: string; x: number; y: number } | null; setPopover: React.Dispatch>; newAllocPopover: { resourceId: string; startDate: Date; endDate: Date; suggestedProjectId: string | null; anchorX: number; anchorY: number; } | null; setNewAllocPopover: React.Dispatch>; openPanelProjectId: string | null; setOpenPanelProjectId: React.Dispatch>; canUndo: boolean; canRedo: boolean; undo: () => Promise; redo: () => Promise; }) { const ctx = useTimelineContext(); const { resources, projectGroups, viewStart, viewEnd, viewDays, setViewStart, setViewDays, filters, setFilters, filterOpen, setFilterOpen, viewMode, setViewMode, today, isLoading, isInitialLoading, totalAllocCount, } = ctx; const scrollContainerRef = useRef(null); const canvasRef = useRef(null); // Tooltip DOM refs const dragTooltipRef = useRef(null); const allocTooltipRef = useRef(null); const rangeHintRef = useRef(null); const [openDemandToAssign, setOpenDemandToAssign] = useState(null); const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } = useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today); const hasActivePointerOverlay = dragState.isDragging || allocDragState.isActive || rangeState.isSelecting; function openAllocationPopoverAt( info: { allocationId: string; projectId: string; }, anchorX: number, anchorY: number, ) { setPopover({ allocationId: info.allocationId, projectId: info.projectId, x: anchorX, y: anchorY, }); } // Keep cellWidthRef in sync so the drag hook uses the correct value. cellWidthRef.current = CELL_WIDTH; // ─── Native mousemove listener — updates tooltips without React state ───── useEffect(() => { if (!hasActivePointerOverlay) return; const el = canvasRef.current; if (!el) return; const handler = (e: MouseEvent) => { const x = e.clientX; const y = e.clientY; mousePosRef.current = { x, y }; if (dragTooltipRef.current) { dragTooltipRef.current.style.left = `${x + 12}px`; dragTooltipRef.current.style.top = `${y - 8}px`; } if (allocTooltipRef.current) { allocTooltipRef.current.style.left = `${x + 14}px`; allocTooltipRef.current.style.top = `${y - 36}px`; } if (rangeHintRef.current) { rangeHintRef.current.style.left = `${x + 12}px`; rangeHintRef.current.style.top = `${y - 28}px`; } }; el.addEventListener("mousemove", handler, { passive: true }); return () => el.removeEventListener("mousemove", handler); }, [hasActivePointerOverlay, isLoading, mousePosRef]); // eslint-disable-line react-hooks/exhaustive-deps // ─── Shift+wheel → horizontal scroll ────────────────────────────────────── useEffect(() => { const el = scrollContainerRef.current; if (!el) return; const handler = (e: WheelEvent) => { if (e.shiftKey && e.deltaY !== 0 && e.deltaX === 0) { e.preventDefault(); el.scrollLeft += e.deltaY; } }; el.addEventListener("wheel", handler, { passive: false }); return () => el.removeEventListener("wheel", handler); }, [isLoading]); // eslint-disable-line react-hooks/exhaustive-deps // ─── Keyboard undo/redo ─────────────────────────────────────────────────── useEffect(() => { const handler = (e: KeyboardEvent) => { const isMac = navigator.platform.toUpperCase().includes("MAC"); const modKey = isMac ? e.metaKey : e.ctrlKey; if (!modKey) return; if (e.key === "z" && !e.shiftKey) { e.preventDefault(); void undo(); } if ((e.key === "z" && e.shiftKey) || e.key === "y") { e.preventDefault(); void redo(); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [undo, redo]); // ─── Lazy-extend date range on scroll ───────────────────────────────────── function handleContainerScroll() { const el = scrollContainerRef.current; if (!el) return; const distanceFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth; if (distanceFromRight < CELL_WIDTH * 40) { setViewDays((d) => d + 120); } } const handleMouseMove = (e: React.MouseEvent) => { if (!hasActivePointerOverlay) return; onCanvasMouseMove(e); }; return (
{/* Toolbar */} setViewStart((v) => addDays(v, -28))} onNavigateToday={() => setViewStart(addDays(today, -30))} onNavigateForward={() => setViewStart((v) => addDays(v, 28))} canUndo={canUndo} canRedo={canRedo} onUndo={() => { void undo(); }} onRedo={() => { void redo(); }} /> {/* Scrollable canvas */}
{isInitialLoading ? (
Loading timeline...
) : (
{/* Canvas rows */}
void onCanvasMouseUp(e)} onMouseLeave={onCanvasMouseLeave} onTouchMove={(e) => { if (!hasActivePointerOverlay) return; onCanvasTouchMove(e); }} onTouchEnd={(e) => void onCanvasTouchEnd(e)} className={clsx( (dragState.isDragging || allocDragState.isActive) && "cursor-grabbing select-none", rangeState.isSelecting && "cursor-crosshair select-none", )} > {viewMode === "resource" && ( )} {viewMode === "project" && ( )}
)}
{/* Saving indicators */} {(isApplying || isAllocSaving) && (
{isApplying ? "Applying shift…" : "Saving…"}
)} {/* Drag preview tooltip */} {dragState.isDragging && dragState.daysDelta !== 0 && (
)} {/* Alloc drag tooltip */} {allocDragState.isActive && allocDragState.daysDelta !== 0 && allocDragState.currentStartDate && allocDragState.currentEndDate && (
{allocDragState.projectName}
{formatDateShort(allocDragState.currentStartDate)} {" – "} {formatDateShort(allocDragState.currentEndDate)}
)} {/* Range-select hint */} {rangeState.isSelecting && rangeState.startDate && rangeState.currentDate && (
{(() => { const end = rangeState.currentDate; const [s, e] = rangeState.startDate <= end ? [rangeState.startDate, end] : [end, rangeState.startDate]; const days = Math.round((e.getTime() - s.getTime()) / 86400000) + 1; return `${days} day${days !== 1 ? "s" : ""}`; })()}
)} {/* Allocation popover */} {popover && ( setPopover(null)} onOpenPanel={(pid) => { setPopover(null); setOpenPanelProjectId(pid); }} anchorX={popover.x} anchorY={popover.y} /> )} {/* New allocation popover */} {newAllocPopover && ( setNewAllocPopover(null)} onCreated={() => setNewAllocPopover(null)} /> )} {/* Project side panel */} {openPanelProjectId && ( setOpenPanelProjectId(null)} /> )} {/* Open-demand assignment modal */} {openDemandToAssign && ( setOpenDemandToAssign(null)} onSuccess={() => setOpenDemandToAssign(null)} /> )}
); }