Files
Nexus/apps/web/src/components/timeline/TimelineView.tsx
T
Hartmut 4a841d5acb
CI / Architecture Guardrails (pull_request) Successful in 17m31s
CI / Assistant Split Regression (pull_request) Successful in 9m42s
CI / Typecheck (pull_request) Successful in 20m48s
CI / Lint (pull_request) Successful in 8m6s
CI / Unit Tests (pull_request) Failing after 7m32s
CI / Build (pull_request) Successful in 9m12s
CI / E2E Tests (pull_request) Successful in 6m12s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 6m58s
CI / Release Images (pull_request) Has been skipped
feat(timeline): start at today and allow infinite scroll into the past
Previously viewStart defaulted to today-30 and the scroll container had
no left-edge expansion logic, so users hit a hard wall when scrolling
left. This change:

- Sets viewStart default to today so the viewport opens with today at
  the left edge (URL ?startDate= override still respected).
- Adds left-edge auto-expansion in handleContainerScroll: when the user
  scrolls within 40 cells of the left boundary, 120 days are prepended
  and a useLayoutEffect applies the matching scrollLeft compensation in
  the same paint frame to prevent a visual jump.
- Floors backward navigation at 5 years (minDate) to prevent unbounded
  viewDays growth.
- Updates handleNavigateToday to match: resets to today rather than
  today-30.

Both resource view and project view use the same TimelineContext /
TimelineView, so both are fixed by this change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 07:16:34 +02:00

1075 lines
39 KiB
TypeScript

"use client";
import { clsx } from "clsx";
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useLayoutEffect, 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 { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import type { TimelineDemandEntry } from "./TimelineContext.js";
import { FloatingActionBar } from "./FloatingActionBar.js";
import { TimelineDragOverlays } from "./TimelineDragOverlays.js";
import { TimelineHeader } from "./TimelineHeader.js";
import { TimelinePopovers } from "./TimelinePopovers.js";
import { TimelineToolbar } from "./TimelineToolbar.js";
import { addDays } from "./utils.js";
import { HEADER_DAY_HEIGHT, HEADER_MONTH_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js";
import {
TimelineProvider,
useTimelineData,
useTimelineView,
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 invalidatePlanningViews = useInvalidatePlanningViews();
const batchShiftMutationOuter = trpc.timeline.batchShiftAllocations.useMutation({
onSuccess: () => void invalidatePlanningViews(),
});
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 {
resources,
projectGroups,
allocsByResource,
openDemandsByProject,
visibleAssignments,
visibleDemands,
isLoading,
isInitialLoading,
isEntriesError,
totalAllocCount,
} = useTimelineData();
const {
viewStart,
viewEnd,
viewDays,
setViewStart,
setViewDays,
filters,
setFilters,
filterOpen,
setFilterOpen,
viewMode,
setViewMode,
today,
} = useTimelineView();
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 invalidatePlanningViewsInner = useInvalidatePlanningViews();
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
onSuccess: () => {
void invalidatePlanningViewsInner();
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]);
// ─── 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]);
// ─── 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]);
// ─── 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);
// Pixels to add to scrollLeft after a left-extension re-render (prevents jump).
const pendingLeftCompensationPx = useRef(0);
// Apply scroll compensation synchronously after the canvas grows leftward.
useLayoutEffect(() => {
const px = pendingLeftCompensationPx.current;
if (px === 0) return;
const el = scrollContainerRef.current;
if (el) el.scrollLeft += px;
pendingLeftCompensationPx.current = 0;
}, [viewStart]);
// 5-year floor — no practical data exists further back; prevents runaway growth.
const minDate = useMemo(() => addDays(today, -(365 * 5)), [today]);
// ─── Navigation callbacks for TimelineToolbar ────────────────────────────
const handleNavigateBack = useCallback(
() =>
setViewStart((v) => {
const candidate = addDays(v, -28);
return candidate < minDate ? minDate : candidate;
}),
[setViewStart, minDate],
);
const handleNavigateToday = useCallback(() => setViewStart(today), [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;
// Right-edge: extend future range
const distanceFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth;
if (distanceFromRight < CELL_WIDTH * 40) {
setViewDays((d) => d + 120);
}
// Left-edge: prepend past range and compensate scroll position so viewport doesn't jump
if (el.scrollLeft < CELL_WIDTH * 40 && viewStart > minDate) {
const daysToPrepend = 120;
// Count the exact visible days (respecting showWeekends) being prepended
let prependedVisible = 0;
for (let i = 1; i <= daysToPrepend; i++) {
const d = addDays(viewStart, -i);
const dow = d.getDay();
if (filters.showWeekends || (dow !== 0 && dow !== 6)) prependedVisible++;
}
pendingLeftCompensationPx.current = prependedVisible * CELL_WIDTH;
setViewStart((v) => {
const candidate = addDays(v, -daysToPrepend);
return candidate < minDate ? minDate : candidate;
});
setViewDays((d) => d + daysToPrepend);
}
scrollLeftRef.current = el.scrollLeft;
if (scrollRafRef.current === null) {
scrollRafRef.current = requestAnimationFrame(() => {
scrollRafRef.current = null;
setScrollLeft(scrollLeftRef.current);
});
}
}, [CELL_WIDTH, setViewDays, viewStart, minDate, setViewStart, filters.showWeekends]);
// ─── 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>
<TimelineDragOverlays
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
multiSelectState={multiSelectState}
shiftPreview={shiftPreview}
isPreviewLoading={isPreviewLoading}
isApplying={isApplying}
isAllocSaving={isAllocSaving}
mousePosRef={mousePosRef}
dragTooltipRef={dragTooltipRef}
allocTooltipRef={allocTooltipRef}
rangeHintRef={rangeHintRef}
multiDragTooltipRef={multiDragTooltipRef}
today={today}
/>
<FloatingActionBar
selectedAllocationCount={multiSelectState.selectedAllocationIds.length}
selectedResourceCount={multiSelectState.selectedResourceIds.length}
onDelete={handleBatchDelete}
onAssign={handleShowBatchAssign}
onClear={clearMultiSelect}
isDeleting={batchDeleteMutation.isPending}
/>
<TimelinePopovers
isSelfServiceTimeline={isSelfServiceTimeline}
hasActivePointerOverlay={hasActivePointerOverlay}
popover={popover}
setPopover={setPopover}
demandPopover={demandPopover}
setDemandPopover={setDemandPopover}
newAllocPopover={newAllocPopover}
setNewAllocPopover={setNewAllocPopover}
enrichedSuggestedProjectId={enrichedSuggestedProjectId}
openPanelProjectId={openPanelProjectId}
setOpenPanelProjectId={setOpenPanelProjectId}
openDemandToAssign={openDemandToAssign}
setOpenDemandToAssign={setOpenDemandToAssign}
openDemandsByProject={openDemandsByProject}
scrollContainerRef={scrollContainerRef}
multiSelectState={multiSelectState}
clearMultiSelect={clearMultiSelect}
handleBatchDelete={handleBatchDelete}
handleShowBatchAssign={handleShowBatchAssign}
isDeleting={batchDeleteMutation.isPending}
showBatchAssign={showBatchAssign}
setShowBatchAssign={setShowBatchAssign}
resourceHover={resourceHover}
setResourceHover={setResourceHover}
inlineEditTarget={inlineEditTarget}
setInlineEditTarget={setInlineEditTarget}
showShortcuts={showShortcuts}
setShowShortcuts={setShowShortcuts}
/>
</div>
);
}