From 4a841d5acbc2f998537d86672a2e660c5efae8e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 22 May 2026 07:16:34 +0200 Subject: [PATCH 1/7] 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 --- .../components/timeline/TimelineContext.tsx | 4 +- .../src/components/timeline/TimelineView.tsx | 53 ++++++++++++++++--- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/timeline/TimelineContext.tsx b/apps/web/src/components/timeline/TimelineContext.tsx index 6c9db5a..60ab1b3 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 addDays(today, -30); + return today; }); 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 addDays(today, -30); + return today; }); const spDays = searchParams.get("days"); diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx index d515a11..79a6f69 100644 --- a/apps/web/src/components/timeline/TimelineView.tsx +++ b/apps/web/src/components/timeline/TimelineView.tsx @@ -2,7 +2,7 @@ import { clsx } from "clsx"; import { useSession } from "next-auth/react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "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"; @@ -685,15 +685,31 @@ function TimelineViewContent({ const scrollRafRef = useRef(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) => addDays(v, -28)), - [setViewStart], - ); - const handleNavigateToday = useCallback( - () => setViewStart(addDays(today, -30)), - [setViewStart, today], + () => + 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], @@ -709,10 +725,31 @@ function TimelineViewContent({ 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(() => { @@ -720,7 +757,7 @@ function TimelineViewContent({ setScrollLeft(scrollLeftRef.current); }); } - }, [CELL_WIDTH, setViewDays]); + }, [CELL_WIDTH, setViewDays, viewStart, minDate, setViewStart, filters.showWeekends]); // ─── Canvas mousemove — only forwards event when drag overlay is active ─── const handleMouseMove = useCallback( From 6ec512e30257590f957f75990ff995227ffe8f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 22 May 2026 08:06:56 +0200 Subject: [PATCH 2/7] test(cron): raise timeout for next/server cold-import on act runner The test takes >5s on the QNAP act runner because dynamic import of next/server has to transpile the module cold on first call. Raise the per-test timeout to 15s to give it headroom without changing the test logic. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/app/api/cron/auth-anomaly-check/route.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/api/cron/auth-anomaly-check/route.test.ts b/apps/web/src/app/api/cron/auth-anomaly-check/route.test.ts index b93ae2a..c417e48 100644 --- a/apps/web/src/app/api/cron/auth-anomaly-check/route.test.ts +++ b/apps/web/src/app/api/cron/auth-anomaly-check/route.test.ts @@ -82,7 +82,7 @@ describe("GET /api/cron/auth-anomaly-check — cron secret enforcement", () => { const { GET } = await importRoute(); const res = await GET(makeRequest()); expect(res.status).toBe(401); - }); + }, 15_000); // next/server cold-import can take >5s on the act runner it("proceeds when verifyCronSecret returns null (allowed)", async () => { verifyCronSecretMock.mockReturnValue(null); 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 3/7] 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], From 7285668c52ff11272a41764b0489225192c5d4c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 22 May 2026 08:38:08 +0200 Subject: [PATCH 4/7] fix(timeline): use empty-deps useLayoutEffect for mount scroll to today MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard-ref approach broke in React Strict Mode (dev): the ref persisted as `true` across the simulated remount, so the second invocation skipped the scroll — leaving scrollLeft=0 (today-90 at the left edge, not today). An empty-deps useLayoutEffect runs twice in Strict Mode but both executions fire against the same initial `toLeft` and produce the correct result. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/timeline/TimelineView.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx index 0937683..ec4cc2d 100644 --- a/apps/web/src/components/timeline/TimelineView.tsx +++ b/apps/web/src/components/timeline/TimelineView.tsx @@ -689,17 +689,15 @@ function TimelineViewContent({ 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); - // Scroll to today on first mount so the viewport opens with today at the left edge. + // Scroll to today on mount so the viewport opens with today at the left edge. + // Empty deps: intentionally runs once (and twice in React Strict Mode dev, both correct). + useLayoutEffect(() => { - if (scrolledToTodayOnMount.current) return; const el = scrollContainerRef.current; 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(() => { From 0e9d6ec3881380b0a3f70ba24eed412b0325f050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 22 May 2026 08:45:09 +0200 Subject: [PATCH 5/7] fix(timeline): wait for canvas width before scrolling to today MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useLayoutEffect([]) fired before isInitialLoading resolved, so the scroll container had no canvas yet — scrollLeft was clipped to 0. Now the scroll-to-today fires on the first render where totalCanvasWidth becomes non-zero. The cleanup effect resets the guard on unmount so React Strict Mode's fake-unmount+remount also scrolls correctly. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/timeline/TimelineView.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx index ec4cc2d..3e5a399 100644 --- a/apps/web/src/components/timeline/TimelineView.tsx +++ b/apps/web/src/components/timeline/TimelineView.tsx @@ -689,15 +689,25 @@ function TimelineViewContent({ const pendingLeftCompensationPx = useRef(0); // Flag: scroll viewport to today after the next viewStart-driven re-layout. const pendingScrollToTodayRef = useRef(false); - - // Scroll to today on mount so the viewport opens with today at the left edge. - // Empty deps: intentionally runs once (and twice in React Strict Mode dev, both correct). - + // Guard reset on every real unmount (including Strict Mode fake-unmount) so the + // scroll-to-today fires correctly on remount. + const hasScrolledToTodayOnLoad = useRef(false); useLayoutEffect(() => { + return () => { + hasScrolledToTodayOnLoad.current = false; + }; + }, []); + + // Scroll to today the first time the canvas has its full width (after initial data load). + // Depends on totalCanvasWidth so it fires after isInitialLoading → false renders the canvas. + useLayoutEffect(() => { + if (totalCanvasWidth === 0) return; + if (hasScrolledToTodayOnLoad.current) return; const el = scrollContainerRef.current; if (!el) return; el.scrollLeft = toLeft(today); - }, []); + hasScrolledToTodayOnLoad.current = true; + }, [totalCanvasWidth, toLeft, today]); // Apply scroll compensation synchronously after the canvas grows (left-extend or Today button). useLayoutEffect(() => { From 2383bcbdc035d475b63dc2565648ba1ea28f3369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 22 May 2026 08:48:23 +0200 Subject: [PATCH 6/7] =?UTF-8?q?fix(timeline):=20trigger=20scroll-to-today?= =?UTF-8?q?=20on=20isInitialLoading=E2=86=92false=20not=20totalCanvasWidth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit totalCanvasWidth is computed from viewStart/viewDays before data loads, so the previous trigger fired during the loading spinner. scrollLeft was clipped to 0 (no canvas in DOM yet) and the guard was set, blocking the real scroll after data arrived. Using isInitialLoading as the dep fires the effect exactly when the canvas enters the DOM. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/timeline/TimelineView.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx index 3e5a399..8767b5b 100644 --- a/apps/web/src/components/timeline/TimelineView.tsx +++ b/apps/web/src/components/timeline/TimelineView.tsx @@ -698,16 +698,16 @@ function TimelineViewContent({ }; }, []); - // Scroll to today the first time the canvas has its full width (after initial data load). - // Depends on totalCanvasWidth so it fires after isInitialLoading → false renders the canvas. + // Scroll to today the first time the canvas is in the DOM (isInitialLoading → false). + // totalCanvasWidth is non-zero before data loads, so it can't be used as the trigger. useLayoutEffect(() => { - if (totalCanvasWidth === 0) return; + if (isInitialLoading) return; if (hasScrolledToTodayOnLoad.current) return; const el = scrollContainerRef.current; if (!el) return; el.scrollLeft = toLeft(today); hasScrolledToTodayOnLoad.current = true; - }, [totalCanvasWidth, toLeft, today]); + }, [isInitialLoading, toLeft, today]); // Apply scroll compensation synchronously after the canvas grows (left-extend or Today button). useLayoutEffect(() => { From 12044f638ec523f3bb0be358f1d2c7e668a7b985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 22 May 2026 09:34:10 +0200 Subject: [PATCH 7/7] =?UTF-8?q?ci:=20retrigger=20=E2=80=94=20E2E=20webServ?= =?UTF-8?q?er=20timeout=20on=20run=20#172=20(QNAP=20runner=20flake)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit