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>
This commit is contained in:
2026-04-09 16:54:38 +02:00
parent 485e220c49
commit 7264f0728a
+127 -83
View File
@@ -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<number | null>(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({
<FloatingActionBar
selectedAllocationCount={multiSelectState.selectedAllocationIds.length}
selectedResourceCount={multiSelectState.selectedResourceIds.length}
onDelete={() => {
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}
/>