From 944d36bdb25622c127c8d92862fbb552d3b2c1ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 22 May 2026 08:15:37 +0200 Subject: [PATCH] fix(timeline): pre-load 90-day past buffer + scroll to today on mount viewStart=today left no canvas to the left of scrollLeft=0, making left-scroll physically impossible. Now viewStart defaults to today-90 so the canvas always has 90 days to scroll into, and a mount-time useLayoutEffect positions the viewport with today at the left edge. The Today button restores this view: scrolls in-range, or resets viewStart and schedules a post-layout scroll if today has scrolled out of the visible window. Co-Authored-By: Claude Sonnet 4.6 --- .../components/timeline/TimelineContext.tsx | 4 +- .../src/components/timeline/TimelineView.tsx | 45 ++++++++++++++++--- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/timeline/TimelineContext.tsx b/apps/web/src/components/timeline/TimelineContext.tsx index 60ab1b3..69777c4 100644 --- a/apps/web/src/components/timeline/TimelineContext.tsx +++ b/apps/web/src/components/timeline/TimelineContext.tsx @@ -284,7 +284,7 @@ export function TimelineProvider({ const d = new Date(sp); if (!isNaN(d.getTime())) return d; } - return today; + return addDays(today, -90); }); const [viewDays, setViewDays] = useState(() => { const sp = searchParams.get("days"); @@ -310,7 +310,7 @@ export function TimelineProvider({ const d = new Date(spStart); if (!isNaN(d.getTime())) return d; } - return today; + return addDays(today, -90); }); const spDays = searchParams.get("days"); diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx index 79a6f69..0937683 100644 --- a/apps/web/src/components/timeline/TimelineView.tsx +++ b/apps/web/src/components/timeline/TimelineView.tsx @@ -687,15 +687,33 @@ function TimelineViewContent({ // Pixels to add to scrollLeft after a left-extension re-render (prevents jump). const pendingLeftCompensationPx = useRef(0); + // Flag: scroll viewport to today after the next viewStart-driven re-layout. + const pendingScrollToTodayRef = useRef(false); + // Guard: only auto-scroll to today once on initial mount. + const scrolledToTodayOnMount = useRef(false); - // Apply scroll compensation synchronously after the canvas grows leftward. + // Scroll to today on first mount so the viewport opens with today at the left edge. useLayoutEffect(() => { - const px = pendingLeftCompensationPx.current; - if (px === 0) return; + if (scrolledToTodayOnMount.current) return; const el = scrollContainerRef.current; - if (el) el.scrollLeft += px; - pendingLeftCompensationPx.current = 0; - }, [viewStart]); + if (!el) return; + el.scrollLeft = toLeft(today); + scrolledToTodayOnMount.current = true; + }, [toLeft, today]); + + // Apply scroll compensation synchronously after the canvas grows (left-extend or Today button). + useLayoutEffect(() => { + const el = scrollContainerRef.current; + if (!el) return; + const px = pendingLeftCompensationPx.current; + if (px !== 0) { + el.scrollLeft += px; + pendingLeftCompensationPx.current = 0; + } else if (pendingScrollToTodayRef.current) { + el.scrollLeft = toLeft(today); + pendingScrollToTodayRef.current = false; + } + }, [viewStart, toLeft, today]); // 5-year floor — no practical data exists further back; prevents runaway growth. const minDate = useMemo(() => addDays(today, -(365 * 5)), [today]); @@ -709,7 +727,20 @@ function TimelineViewContent({ }), [setViewStart, minDate], ); - const handleNavigateToday = useCallback(() => setViewStart(today), [setViewStart, today]); + const handleNavigateToday = useCallback(() => { + const el = scrollContainerRef.current; + const todayMs = new Date(today).setHours(0, 0, 0, 0); + const vsMs = new Date(viewStart).setHours(0, 0, 0, 0); + const veMs = new Date(addDays(viewStart, viewDays)).setHours(0, 0, 0, 0); + if (todayMs >= vsMs && todayMs < veMs && el) { + // Today is in range — just scroll without touching state. + el.scrollLeft = toLeft(today); + } else { + // Today is out of range — reset the window and schedule a scroll. + pendingScrollToTodayRef.current = true; + setViewStart(addDays(today, -90)); + } + }, [today, viewStart, viewDays, toLeft, setViewStart]); const handleNavigateForward = useCallback( () => setViewStart((v) => addDays(v, 28)), [setViewStart],