diff --git a/apps/web/src/components/timeline/TimelineResourcePanel.tsx b/apps/web/src/components/timeline/TimelineResourcePanel.tsx index d26f30a..804531f 100644 --- a/apps/web/src/components/timeline/TimelineResourcePanel.tsx +++ b/apps/web/src/components/timeline/TimelineResourcePanel.tsx @@ -82,6 +82,9 @@ interface TimelineResourcePanelProps { optimisticAllocations: TimelineVisualOverrides; suppressHoverInteractions: boolean; onInlineEdit?: (allocationId: string, initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, barRect: DOMRect) => void; + /** Horizontal virtualization: current scroll offset and visible container width */ + scrollLeft?: number; + containerWidth?: number; // Layout from useTimelineLayout CELL_WIDTH: number; dates: Date[]; @@ -130,6 +133,8 @@ function TimelineResourcePanelInner({ optimisticAllocations, suppressHoverInteractions, onInlineEdit, + scrollLeft = 0, + containerWidth = 1200, CELL_WIDTH, dates, totalCanvasWidth, @@ -513,6 +518,8 @@ function TimelineResourcePanelInner({ multiSelectState, suppressHoverInteractions, onInlineEdit, + scrollLeft, + containerWidth, )} {filters.showVacations && renderVacationBlocks( @@ -619,7 +626,12 @@ function renderAllocBlocksFromData( multiSelectState: MultiSelectState, suppressHoverInteractions: boolean, onInlineEdit?: (allocationId: string, initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, barRect: DOMRect) => void, + scrollLeft = 0, + containerWidth = 1200, ) { + const OVERSCAN_PX = 10 * CELL_WIDTH; + const visibleLeft = scrollLeft - OVERSCAN_PX; + const visibleRight = scrollLeft + containerWidth + OVERSCAN_PX; const anyDragActive = dragState.isDragging || allocDragState.isActive; const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds); @@ -764,6 +776,12 @@ function renderAllocBlocksFromData( return []; } + // Horizontal virtualization: skip bars entirely outside the visible window + const effectiveRight = segmentLeft + segmentWidth; + if (effectiveRight < visibleLeft || segmentLeft > visibleRight) { + return []; + } + const handleWidth = segmentWidth >= 48 ? 10 : 6; const dragInset = Math.min(handleWidth, Math.max(2, Math.floor(segmentWidth / 4))); const segmentInfo: AllocMouseDownInfo = { diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx index e601c8a..65f6224 100644 --- a/apps/web/src/components/timeline/TimelineView.tsx +++ b/apps/web/src/components/timeline/TimelineView.tsx @@ -678,6 +678,13 @@ function TimelineViewContent({ }; }, [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(null); + const [scrollLeft, setScrollLeft] = useState(0); + // ─── Lazy-extend date range on scroll ───────────────────────────────────── function handleContainerScroll() { const el = scrollContainerRef.current; @@ -686,6 +693,13 @@ function TimelineViewContent({ 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); + }); + } } const handleMouseMove = (e: React.MouseEvent) => { @@ -809,6 +823,8 @@ function TimelineViewContent({ optimisticAllocations={optimisticAllocations} suppressHoverInteractions={hasActivePointerOverlay} {...(!isSelfServiceTimeline ? { onInlineEdit: (id: string, vals: { startDate: Date; endDate: Date; hoursPerDay: number }, rect: DOMRect) => setInlineEditTarget({ allocationId: id, ...vals, barRect: rect }) } : {})} + scrollLeft={scrollLeft} + containerWidth={scrollContainerRef.current?.clientWidth ?? 1200} CELL_WIDTH={CELL_WIDTH} dates={dates} totalCanvasWidth={totalCanvasWidth}