From 7264f0728a50b0d623ae8e3c93938cb5948f19d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 16:54:38 +0200 Subject: [PATCH] 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 --- .../src/components/timeline/TimelineView.tsx | 210 +++++++++++------- 1 file changed, 127 insertions(+), 83 deletions(-) diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx index 65f6224..30aa578 100644 --- a/apps/web/src/components/timeline/TimelineView.tsx +++ b/apps/web/src/components/timeline/TimelineView.tsx @@ -3,7 +3,7 @@ import { MILLISECONDS_PER_DAY } from "@capakraken/shared"; import { clsx } from "clsx"; import { useSession } from "next-auth/react"; -import { useEffect, useMemo, useRef, useState } from "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"; @@ -382,6 +382,15 @@ function TimelineViewContent({ }, }); + // ─── 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); @@ -389,13 +398,7 @@ function TimelineViewContent({ scrollContainerRef, cellWidth: CELL_WIDTH, selectedAllocationIds: multiSelectState.selectedAllocationIds, - onDeleteSelected: () => { - 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 }); - } - }, + onDeleteSelected: handleBatchDelete, }); const [inlineEditTarget, setInlineEditTarget] = useState<{ @@ -480,39 +483,6 @@ function TimelineViewContent({ return maxPid; }, [newAllocPopover, allocsByResource]); - function openAllocationPopoverAt( - info: { - allocationId: string; - projectId: string; - contextDate?: Date; - }, - anchorX: number, - anchorY: number, - ) { - if (hasActivePointerOverlay) return; - // Check if this is a demand (not an assignment) — route to DemandPopover - 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 } : {}), - }); - } - // Keep cellWidthRef in sync so the drag hook uses the correct value. cellWidthRef.current = CELL_WIDTH; @@ -685,8 +655,25 @@ function TimelineViewContent({ const scrollRafRef = useRef(null); const [scrollLeft, setScrollLeft] = useState(0); - // ─── Lazy-extend date range on scroll ───────────────────────────────────── - function handleContainerScroll() { + + // ─── 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; @@ -700,12 +687,82 @@ function TimelineViewContent({ setScrollLeft(scrollLeftRef.current); }); } - } + }, [CELL_WIDTH, setViewDays]); - const handleMouseMove = (e: React.MouseEvent) => { - if (!hasActivePointerOverlay) return; - onCanvasMouseMove(e); - }; + // ─── 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({ @@ -739,17 +796,13 @@ function TimelineViewContent({ resourceCount={resources.length} projectCount={projectGroups.length} totalAllocCount={totalAllocCount} - onNavigateBack={() => setViewStart((v) => addDays(v, -28))} - onNavigateToday={() => setViewStart(addDays(today, -30))} - onNavigateForward={() => setViewStart((v) => addDays(v, 28))} + onNavigateBack={handleNavigateBack} + onNavigateToday={handleNavigateToday} + onNavigateForward={handleNavigateForward} canUndo={canUndo} canRedo={canRedo} - onUndo={() => { - void undo(); - }} - onRedo={() => { - void redo(); - }} + onUndo={handleUndo} + onRedo={handleRedo} /> {/* Project color legend */} @@ -814,15 +867,15 @@ function TimelineViewContent({ rangeState={effectiveRangeState} shiftPreview={shiftPreview} contextResourceIds={contextResourceIds} - onAllocMouseDown={isSelfServiceTimeline ? () => undefined : onAllocMouseDown} - onAllocTouchStart={isSelfServiceTimeline ? () => undefined : onAllocTouchStart} - onRowMouseDown={isSelfServiceTimeline ? () => undefined : onRowMouseDown} - onRowTouchStart={isSelfServiceTimeline ? () => undefined : onRowTouchStart} - onAllocationContextMenu={isSelfServiceTimeline ? () => undefined : openAllocationPopoverAt} + onAllocMouseDown={panelOnAllocMouseDown} + onAllocTouchStart={panelOnAllocTouchStart} + onRowMouseDown={panelOnRowMouseDown} + onRowTouchStart={panelOnRowTouchStart} + onAllocationContextMenu={panelOnAllocationContextMenu} multiSelectState={multiSelectState} optimisticAllocations={optimisticAllocations} suppressHoverInteractions={hasActivePointerOverlay} - {...(!isSelfServiceTimeline ? { onInlineEdit: (id: string, vals: { startDate: Date; endDate: Date; hoursPerDay: number }, rect: DOMRect) => setInlineEditTarget({ allocationId: id, ...vals, barRect: rect }) } : {})} + {...(!isSelfServiceTimeline ? { onInlineEdit: handleInlineEdit } : {})} scrollLeft={scrollLeft} containerWidth={scrollContainerRef.current?.clientWidth ?? 1200} CELL_WIDTH={CELL_WIDTH} @@ -842,18 +895,15 @@ function TimelineViewContent({ allocDragState={allocDragState} rangeState={effectiveRangeState} multiSelectState={multiSelectState} - onProjectBarMouseDown={isSelfServiceTimeline ? () => undefined : onProjectBarMouseDown} - onProjectBarTouchStart={isSelfServiceTimeline ? () => undefined : onProjectBarTouchStart} - onAllocMouseDown={isSelfServiceTimeline ? () => undefined : onAllocMouseDown} - onAllocTouchStart={isSelfServiceTimeline ? () => undefined : onAllocTouchStart} - onRowMouseDown={isSelfServiceTimeline ? () => undefined : onRowMouseDown} - onRowTouchStart={isSelfServiceTimeline ? () => undefined : onRowTouchStart} - onOpenPanel={isSelfServiceTimeline ? () => undefined : setOpenPanelProjectId} - onOpenDemandClick={isSelfServiceTimeline ? () => undefined : (demand, anchorX, anchorY) => { - if (hasActivePointerOverlay) return; - setDemandPopover({ demand, x: anchorX, y: anchorY }); - }} - onAllocationContextMenu={isSelfServiceTimeline ? () => undefined : openAllocationPopoverAt} + 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} @@ -1087,14 +1137,8 @@ function TimelineViewContent({ { - 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 }); - } - }} - onAssign={() => setShowBatchAssign(true)} + onDelete={handleBatchDelete} + onAssign={handleShowBatchAssign} onClear={clearMultiSelect} isDeleting={batchDeleteMutation.isPending} />