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:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user