Files
CapaKraken/apps/web/src/components/timeline/TimelineView.tsx
T
Hartmut 7264f0728a perf(timeline): add useCallback/useMemo to timeline components
Prevents redundant re-renders when parent state changes by stabilising
event handler references and memoising expensive derived data in
TimelineView, TimelineResourcePanel, and TimelineProjectPanel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:54:38 +02:00

1197 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import { clsx } from "clsx";
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useMemo, 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 { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationPopover } from "./AllocationPopover.js";
import { DemandPopover } from "./DemandPopover.js";
import { ResourceHoverCard } from "./ResourceHoverCard.js";
import type { TimelineDemandEntry } from "./TimelineContext.js";
import { BatchAssignPopover } from "./BatchAssignPopover.js";
import { FloatingActionBar } from "./FloatingActionBar.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";
import { ProjectColorLegend } from "./ProjectColorLegend.js";
import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.js";
import type { TimelineVisualOverrides } from "./allocationVisualState.js";
import { SuccessToast } from "~/components/ui/SuccessToast.js";
import { useTimelineKeyboard } from "~/hooks/useTimelineKeyboard.js";
import { KeyboardShortcutOverlay } from "./KeyboardShortcutOverlay.js";
import { InlineAllocationEditor } from "./InlineAllocationEditor.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 { data: session, status: sessionStatus } = useSession();
const mousePosRef = useRef({ x: 0, y: 0 });
const role = sessionStatus === "authenticated"
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
: null;
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
const canManageTimeline = !isSelfServiceTimeline;
const { push: pushHistory, pushBatch: pushBatchHistory, undo, redo, canUndo, canRedo } = useAllocationHistory();
const pushHistoryRef = useRef(pushHistory);
pushHistoryRef.current = pushHistory;
const pushBatchHistoryRef = useRef(pushBatchHistory);
pushBatchHistoryRef.current = pushBatchHistory;
const [popover, setPopover] = useState<{
allocationId: string;
projectId: string;
allocation?: TimelineAssignmentEntry | null;
x: number;
y: number;
contextDate?: Date;
} | null>(null);
const [newAllocPopover, setNewAllocPopover] = useState<{
resourceId: string;
startDate: Date;
endDate: Date;
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
/** Selection coordinates to keep the overlay visible while popover is open */
selectionResourceId: string;
selectionStart: Date;
selectionEnd: Date;
} | 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 invalidateTimeline = useInvalidateTimeline();
const batchShiftMutationOuter = trpc.timeline.batchShiftAllocations.useMutation({
onSuccess: invalidateTimeline,
});
const [dragErrorToast, setDragErrorToast] = useState<string | null>(null);
const {
dragState,
allocDragState,
rangeState,
multiSelectState,
setMultiSelectState,
optimisticAllocations,
reconcileOptimisticAllocations,
shiftPreview,
isPreviewLoading,
isApplying,
isAllocSaving,
onProjectBarMouseDown,
onAllocMouseDown,
onRowMouseDown,
onCanvasMouseMove,
onCanvasMouseUp,
onCanvasMouseLeave,
onCanvasRightMouseDown,
clearMultiSelect,
onProjectBarTouchStart,
onAllocTouchStart,
onRowTouchStart,
onCanvasTouchMove,
onCanvasTouchEnd,
} = useTimelineDrag({
cellWidthRef,
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,
selectionResourceId: info.resourceId,
selectionStart: info.startDate,
selectionEnd: info.endDate,
});
},
onAllocationMoved: (snapshot) => {
pushHistoryRef.current(snapshot);
},
onShiftClickAlloc: (allocationId: string) => {
setMultiSelectState(prev => {
const ids = new Set(prev.selectedAllocationIds);
if (ids.has(allocationId)) {
ids.delete(allocationId);
} else {
ids.add(allocationId);
}
return { ...prev, isSelecting: false, selectedAllocationIds: [...ids] };
});
},
onMultiDragComplete: (daysDelta, mode, selectedIds) => {
const ids = selectedIds ?? multiSelectState.selectedAllocationIds;
if (ids.length > 0 && daysDelta !== 0) {
pushBatchHistoryRef.current(ids, daysDelta, mode);
batchShiftMutationOuter.mutate({ allocationIds: ids, daysDelta, mode });
clearMultiSelect();
}
},
onMutationError: (message) => setDragErrorToast(message),
});
const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null);
const dragProjectId = dragState.isDragging ? dragState.projectId : null;
const contextProjectId = canManageTimeline ? (dragProjectId ?? openPanelProjectId) : null;
const { contextResourceIds, contextAllocations } = useProjectDragContext(contextProjectId, canManageTimeline);
return (
<>
<SuccessToast
show={dragErrorToast !== null}
message={dragErrorToast ?? ""}
variant="warning"
onDone={() => setDragErrorToast(null)}
/>
<TimelineProvider
isDragging={dragState.isDragging}
contextAllocations={contextAllocations as TimelineAssignmentEntry[]}
>
<TimelineViewContent
mousePosRef={mousePosRef}
cellWidthRef={cellWidthRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
multiSelectState={multiSelectState}
setMultiSelectState={setMultiSelectState}
optimisticAllocations={optimisticAllocations}
reconcileOptimisticAllocations={reconcileOptimisticAllocations}
onCanvasRightMouseDown={onCanvasRightMouseDown}
clearMultiSelect={clearMultiSelect}
shiftPreview={shiftPreview}
isPreviewLoading={isPreviewLoading}
isApplying={isApplying}
isAllocSaving={isAllocSaving}
onProjectBarMouseDown={onProjectBarMouseDown}
onAllocMouseDown={onAllocMouseDown}
onRowMouseDown={onRowMouseDown}
onCanvasMouseMove={onCanvasMouseMove}
onCanvasMouseUp={onCanvasMouseUp}
onCanvasMouseLeave={onCanvasMouseLeave}
onProjectBarTouchStart={onProjectBarTouchStart}
onAllocTouchStart={onAllocTouchStart}
onRowTouchStart={onRowTouchStart}
onCanvasTouchMove={onCanvasTouchMove}
onCanvasTouchEnd={onCanvasTouchEnd}
contextResourceIds={contextResourceIds}
popover={popover}
setPopover={setPopover}
newAllocPopover={newAllocPopover}
setNewAllocPopover={setNewAllocPopover}
openPanelProjectId={openPanelProjectId}
setOpenPanelProjectId={setOpenPanelProjectId}
canUndo={canUndo}
canRedo={canRedo}
isSelfServiceTimeline={isSelfServiceTimeline}
undo={undo}
redo={redo}
/>
</TimelineProvider>
</>
);
}
// ─── Content (inside TimelineProvider — has context access) ─────────────────
function TimelineViewContent({
mousePosRef,
cellWidthRef,
dragState,
allocDragState,
rangeState,
multiSelectState,
setMultiSelectState,
optimisticAllocations,
reconcileOptimisticAllocations,
onCanvasRightMouseDown,
clearMultiSelect,
shiftPreview,
isPreviewLoading,
isApplying,
isAllocSaving,
onProjectBarMouseDown,
onAllocMouseDown,
onRowMouseDown,
onCanvasMouseMove,
onCanvasMouseUp,
onCanvasMouseLeave,
onProjectBarTouchStart,
onAllocTouchStart,
onRowTouchStart,
onCanvasTouchMove,
onCanvasTouchEnd,
contextResourceIds,
popover,
setPopover,
newAllocPopover,
setNewAllocPopover,
openPanelProjectId,
setOpenPanelProjectId,
canUndo,
canRedo,
isSelfServiceTimeline,
undo,
redo,
}: {
mousePosRef: React.RefObject<{ x: number; y: number }>;
cellWidthRef: React.RefObject<number>;
dragState: ReturnType<typeof useTimelineDrag>["dragState"];
allocDragState: ReturnType<typeof useTimelineDrag>["allocDragState"];
rangeState: ReturnType<typeof useTimelineDrag>["rangeState"];
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
setMultiSelectState: ReturnType<typeof useTimelineDrag>["setMultiSelectState"];
optimisticAllocations: TimelineVisualOverrides;
reconcileOptimisticAllocations: ReturnType<typeof useTimelineDrag>["reconcileOptimisticAllocations"];
onCanvasRightMouseDown: ReturnType<typeof useTimelineDrag>["onCanvasRightMouseDown"];
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
isPreviewLoading: boolean;
isApplying: boolean;
isAllocSaving: boolean;
onProjectBarMouseDown: ReturnType<typeof useTimelineDrag>["onProjectBarMouseDown"];
onAllocMouseDown: ReturnType<typeof useTimelineDrag>["onAllocMouseDown"];
onRowMouseDown: ReturnType<typeof useTimelineDrag>["onRowMouseDown"];
onCanvasMouseMove: ReturnType<typeof useTimelineDrag>["onCanvasMouseMove"];
onCanvasMouseUp: ReturnType<typeof useTimelineDrag>["onCanvasMouseUp"];
onCanvasMouseLeave: ReturnType<typeof useTimelineDrag>["onCanvasMouseLeave"];
onProjectBarTouchStart: ReturnType<typeof useTimelineDrag>["onProjectBarTouchStart"];
onAllocTouchStart: ReturnType<typeof useTimelineDrag>["onAllocTouchStart"];
onRowTouchStart: ReturnType<typeof useTimelineDrag>["onRowTouchStart"];
onCanvasTouchMove: ReturnType<typeof useTimelineDrag>["onCanvasTouchMove"];
onCanvasTouchEnd: ReturnType<typeof useTimelineDrag>["onCanvasTouchEnd"];
contextResourceIds: string[];
popover: {
allocationId: string;
projectId: string;
allocation?: TimelineAssignmentEntry | null;
x: number;
y: number;
contextDate?: Date;
} | null;
setPopover: React.Dispatch<React.SetStateAction<typeof popover>>;
newAllocPopover: {
resourceId: string;
startDate: Date;
endDate: Date;
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
selectionResourceId: string;
selectionStart: Date;
selectionEnd: Date;
} | null;
setNewAllocPopover: React.Dispatch<React.SetStateAction<typeof newAllocPopover>>;
openPanelProjectId: string | null;
setOpenPanelProjectId: React.Dispatch<React.SetStateAction<string | null>>;
canUndo: boolean;
canRedo: boolean;
isSelfServiceTimeline: boolean;
undo: () => Promise<void>;
redo: () => Promise<void>;
}) {
const ctx = useTimelineContext();
const {
resources,
projectGroups,
allocsByResource,
openDemandsByProject,
viewStart,
viewEnd,
viewDays,
visibleAssignments,
visibleDemands,
setViewStart,
setViewDays,
filters,
setFilters,
filterOpen,
setFilterOpen,
viewMode,
setViewMode,
today,
isLoading,
isInitialLoading,
isEntriesError,
totalAllocCount,
} = ctx;
const scrollContainerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
// Tooltip DOM refs
const dragTooltipRef = useRef<HTMLDivElement>(null);
const allocTooltipRef = useRef<HTMLDivElement>(null);
const rangeHintRef = useRef<HTMLDivElement>(null);
const multiDragTooltipRef = useRef<HTMLDivElement>(null);
const [openDemandToAssign, setOpenDemandToAssign] = useState<OpenDemandAssignment | null>(null);
const [demandPopover, setDemandPopover] = useState<{
demand: TimelineDemandEntry;
x: number;
y: number;
} | null>(null);
const [showBatchAssign, setShowBatchAssign] = useState(false);
const [resourceHover, setResourceHover] = useState<{
resourceId: string;
anchorEl: HTMLElement;
} | null>(null);
const resourceHoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const previousViewModeRef = useRef(viewMode);
const invalidateTimelineInner = useInvalidateTimeline();
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
onSuccess: () => {
invalidateTimelineInner();
clearMultiSelect();
},
});
// ─── Batch-delete handler — shared by keyboard shortcut and action bar ─────
const handleBatchDelete = useCallback(() => {
if (multiSelectState.selectedAllocationIds.length === 0) return;
const msg = `Delete ${multiSelectState.selectedAllocationIds.length} allocation(s)? This cannot be undone.`;
if (window.confirm(msg)) {
batchDeleteMutation.mutate({ ids: multiSelectState.selectedAllocationIds });
}
}, [batchDeleteMutation, multiSelectState.selectedAllocationIds]);
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } =
useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today);
const { showShortcuts, setShowShortcuts } = useTimelineKeyboard({
scrollContainerRef,
cellWidth: CELL_WIDTH,
selectedAllocationIds: multiSelectState.selectedAllocationIds,
onDeleteSelected: handleBatchDelete,
});
const [inlineEditTarget, setInlineEditTarget] = useState<{
allocationId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
barRect: DOMRect;
} | null>(null);
const hasActivePointerOverlay =
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging;
useEffect(() => {
if (optimisticAllocations.size === 0) return;
reconcileOptimisticAllocations([...visibleAssignments, ...visibleDemands]);
}, [
optimisticAllocations,
reconcileOptimisticAllocations,
visibleAssignments,
visibleDemands,
]);
useEffect(() => {
if (!hasActivePointerOverlay) return;
setPopover(null);
setDemandPopover(null);
setResourceHover(null);
}, [hasActivePointerOverlay]);
useEffect(() => {
if (previousViewModeRef.current === viewMode) {
return;
}
previousViewModeRef.current = viewMode;
setPopover(null);
setDemandPopover(null);
setNewAllocPopover(null);
setResourceHover(null);
}, [viewMode, setNewAllocPopover]);
useEffect(() => {
if (!isInitialLoading) return;
setPopover(null);
setDemandPopover(null);
setNewAllocPopover(null);
setResourceHover(null);
}, [isInitialLoading, setNewAllocPopover]);
// ─── Keep selection overlay visible while popover is open ───────────────────
const effectiveRangeState: typeof rangeState = rangeState.isSelecting
? rangeState
: newAllocPopover
? {
isSelecting: true,
resourceId: newAllocPopover.selectionResourceId,
startDate: newAllocPopover.selectionStart,
currentDate: newAllocPopover.selectionEnd,
suggestedProjectId: newAllocPopover.suggestedProjectId,
startClientX: 0,
}
: rangeState;
// ─── Auto-suggest project for resource-view range select ───────────────────
const enrichedSuggestedProjectId = useMemo(() => {
if (!newAllocPopover) return null;
// Already has a suggestion (e.g. from project view)
if (newAllocPopover.suggestedProjectId) return newAllocPopover.suggestedProjectId;
// Resource view: find the project with the most hours in this resource's row
const allocs = allocsByResource.get(newAllocPopover.resourceId);
if (!allocs || allocs.length === 0) return null;
const projectHours = new Map<string, number>();
for (const alloc of allocs) {
projectHours.set(alloc.projectId, (projectHours.get(alloc.projectId) ?? 0) + alloc.hoursPerDay);
}
let maxPid: string | null = null;
let maxH = 0;
for (const [pid, h] of projectHours) {
if (h > maxH) { maxH = h; maxPid = pid; }
}
return maxPid;
}, [newAllocPopover, allocsByResource]);
// 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`;
}
if (multiDragTooltipRef.current) {
multiDragTooltipRef.current.style.left = `${x + 14}px`;
multiDragTooltipRef.current.style.top = `${y - 36}px`;
}
};
// During multi-drag, listen on document (cursor may leave canvas)
const target: EventTarget = multiSelectState.isMultiDragging ? document : el;
target.addEventListener("mousemove", handler as EventListener, { passive: true });
return () => target.removeEventListener("mousemove", handler as EventListener);
}, [hasActivePointerOverlay, isLoading, mousePosRef, multiSelectState.isMultiDragging]); // 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]);
// ─── ESC to close overlays (topmost first) ─────────────────────────────────
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) {
e.preventDefault();
clearMultiSelect();
return;
}
if (demandPopover) {
e.preventDefault();
setDemandPopover(null);
} else if (popover) {
e.preventDefault();
setPopover(null);
} else if (newAllocPopover) {
e.preventDefault();
setNewAllocPopover(null);
} else if (openDemandToAssign) {
e.preventDefault();
setOpenDemandToAssign(null);
} else if (openPanelProjectId) {
e.preventDefault();
setOpenPanelProjectId(null);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [demandPopover, popover, newAllocPopover, openDemandToAssign, openPanelProjectId, setPopover, setNewAllocPopover, setOpenPanelProjectId, multiSelectState.selectedAllocationIds.length, multiSelectState.selectedResourceIds.length, clearMultiSelect]);
// ─── Resource hover card — event delegation on label columns ──────────────
useEffect(() => {
if (hasActivePointerOverlay) {
if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current);
resourceHoverTimerRef.current = null;
}
setResourceHover(null);
return;
}
const canvas = canvasRef.current;
if (!canvas) return;
const HOVER_DELAY = 400;
function onMouseOver(e: MouseEvent) {
if (hasActivePointerOverlay) return;
const target = (e.target as HTMLElement).closest<HTMLElement>("[data-resource-hover-id]");
if (!target) return;
const rid = target.dataset.resourceHoverId;
if (!rid) return;
// Clear any pending hide
if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current);
resourceHoverTimerRef.current = null;
}
// If already showing this resource, skip
if (resourceHover?.resourceId === rid) return;
resourceHoverTimerRef.current = setTimeout(() => {
resourceHoverTimerRef.current = null;
setResourceHover({ resourceId: rid, anchorEl: target });
}, HOVER_DELAY);
}
function onMouseOut(e: MouseEvent) {
if (hasActivePointerOverlay) return;
const related = e.relatedTarget as HTMLElement | null;
// Don't close if moving into another resource-hover target or the hover card itself
if (related?.closest?.("[data-resource-hover-id]") || related?.closest?.("[data-resource-hover-card]")) return;
if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current);
resourceHoverTimerRef.current = null;
}
// Small delay before hiding to allow moving into hover card
resourceHoverTimerRef.current = setTimeout(() => {
resourceHoverTimerRef.current = null;
setResourceHover(null);
}, 150);
}
canvas.addEventListener("mouseover", onMouseOver, { passive: true });
canvas.addEventListener("mouseout", onMouseOut, { passive: true });
return () => {
canvas.removeEventListener("mouseover", onMouseOver);
canvas.removeEventListener("mouseout", onMouseOut);
if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current);
resourceHoverTimerRef.current = null;
}
};
}, [resourceHover?.resourceId, isInitialLoading, hasActivePointerOverlay]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Scroll-left tracking for horizontal virtualization ────────────────────
// Updated via RAF so React state only updates after a frame, not on every
// pixel of scroll. The ref gives instant reads inside event handlers.
const scrollLeftRef = useRef(0);
const scrollRafRef = useRef<number | null>(null);
const [scrollLeft, setScrollLeft] = useState(0);
// ─── Navigation callbacks for TimelineToolbar ────────────────────────────
const handleNavigateBack = useCallback(
() => setViewStart((v) => addDays(v, -28)),
[setViewStart],
);
const handleNavigateToday = useCallback(
() => setViewStart(addDays(today, -30)),
[setViewStart, today],
);
const handleNavigateForward = useCallback(
() => setViewStart((v) => addDays(v, 28)),
[setViewStart],
);
const handleUndo = useCallback(() => { void undo(); }, [undo]);
const handleRedo = useCallback(() => { void redo(); }, [redo]);
// ─── Scroll handler — extends date range and tracks scroll offset ─────────
const handleContainerScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
const distanceFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth;
if (distanceFromRight < CELL_WIDTH * 40) {
setViewDays((d) => d + 120);
}
scrollLeftRef.current = el.scrollLeft;
if (scrollRafRef.current === null) {
scrollRafRef.current = requestAnimationFrame(() => {
scrollRafRef.current = null;
setScrollLeft(scrollLeftRef.current);
});
}
}, [CELL_WIDTH, setViewDays]);
// ─── Canvas mousemove — only forwards event when drag overlay is active ───
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!hasActivePointerOverlay) return;
onCanvasMouseMove(e);
},
[hasActivePointerOverlay, onCanvasMouseMove],
);
// ─── openAllocationPopoverAt — routed to demand or allocation popover ─────
const openAllocationPopoverAt = useCallback(
(
info: { allocationId: string; projectId: string; contextDate?: Date },
anchorX: number,
anchorY: number,
) => {
if (hasActivePointerOverlay) return;
const demands = openDemandsByProject.get(info.projectId);
const demand = demands?.find((d) => d.id === info.allocationId);
if (demand) {
setDemandPopover({ demand, x: anchorX, y: anchorY });
return;
}
const allocation = visibleAssignments.find((entry) => (
entry.id === info.allocationId
|| entry.entityId === info.allocationId
|| entry.sourceAllocationId === info.allocationId
|| getPlanningEntryMutationId(entry) === info.allocationId
)) ?? null;
setPopover({
allocationId: info.allocationId,
projectId: info.projectId,
allocation,
x: anchorX,
y: anchorY,
...(info.contextDate ? { contextDate: info.contextDate } : {}),
});
},
[hasActivePointerOverlay, openDemandsByProject, visibleAssignments],
);
// ─── onOpenDemandClick for project panel — guards against overlay-active ──
const handleOpenDemandClick = useCallback(
(demand: TimelineDemandEntry, anchorX: number, anchorY: number) => {
if (hasActivePointerOverlay) return;
setDemandPopover({ demand, x: anchorX, y: anchorY });
},
[hasActivePointerOverlay],
);
// ─── onInlineEdit for resource panel — opens inline allocation editor ─────
const handleInlineEdit = useCallback(
(id: string, vals: { startDate: Date; endDate: Date; hoursPerDay: number }, rect: DOMRect) => {
setInlineEditTarget({ allocationId: id, ...vals, barRect: rect });
},
[],
);
// ─── FloatingActionBar callbacks ────────────────────────────────────────────
const handleShowBatchAssign = useCallback(() => setShowBatchAssign(true), []);
// ─── Stable panel event handlers — self-service gets a typed no-op so the
// memo() on ResourcePanel/ProjectPanel is not defeated by new fn refs.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stableNoop = useCallback((..._args: any[]) => undefined, []);
const panelOnAllocMouseDown = (isSelfServiceTimeline ? stableNoop : onAllocMouseDown) as typeof onAllocMouseDown;
const panelOnAllocTouchStart = (isSelfServiceTimeline ? stableNoop : onAllocTouchStart) as typeof onAllocTouchStart;
const panelOnRowMouseDown = (isSelfServiceTimeline ? stableNoop : onRowMouseDown) as typeof onRowMouseDown;
const panelOnRowTouchStart = (isSelfServiceTimeline ? stableNoop : onRowTouchStart) as typeof onRowTouchStart;
const panelOnAllocationContextMenu = (isSelfServiceTimeline ? stableNoop : openAllocationPopoverAt) as typeof openAllocationPopoverAt;
const panelOnProjectBarMouseDown = (isSelfServiceTimeline ? stableNoop : onProjectBarMouseDown) as typeof onProjectBarMouseDown;
const panelOnProjectBarTouchStart = (isSelfServiceTimeline ? stableNoop : onProjectBarTouchStart) as typeof onProjectBarTouchStart;
const panelOnOpenPanel = (isSelfServiceTimeline ? stableNoop : setOpenPanelProjectId) as typeof setOpenPanelProjectId;
const panelOnOpenDemandClick = (isSelfServiceTimeline ? stableNoop : handleOpenDemandClick) as typeof handleOpenDemandClick;
// ─── Multi-select intersection computation ────────────────────────────────
useMultiSelectIntersection({
multiSelectState,
setMultiSelectState,
clearMultiSelect,
canvasRef,
viewMode,
resources,
allocsByResource,
projectGroups,
openDemandsByProject,
dates,
today,
CELL_WIDTH,
toLeft,
toWidth,
});
return (
<div className="relative flex flex-1 flex-col gap-4 min-h-0">
{/* Toolbar */}
<TimelineToolbar
viewMode={viewMode}
onViewModeChange={setViewMode}
isViewModeSwitchDisabled={isInitialLoading}
filters={filters}
onFiltersChange={setFilters}
filterOpen={filterOpen}
onFilterOpenChange={setFilterOpen}
resourceCount={resources.length}
projectCount={projectGroups.length}
totalAllocCount={totalAllocCount}
onNavigateBack={handleNavigateBack}
onNavigateToday={handleNavigateToday}
onNavigateForward={handleNavigateForward}
canUndo={canUndo}
canRedo={canRedo}
onUndo={handleUndo}
onRedo={handleRedo}
/>
{/* Project color legend */}
<ProjectColorLegend />
{/* Scrollable canvas */}
<div
ref={scrollContainerRef}
onScroll={handleContainerScroll}
className="app-surface relative z-0 flex-1 overflow-auto"
>
{isEntriesError ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-sm text-red-600 dark:text-red-400">
<span>Failed to load timeline data. Please try refreshing the page.</span>
</div>
) : isInitialLoading ? (
<div className="flex items-center justify-center py-24 text-sm text-gray-500 dark:text-gray-400">
Loading timeline...
</div>
) : (
<div style={{ minWidth: LABEL_WIDTH + totalCanvasWidth }}>
<TimelineHeader
monthGroups={monthGroups}
dates={dates}
CELL_WIDTH={CELL_WIDTH}
LABEL_WIDTH={LABEL_WIDTH}
HEADER_MONTH_HEIGHT={HEADER_MONTH_HEIGHT}
HEADER_DAY_HEIGHT={HEADER_DAY_HEIGHT}
zoom={filters.zoom}
viewMode={viewMode}
today={today}
/>
{/* Canvas rows */}
<div
ref={canvasRef}
onMouseMove={handleMouseMove}
onMouseUp={(e) => void onCanvasMouseUp(e)}
onMouseLeave={onCanvasMouseLeave}
onMouseDown={(e) => {
if (!isSelfServiceTimeline && e.button === 2) {
onCanvasRightMouseDown(e);
}
}}
onContextMenu={(e) => e.preventDefault()}
onTouchMove={(e) => {
if (!hasActivePointerOverlay) return;
onCanvasTouchMove(e);
}}
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
className={clsx(
(dragState.isDragging || allocDragState.isActive || multiSelectState.isMultiDragging) && "cursor-grabbing select-none",
rangeState.isSelecting && "cursor-crosshair select-none",
multiSelectState.isSelecting && "cursor-crosshair select-none",
)}
>
{viewMode === "resource" && (
<TimelineResourcePanel
scrollContainerRef={scrollContainerRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={effectiveRangeState}
shiftPreview={shiftPreview}
contextResourceIds={contextResourceIds}
onAllocMouseDown={panelOnAllocMouseDown}
onAllocTouchStart={panelOnAllocTouchStart}
onRowMouseDown={panelOnRowMouseDown}
onRowTouchStart={panelOnRowTouchStart}
onAllocationContextMenu={panelOnAllocationContextMenu}
multiSelectState={multiSelectState}
optimisticAllocations={optimisticAllocations}
suppressHoverInteractions={hasActivePointerOverlay}
{...(!isSelfServiceTimeline ? { onInlineEdit: handleInlineEdit } : {})}
scrollLeft={scrollLeft}
containerWidth={scrollContainerRef.current?.clientWidth ?? 1200}
CELL_WIDTH={CELL_WIDTH}
dates={dates}
totalCanvasWidth={totalCanvasWidth}
toLeft={toLeft}
toWidth={toWidth}
gridLines={gridLines}
xToDate={xToDate}
/>
)}
{viewMode === "project" && (
<TimelineProjectPanel
scrollContainerRef={scrollContainerRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={effectiveRangeState}
multiSelectState={multiSelectState}
onProjectBarMouseDown={panelOnProjectBarMouseDown}
onProjectBarTouchStart={panelOnProjectBarTouchStart}
onAllocMouseDown={panelOnAllocMouseDown}
onAllocTouchStart={panelOnAllocTouchStart}
onRowMouseDown={panelOnRowMouseDown}
onRowTouchStart={panelOnRowTouchStart}
onOpenPanel={panelOnOpenPanel}
onOpenDemandClick={panelOnOpenDemandClick}
onAllocationContextMenu={panelOnAllocationContextMenu}
optimisticAllocations={optimisticAllocations}
suppressHoverInteractions={hasActivePointerOverlay}
CELL_WIDTH={CELL_WIDTH}
dates={dates}
totalCanvasWidth={totalCanvasWidth}
toLeft={toLeft}
toWidth={toWidth}
gridLines={gridLines}
xToDate={xToDate}
/>
)}
</div>
</div>
)}
</div>
{/* Multi-select rectangle overlay */}
{multiSelectState.isSelecting && (
<div
className="fixed border-2 border-sky-500 bg-sky-500/10 pointer-events-none z-30 rounded"
style={{
left: Math.min(multiSelectState.startX, multiSelectState.currentX),
top: Math.min(multiSelectState.startY, multiSelectState.currentY),
width: Math.abs(multiSelectState.currentX - multiSelectState.startX),
height: Math.abs(multiSelectState.currentY - multiSelectState.startY),
}}
/>
)}
{/* Saving indicators */}
{(isApplying || isAllocSaving) && (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-2xl bg-white/50 dark:bg-gray-950/50">
<div className="app-surface px-5 py-3 text-sm font-medium text-gray-700 dark:text-gray-200">
{isApplying ? "Applying shift…" : "Saving…"}
</div>
</div>
)}
{/* Drag preview tooltip */}
{dragState.isDragging && dragState.daysDelta !== 0 && (
<div
ref={dragTooltipRef}
className="fixed z-50 pointer-events-none"
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 8 }}
>
<ShiftPreviewTooltip
preview={
shiftPreview ?? {
valid: true,
deltaCents: 0,
wouldExceedBudget: false,
budgetUtilizationAfter: 0,
conflictCount: 0,
errors: [],
warnings: [],
}
}
projectName={dragState.projectName ?? ""}
newStartDate={dragState.currentStartDate ?? today}
newEndDate={dragState.currentEndDate ?? today}
isLoading={isPreviewLoading}
/>
</div>
)}
{/* Alloc drag tooltip */}
{allocDragState.isActive &&
allocDragState.daysDelta !== 0 &&
allocDragState.currentStartDate &&
allocDragState.currentEndDate && (
<div
ref={allocTooltipRef}
className="fixed z-40 bg-gray-800 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg space-y-0.5"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
<div className="font-semibold">{allocDragState.projectName}</div>
<div className="opacity-80">
{formatDateShort(allocDragState.currentStartDate)}
{" "}
{formatDateShort(allocDragState.currentEndDate)}
</div>
</div>
)}
{/* Range-select hint */}
{rangeState.isSelecting && rangeState.startDate && rangeState.currentDate && (
<div
ref={rangeHintRef}
className="fixed z-40 bg-brand-700 text-white text-xs px-2 py-1 rounded-lg pointer-events-none shadow"
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 28 }}
>
{(() => {
const end = rangeState.currentDate;
const [s, e] =
rangeState.startDate <= end
? [rangeState.startDate, end]
: [end, rangeState.startDate];
const days = Math.round((e.getTime() - s.getTime()) / MILLISECONDS_PER_DAY) + 1;
return `${days} day${days !== 1 ? "s" : ""}`;
})()}
</div>
)}
{/* Multi-drag tooltip */}
{multiSelectState.isMultiDragging && multiSelectState.multiDragDaysDelta !== 0 && (
<div
ref={multiDragTooltipRef}
className="fixed z-50 bg-sky-700 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg font-medium"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
{multiSelectState.multiDragMode === "resize-start" ? "Start " : multiSelectState.multiDragMode === "resize-end" ? "End " : ""}
{multiSelectState.multiDragDaysDelta > 0 ? "+" : ""}
{multiSelectState.multiDragDaysDelta}d
{" "}
({multiSelectState.selectedAllocationIds.length} allocations)
</div>
)}
{/* Allocation / Demand popover (click path) */}
{!isSelfServiceTimeline && !hasActivePointerOverlay && popover && (() => {
// Check if clicked allocation is actually a demand
const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId);
if (clickedDemand) {
return (
<DemandPopover
demand={clickedDemand}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setPopover(null);
setOpenDemandToAssign({
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
});
}}
anchorX={popover.x}
anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]}
/>
);
}
return (
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
initialAllocation={popover.allocation ?? null}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]}
{...(popover.contextDate ? { contextDate: popover.contextDate } : {})}
/>
);
})()}
{/* Demand popover */}
{!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && (
<DemandPopover
demand={demandPopover.demand}
onClose={() => setDemandPopover(null)}
onOpenPanel={(pid) => {
setDemandPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setDemandPopover(null);
setOpenDemandToAssign({
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
});
}}
anchorX={demandPopover.x}
anchorY={demandPopover.y}
ignoreScrollContainers={[scrollContainerRef]}
/>
)}
{/* New allocation popover */}
{!isSelfServiceTimeline && newAllocPopover && (
<NewAllocationPopover
resourceId={newAllocPopover.resourceId}
startDate={newAllocPopover.startDate}
endDate={newAllocPopover.endDate}
suggestedProjectId={enrichedSuggestedProjectId}
anchorX={newAllocPopover.anchorX}
anchorY={newAllocPopover.anchorY}
onClose={() => setNewAllocPopover(null)}
onCreated={() => setNewAllocPopover(null)}
ignoreScrollContainers={[scrollContainerRef]}
/>
)}
{/* Project side panel */}
{!isSelfServiceTimeline && openPanelProjectId && (
<ProjectPanel projectId={openPanelProjectId} onClose={() => setOpenPanelProjectId(null)} />
)}
{/* Open-demand assignment modal */}
{!isSelfServiceTimeline && openDemandToAssign && (
<FillOpenDemandModal
allocation={openDemandToAssign}
onClose={() => setOpenDemandToAssign(null)}
onSuccess={() => setOpenDemandToAssign(null)}
/>
)}
{/* Multi-select floating action bar */}
<FloatingActionBar
selectedAllocationCount={multiSelectState.selectedAllocationIds.length}
selectedResourceCount={multiSelectState.selectedResourceIds.length}
onDelete={handleBatchDelete}
onAssign={handleShowBatchAssign}
onClear={clearMultiSelect}
isDeleting={batchDeleteMutation.isPending}
/>
{/* Batch assign popover */}
{showBatchAssign && multiSelectState.dateRange && (
<BatchAssignPopover
resourceIds={multiSelectState.selectedResourceIds}
startDate={multiSelectState.dateRange.start}
endDate={multiSelectState.dateRange.end}
onClose={() => setShowBatchAssign(false)}
onCreated={() => {
setShowBatchAssign(false);
clearMultiSelect();
}}
/>
)}
{/* Resource hover card */}
{!hasActivePointerOverlay && resourceHover && (
<ResourceHoverCard
resourceId={resourceHover.resourceId}
anchorEl={resourceHover.anchorEl}
onClose={() => setResourceHover(null)}
/>
)}
{/* Inline allocation editor */}
{inlineEditTarget && (
<InlineAllocationEditor
allocationId={inlineEditTarget.allocationId}
initialStartDate={inlineEditTarget.startDate}
initialEndDate={inlineEditTarget.endDate}
initialHoursPerDay={inlineEditTarget.hoursPerDay}
barRect={inlineEditTarget.barRect}
onClose={() => setInlineEditTarget(null)}
onSaved={() => setInlineEditTarget(null)}
/>
)}
{/* Keyboard shortcut overlay */}
{showShortcuts && <KeyboardShortcutOverlay onClose={() => setShowShortcuts(false)} />}
{/* Keyboard shortcut hint button */}
<button
type="button"
onClick={() => setShowShortcuts((prev) => !prev)}
title="Keyboard shortcuts (?)"
className="fixed bottom-6 right-6 z-40 rounded-full w-8 h-8 flex items-center justify-center bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-sm font-medium"
>
?
</button>
</div>
);
}