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); diff --git a/apps/web/src/components/timeline/TimelineContext.tsx b/apps/web/src/components/timeline/TimelineContext.tsx index 6c9db5a..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 addDays(today, -30); + 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 addDays(today, -30); + 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 d515a11..8767b5b 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,70 @@ 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); + // Flag: scroll viewport to today after the next viewStart-driven re-layout. + const pendingScrollToTodayRef = useRef(false); + // 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 is in the DOM (isInitialLoading → false). + // totalCanvasWidth is non-zero before data loads, so it can't be used as the trigger. + useLayoutEffect(() => { + if (isInitialLoading) return; + if (hasScrolledToTodayOnLoad.current) return; + const el = scrollContainerRef.current; + if (!el) return; + el.scrollLeft = toLeft(today); + hasScrolledToTodayOnLoad.current = true; + }, [isInitialLoading, 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]); + // ─── 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(() => { + 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], @@ -709,10 +764,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 +796,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(