From dc18afcadd8e5d906d25bd7ea93edbbb58e1df17 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:33:48 +0000 Subject: [PATCH 1/5] feat(timeline): scroll position lives in the url Add a scrollTo search param that captures the moment centered in the Timeline viewport. A pure resolveTimelineMountMoment precedence function (scrollTo -> day filter -> festival start) decides where to center on mount; useTimelineScrollSync owns that one-time centering plus debounced (~300ms), 5-minute-rounded, history-replace writes on user scroll. useTimelineUrlState now selects its filter params with structural sharing so scrollTo writes don't recompute the filtered schedule. Closes #192 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/hooks/useTimelineScrollSync.ts | 117 ++++++++++++++ src/hooks/useTimelineUrlState.ts | 10 ++ src/lib/searchSchemas.ts | 3 + src/lib/timelineMountMoment.test.ts | 113 +++++++++++++ src/lib/timelineMountMoment.ts | 60 +++++++ .../horizontal/TimelineContainer.tsx | 8 + tests/e2e/timeline-scroll.spec.ts | 149 ++++++++++++++++++ 7 files changed, 460 insertions(+) create mode 100644 src/hooks/useTimelineScrollSync.ts create mode 100644 src/lib/timelineMountMoment.test.ts create mode 100644 src/lib/timelineMountMoment.ts create mode 100644 tests/e2e/timeline-scroll.spec.ts diff --git a/src/hooks/useTimelineScrollSync.ts b/src/hooks/useTimelineScrollSync.ts new file mode 100644 index 00000000..d7264d15 --- /dev/null +++ b/src/hooks/useTimelineScrollSync.ts @@ -0,0 +1,117 @@ +import { useEffect, useLayoutEffect, useRef } from "react"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import type { RefObject } from "react"; +import { offsetToTime, timeToOffset } from "@/lib/timelineCalculator"; +import { + resolveTimelineMountMoment, + roundToNearestMinutes, +} from "@/lib/timelineMountMoment"; + +const SCROLL_DEBOUNCE_MS = 300; +const SCROLL_ROUND_MINUTES = 5; + +interface UseTimelineScrollSyncOptions { + scrollContainerRef: RefObject; + festivalStart: Date; + timezone: string; +} + +/** + * Owns the one-way sync between the timeline's scroll position and the + * `scrollTo` URL param: + * + * - On mount only: centers the viewport per `resolveTimelineMountMoment`'s + * precedence (scrollTo -> day filter -> festival start). + * - On user scroll: after the scroll settles (~300ms), writes the moment + * now centered in the viewport back to the URL (history replace), + * rounded to 5-minute granularity. + * + * These two directions never trigger each other: the mount effect runs once + * and the scroll listener only ever navigates, never touches `scrollLeft`. + */ +export function useTimelineScrollSync({ + scrollContainerRef, + festivalStart, + timezone, +}: UseTimelineScrollSyncOptions) { + const route = + "/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline" as const; + + // Narrow, structurally-shared selection: this hook only cares about + // scrollTo/day, so its own writes to scrollTo don't cascade elsewhere. + const { scrollTo, day } = useSearch({ + from: route, + select: (search) => ({ scrollTo: search.scrollTo, day: search.day }), + structuralSharing: true, + }); + const navigate = useNavigate({ from: route }); + + const hasCenteredOnMountRef = useRef(false); + const suppressNextScrollEventRef = useRef(false); + + useLayoutEffect(() => { + if (hasCenteredOnMountRef.current) return; + const container = scrollContainerRef.current; + if (!container) return; + hasCenteredOnMountRef.current = true; + + const moment = resolveTimelineMountMoment({ + scrollTo, + day, + timezone, + festivalStart, + }); + + const targetScrollLeft = Math.max( + 0, + timeToOffset(moment, festivalStart) - container.clientWidth / 2, + ); + + if (targetScrollLeft !== container.scrollLeft) { + suppressNextScrollEventRef.current = true; + container.scrollLeft = targetScrollLeft; + } + // Mount-only positioning: intentionally does not re-run when scrollTo/day + // change afterwards (one-way ownership, URL -> scroll only on mount). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scrollContainerRef]); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + let debounceTimer: ReturnType | undefined; + + function handleScroll() { + if (suppressNextScrollEventRef.current) { + suppressNextScrollEventRef.current = false; + return; + } + + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + const el = scrollContainerRef.current; + if (!el) return; + + const centerOffset = el.scrollLeft + el.clientWidth / 2; + const centerMoment = offsetToTime(centerOffset, festivalStart); + const rounded = roundToNearestMinutes( + centerMoment, + SCROLL_ROUND_MINUTES, + ); + + navigate({ + to: ".", + search: (prev) => ({ ...prev, scrollTo: rounded.toISOString() }), + replace: true, + }); + }, SCROLL_DEBOUNCE_MS); + } + + container.addEventListener("scroll", handleScroll, { passive: true }); + return () => { + container.removeEventListener("scroll", handleScroll); + if (debounceTimer) clearTimeout(debounceTimer); + }; + }, [scrollContainerRef, festivalStart, navigate]); +} diff --git a/src/hooks/useTimelineUrlState.ts b/src/hooks/useTimelineUrlState.ts index 3cd439b9..85834f6b 100644 --- a/src/hooks/useTimelineUrlState.ts +++ b/src/hooks/useTimelineUrlState.ts @@ -7,8 +7,18 @@ export type TimeFilter = TimelineSearch["time"]; export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") { const route = `/festivals/$festivalSlug/editions/$editionSlug/schedule/${tab}` as const; + // Select only the filter params this hook exposes, with structural sharing, + // so a `scrollTo` write (from scroll syncing) doesn't change this object's + // identity and trigger consumers to recompute the filtered schedule. const state = useSearch({ from: route, + select: (search) => ({ + view: search.view, + day: search.day, + time: search.time, + stages: search.stages, + }), + structuralSharing: true, }); const navigate = useNavigate({ from: route }); diff --git a/src/lib/searchSchemas.ts b/src/lib/searchSchemas.ts index 136cb1ac..d82c6569 100644 --- a/src/lib/searchSchemas.ts +++ b/src/lib/searchSchemas.ts @@ -38,6 +38,9 @@ export const timelineSearchSchema = z.object({ day: z.string().catch("all"), time: z.enum(["all", "morning", "afternoon", "evening"]).catch("all"), stages: z.array(z.string()).catch([]), + // The moment (ISO datetime) centered in the timeline viewport. Absent by + // default; only written once the user scrolls. See useTimelineScrollSync. + scrollTo: z.string().optional().catch(undefined), }); export type TimelineSearch = z.infer; diff --git a/src/lib/timelineMountMoment.test.ts b/src/lib/timelineMountMoment.test.ts new file mode 100644 index 00000000..fbab3ed2 --- /dev/null +++ b/src/lib/timelineMountMoment.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { + resolveTimelineMountMoment, + roundToNearestMinutes, +} from "./timelineMountMoment"; + +const TIMEZONE = "Europe/Lisbon"; // UTC+1 in July (WEST) +const FESTIVAL_START = new Date("2025-07-12T10:00:00Z"); + +describe("resolveTimelineMountMoment", () => { + it("prefers scrollTo when present and valid", () => { + const moment = resolveTimelineMountMoment({ + scrollTo: "2025-07-13T22:00:00.000Z", + day: "2025-07-12", + timezone: TIMEZONE, + festivalStart: FESTIVAL_START, + }); + + expect(moment.getTime()).toBe( + new Date("2025-07-13T22:00:00.000Z").getTime(), + ); + }); + + it("falls back to the day filter's start when scrollTo is absent", () => { + const moment = resolveTimelineMountMoment({ + scrollTo: undefined, + day: "2025-07-13", + timezone: TIMEZONE, + festivalStart: FESTIVAL_START, + }); + + // Midnight in Europe/Lisbon (UTC+1 in July) is 23:00 UTC the prior day. + expect(moment.getTime()).toBe( + new Date("2025-07-12T23:00:00.000Z").getTime(), + ); + }); + + it("falls back to the day filter's start when scrollTo is an invalid date string", () => { + const moment = resolveTimelineMountMoment({ + scrollTo: "not-a-date", + day: "2025-07-13", + timezone: TIMEZONE, + festivalStart: FESTIVAL_START, + }); + + expect(moment.getTime()).toBe( + new Date("2025-07-12T23:00:00.000Z").getTime(), + ); + }); + + it("falls back to festivalStart when day filter is 'all' and scrollTo is absent", () => { + const moment = resolveTimelineMountMoment({ + scrollTo: undefined, + day: "all", + timezone: TIMEZONE, + festivalStart: FESTIVAL_START, + }); + + expect(moment.getTime()).toBe(FESTIVAL_START.getTime()); + }); + + it("falls back to festivalStart when scrollTo is invalid and day is 'all'", () => { + const moment = resolveTimelineMountMoment({ + scrollTo: "garbage", + day: "all", + timezone: TIMEZONE, + festivalStart: FESTIVAL_START, + }); + + expect(moment.getTime()).toBe(FESTIVAL_START.getTime()); + }); + + it("scrollTo takes precedence over an active day filter", () => { + const moment = resolveTimelineMountMoment({ + scrollTo: "2025-07-14T12:00:00.000Z", + day: "2025-07-13", + timezone: TIMEZONE, + festivalStart: FESTIVAL_START, + }); + + expect(moment.getTime()).toBe( + new Date("2025-07-14T12:00:00.000Z").getTime(), + ); + }); +}); + +describe("roundToNearestMinutes", () => { + it("rounds down to the nearest 5 minutes", () => { + const date = new Date("2025-07-12T10:02:00.000Z"); + expect(roundToNearestMinutes(date, 5).getTime()).toBe( + new Date("2025-07-12T10:00:00.000Z").getTime(), + ); + }); + + it("rounds up to the nearest 5 minutes", () => { + const date = new Date("2025-07-12T10:03:00.000Z"); + expect(roundToNearestMinutes(date, 5).getTime()).toBe( + new Date("2025-07-12T10:05:00.000Z").getTime(), + ); + }); + + it("defaults to a 5-minute granularity", () => { + const date = new Date("2025-07-12T10:07:00.000Z"); + expect(roundToNearestMinutes(date).getTime()).toBe( + new Date("2025-07-12T10:05:00.000Z").getTime(), + ); + }); + + it("is a no-op for a moment already on the grid", () => { + const date = new Date("2025-07-12T10:15:00.000Z"); + expect(roundToNearestMinutes(date, 5).getTime()).toBe(date.getTime()); + }); +}); diff --git a/src/lib/timelineMountMoment.ts b/src/lib/timelineMountMoment.ts new file mode 100644 index 00000000..24cb169b --- /dev/null +++ b/src/lib/timelineMountMoment.ts @@ -0,0 +1,60 @@ +import { isValid, parseISO } from "date-fns"; +import { fromZonedTime } from "date-fns-tz"; + +export interface TimelineMountMomentInput { + /** Raw `scrollTo` search param, if present in the URL. */ + scrollTo?: string; + /** Active day filter: "all" or a "yyyy-MM-dd" festival calendar day. */ + day: string; + /** Festival's IANA timezone, used to resolve the day filter's start. */ + timezone: string; + /** Timeline geometry origin (earliest moment on the timeline). */ + festivalStart: Date; +} + +/** + * Decides which moment the timeline viewport should be centered on when the + * Timeline mounts. Pure and order-sensitive: + * + * 1. `scrollTo` from the URL, if present and parseable. + * 2. The start of the active `day` filter, if one is set. + * 3. The festival start (timeline origin). + * + * A future rule ("now, minus 1h, when now falls inside the festival window") + * slots in as an additional candidate between the day filter and the + * festival-start fallback (see issue #194). + */ +export function resolveTimelineMountMoment( + input: TimelineMountMomentInput, +): Date { + return ( + momentFromScrollTo(input.scrollTo) ?? + momentFromDayFilter(input.day, input.timezone) ?? + input.festivalStart + ); +} + +function momentFromScrollTo(scrollTo: string | undefined): Date | null { + if (!scrollTo) return null; + const parsed = parseISO(scrollTo); + return isValid(parsed) ? parsed : null; +} + +function momentFromDayFilter(day: string, timezone: string): Date | null { + if (!day || day === "all") return null; + try { + const dayStart = fromZonedTime(`${day}T00:00:00`, timezone); + return isValid(dayStart) ? dayStart : null; + } catch { + return null; + } +} + +/** + * Rounds a moment to the nearest multiple of `minutes` (default 5), used to + * keep `scrollTo` URL writes coarse-grained instead of pixel-precise. + */ +export function roundToNearestMinutes(date: Date, minutes = 5): Date { + const ms = minutes * 60 * 1000; + return new Date(Math.round(date.getTime() / ms) * ms); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx index 89cb7276..b2156dc3 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx @@ -2,6 +2,7 @@ import { useRef } from "react"; import { TimeScale } from "./TimeScale"; import { StageRow } from "./StageRow"; import type { TimelineData } from "@/lib/timelineCalculator"; +import { useTimelineScrollSync } from "@/hooks/useTimelineScrollSync"; interface TimelineContainerProps { timelineData: TimelineData; @@ -14,9 +15,16 @@ export function TimelineContainer({ }: TimelineContainerProps) { const scrollContainerRef = useRef(null); + useTimelineScrollSync({ + scrollContainerRef, + festivalStart: timelineData.festivalStart, + timezone, + }); + return (
{/* Time Scale */} diff --git a/tests/e2e/timeline-scroll.spec.ts b/tests/e2e/timeline-scroll.spec.ts new file mode 100644 index 00000000..545c42ff --- /dev/null +++ b/tests/e2e/timeline-scroll.spec.ts @@ -0,0 +1,149 @@ +import { test, expect } from "@playwright/test"; + +// Seeded in supabase/seed.sql: festival slug "test", edition slug "2025". +const TIMELINE_PATH = "/festivals/test/editions/2025/schedule/timeline"; +const SCROLL_DEBOUNCE_WAIT_MS = 600; // > the ~300ms debounce in useTimelineScrollSync + +test.describe("Timeline scroll position (scrollTo URL state)", () => { + test("untouched timeline has no scrollTo in the URL", async ({ page }) => { + await page.goto(TIMELINE_PATH); + + const scrollContainer = page.getByTestId("timeline-scroll-container"); + if (!(await scrollContainer.isVisible().catch(() => false))) { + test.skip(true, "Schedule not revealed in this environment"); + } + + expect(new URL(page.url()).searchParams.has("scrollTo")).toBe(false); + }); + + test("scrolling writes a debounced, rounded scrollTo via history replace", async ({ + page, + }) => { + await page.goto(TIMELINE_PATH); + + const scrollContainer = page.getByTestId("timeline-scroll-container"); + if (!(await scrollContainer.isVisible().catch(() => false))) { + test.skip(true, "Schedule not revealed in this environment"); + } + + const historyLengthBeforeScroll = await page.evaluate( + () => window.history.length, + ); + + await scrollContainer.evaluate((el) => { + el.scrollLeft = el.scrollLeft + 400; + }); + + // No write yet: still inside the debounce window. + await page.waitForTimeout(100); + expect(new URL(page.url()).searchParams.has("scrollTo")).toBe(false); + + await page.waitForTimeout(SCROLL_DEBOUNCE_WAIT_MS); + await expect(page).toHaveURL(/scrollTo=/); + + const scrollTo = new URL(page.url()).searchParams.get("scrollTo"); + expect(scrollTo).toBeTruthy(); + expect(new Date(scrollTo as string).toString()).not.toBe("Invalid Date"); + // Rounded to 5-minute granularity. + expect(new Date(scrollTo as string).getMinutes() % 5).toBe(0); + + // History replace, not push: writing scrollTo must not grow the + // history stack, even across multiple debounced writes. + await scrollContainer.evaluate((el) => { + el.scrollLeft = el.scrollLeft + 200; + }); + await page.waitForTimeout(SCROLL_DEBOUNCE_WAIT_MS); + + const historyLengthAfterScroll = await page.evaluate( + () => window.history.length, + ); + expect(historyLengthAfterScroll).toBe(historyLengthBeforeScroll); + }); + + test("opening a URL with scrollTo centers the viewport on that moment", async ({ + page, + }) => { + await page.goto(TIMELINE_PATH); + + const scrollContainer = page.getByTestId("timeline-scroll-container"); + if (!(await scrollContainer.isVisible().catch(() => false))) { + test.skip(true, "Schedule not revealed in this environment"); + } + + // Scroll to discover a real, in-range moment to jump back to. + await scrollContainer.evaluate((el) => { + el.scrollLeft = el.scrollLeft + 600; + }); + await page.waitForTimeout(SCROLL_DEBOUNCE_WAIT_MS); + + const scrollTo = new URL(page.url()).searchParams.get("scrollTo"); + expect(scrollTo).toBeTruthy(); + const scrollLeftAfterScroll = await scrollContainer.evaluate( + (el) => el.scrollLeft, + ); + + // Reset by navigating away, then open the URL with scrollTo directly. + await page.goto(`${TIMELINE_PATH}?scrollTo=${encodeURIComponent(scrollTo as string)}`); + const reloadedContainer = page.getByTestId("timeline-scroll-container"); + await expect(reloadedContainer).toBeVisible(); + + const scrollLeftOnLoad = await reloadedContainer.evaluate( + (el) => el.scrollLeft, + ); + + // Centering is deterministic given the same viewport width, so this + // should land close to where the debounced write captured it from. + expect(Math.abs(scrollLeftOnLoad - scrollLeftAfterScroll)).toBeLessThan( + 10, + ); + }); + + test("back from a set detail page and a full reload both restore the viewport position", async ({ + page, + }) => { + await page.goto(TIMELINE_PATH); + + const scrollContainer = page.getByTestId("timeline-scroll-container"); + if (!(await scrollContainer.isVisible().catch(() => false))) { + test.skip(true, "Schedule not revealed in this environment"); + } + + await scrollContainer.evaluate((el) => { + el.scrollLeft = el.scrollLeft + 500; + }); + await page.waitForTimeout(SCROLL_DEBOUNCE_WAIT_MS); + + const urlWithScroll = page.url(); + const scrollLeftBeforeNav = await scrollContainer.evaluate( + (el) => el.scrollLeft, + ); + + const setLink = page.locator('a[href*="/sets/"]').first(); + if (await setLink.isVisible().catch(() => false)) { + await setLink.click(); + await page.goBack(); + await expect(page).toHaveURL(urlWithScroll); + + const restoredContainer = page.getByTestId("timeline-scroll-container"); + await expect(restoredContainer).toBeVisible(); + const scrollLeftAfterBack = await restoredContainer.evaluate( + (el) => el.scrollLeft, + ); + expect(Math.abs(scrollLeftAfterBack - scrollLeftBeforeNav)).toBeLessThan( + 10, + ); + } + + // A full reload of the same URL should independently restore the + // viewport position via mount-time centering on scrollTo. + await page.goto(urlWithScroll); + const reloadedContainer = page.getByTestId("timeline-scroll-container"); + await expect(reloadedContainer).toBeVisible(); + const scrollLeftAfterReload = await reloadedContainer.evaluate( + (el) => el.scrollLeft, + ); + expect(Math.abs(scrollLeftAfterReload - scrollLeftBeforeNav)).toBeLessThan( + 10, + ); + }); +}); From f7631773f32992ccb184aa52e3951f682bdcf48d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:46:07 +0000 Subject: [PATCH 2/5] fix(timeline): harden scroll sync against review findings Position-based suppression of programmatic scroll events (a browser may fire more than one per scrollLeft write), and the back-restore e2e half now skips explicitly instead of silently passing when no set link is rendered. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/hooks/useTimelineScrollSync.ts | 17 ++++++++++++----- tests/e2e/timeline-scroll.spec.ts | 30 ++++++++++++++++-------------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/hooks/useTimelineScrollSync.ts b/src/hooks/useTimelineScrollSync.ts index d7264d15..79d59677 100644 --- a/src/hooks/useTimelineScrollSync.ts +++ b/src/hooks/useTimelineScrollSync.ts @@ -47,7 +47,10 @@ export function useTimelineScrollSync({ const navigate = useNavigate({ from: route }); const hasCenteredOnMountRef = useRef(false); - const suppressNextScrollEventRef = useRef(false); + // Position of the last programmatic scroll; scroll events reporting this + // position are ignored (a browser may fire more than one for a single + // scrollLeft write), so only genuine user scrolling reaches the URL. + const programmaticScrollLeftRef = useRef(null); useLayoutEffect(() => { if (hasCenteredOnMountRef.current) return; @@ -68,7 +71,7 @@ export function useTimelineScrollSync({ ); if (targetScrollLeft !== container.scrollLeft) { - suppressNextScrollEventRef.current = true; + programmaticScrollLeftRef.current = targetScrollLeft; container.scrollLeft = targetScrollLeft; } // Mount-only positioning: intentionally does not re-run when scrollTo/day @@ -83,9 +86,13 @@ export function useTimelineScrollSync({ let debounceTimer: ReturnType | undefined; function handleScroll() { - if (suppressNextScrollEventRef.current) { - suppressNextScrollEventRef.current = false; - return; + const programmaticLeft = programmaticScrollLeftRef.current; + if (programmaticLeft !== null) { + const el = scrollContainerRef.current; + if (el && Math.abs(el.scrollLeft - programmaticLeft) <= 1) { + return; + } + programmaticScrollLeftRef.current = null; } if (debounceTimer) clearTimeout(debounceTimer); diff --git a/tests/e2e/timeline-scroll.spec.ts b/tests/e2e/timeline-scroll.spec.ts index 545c42ff..b41657e5 100644 --- a/tests/e2e/timeline-scroll.spec.ts +++ b/tests/e2e/timeline-scroll.spec.ts @@ -119,20 +119,22 @@ test.describe("Timeline scroll position (scrollTo URL state)", () => { ); const setLink = page.locator('a[href*="/sets/"]').first(); - if (await setLink.isVisible().catch(() => false)) { - await setLink.click(); - await page.goBack(); - await expect(page).toHaveURL(urlWithScroll); - - const restoredContainer = page.getByTestId("timeline-scroll-container"); - await expect(restoredContainer).toBeVisible(); - const scrollLeftAfterBack = await restoredContainer.evaluate( - (el) => el.scrollLeft, - ); - expect(Math.abs(scrollLeftAfterBack - scrollLeftBeforeNav)).toBeLessThan( - 10, - ); - } + test.skip( + !(await setLink.isVisible().catch(() => false)), + "No set detail link rendered in this environment", + ); + await setLink.click(); + await page.goBack(); + await expect(page).toHaveURL(urlWithScroll); + + const restoredContainer = page.getByTestId("timeline-scroll-container"); + await expect(restoredContainer).toBeVisible(); + const scrollLeftAfterBack = await restoredContainer.evaluate( + (el) => el.scrollLeft, + ); + expect(Math.abs(scrollLeftAfterBack - scrollLeftBeforeNav)).toBeLessThan( + 10, + ); // A full reload of the same URL should independently restore the // viewport position via mount-time centering on scrollTo. From 162493c9737a6d65cb50a1ac22220619e356ecfb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:33:30 +0000 Subject: [PATCH 3/5] test(timeline): drive debounce waits with playwright fake clock Also drops a redundant schema comment, per review. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/lib/searchSchemas.ts | 2 -- tests/e2e/timeline-scroll.spec.ts | 17 +++++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/lib/searchSchemas.ts b/src/lib/searchSchemas.ts index d82c6569..17334de7 100644 --- a/src/lib/searchSchemas.ts +++ b/src/lib/searchSchemas.ts @@ -38,8 +38,6 @@ export const timelineSearchSchema = z.object({ day: z.string().catch("all"), time: z.enum(["all", "morning", "afternoon", "evening"]).catch("all"), stages: z.array(z.string()).catch([]), - // The moment (ISO datetime) centered in the timeline viewport. Absent by - // default; only written once the user scrolls. See useTimelineScrollSync. scrollTo: z.string().optional().catch(undefined), }); diff --git a/tests/e2e/timeline-scroll.spec.ts b/tests/e2e/timeline-scroll.spec.ts index b41657e5..8b26ba1e 100644 --- a/tests/e2e/timeline-scroll.spec.ts +++ b/tests/e2e/timeline-scroll.spec.ts @@ -2,10 +2,12 @@ import { test, expect } from "@playwright/test"; // Seeded in supabase/seed.sql: festival slug "test", edition slug "2025". const TIMELINE_PATH = "/festivals/test/editions/2025/schedule/timeline"; -const SCROLL_DEBOUNCE_WAIT_MS = 600; // > the ~300ms debounce in useTimelineScrollSync +// Fast-forwarded past the ~300ms debounce via Playwright's fake clock. +const SCROLL_DEBOUNCE_WAIT_MS = 600; test.describe("Timeline scroll position (scrollTo URL state)", () => { test("untouched timeline has no scrollTo in the URL", async ({ page }) => { + await page.clock.install(); await page.goto(TIMELINE_PATH); const scrollContainer = page.getByTestId("timeline-scroll-container"); @@ -19,6 +21,7 @@ test.describe("Timeline scroll position (scrollTo URL state)", () => { test("scrolling writes a debounced, rounded scrollTo via history replace", async ({ page, }) => { + await page.clock.install(); await page.goto(TIMELINE_PATH); const scrollContainer = page.getByTestId("timeline-scroll-container"); @@ -35,10 +38,10 @@ test.describe("Timeline scroll position (scrollTo URL state)", () => { }); // No write yet: still inside the debounce window. - await page.waitForTimeout(100); + await page.clock.fastForward(100); expect(new URL(page.url()).searchParams.has("scrollTo")).toBe(false); - await page.waitForTimeout(SCROLL_DEBOUNCE_WAIT_MS); + await page.clock.fastForward(SCROLL_DEBOUNCE_WAIT_MS); await expect(page).toHaveURL(/scrollTo=/); const scrollTo = new URL(page.url()).searchParams.get("scrollTo"); @@ -52,7 +55,7 @@ test.describe("Timeline scroll position (scrollTo URL state)", () => { await scrollContainer.evaluate((el) => { el.scrollLeft = el.scrollLeft + 200; }); - await page.waitForTimeout(SCROLL_DEBOUNCE_WAIT_MS); + await page.clock.fastForward(SCROLL_DEBOUNCE_WAIT_MS); const historyLengthAfterScroll = await page.evaluate( () => window.history.length, @@ -63,6 +66,7 @@ test.describe("Timeline scroll position (scrollTo URL state)", () => { test("opening a URL with scrollTo centers the viewport on that moment", async ({ page, }) => { + await page.clock.install(); await page.goto(TIMELINE_PATH); const scrollContainer = page.getByTestId("timeline-scroll-container"); @@ -74,7 +78,7 @@ test.describe("Timeline scroll position (scrollTo URL state)", () => { await scrollContainer.evaluate((el) => { el.scrollLeft = el.scrollLeft + 600; }); - await page.waitForTimeout(SCROLL_DEBOUNCE_WAIT_MS); + await page.clock.fastForward(SCROLL_DEBOUNCE_WAIT_MS); const scrollTo = new URL(page.url()).searchParams.get("scrollTo"); expect(scrollTo).toBeTruthy(); @@ -101,6 +105,7 @@ test.describe("Timeline scroll position (scrollTo URL state)", () => { test("back from a set detail page and a full reload both restore the viewport position", async ({ page, }) => { + await page.clock.install(); await page.goto(TIMELINE_PATH); const scrollContainer = page.getByTestId("timeline-scroll-container"); @@ -111,7 +116,7 @@ test.describe("Timeline scroll position (scrollTo URL state)", () => { await scrollContainer.evaluate((el) => { el.scrollLeft = el.scrollLeft + 500; }); - await page.waitForTimeout(SCROLL_DEBOUNCE_WAIT_MS); + await page.clock.fastForward(SCROLL_DEBOUNCE_WAIT_MS); const urlWithScroll = page.url(); const scrollLeftBeforeNav = await scrollContainer.evaluate( From acf2265b84b0a9e72f735d6197b5ba2caef798f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:43:18 +0000 Subject: [PATCH 4/5] fix(timeline): suppress mount scroll at clamped position The browser clamps scrollLeft to the scrollable range, so the suppression ref must record the read-back value, not the requested target, for the scroll handler to recognize the mount event. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/hooks/useTimelineScrollSync.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useTimelineScrollSync.ts b/src/hooks/useTimelineScrollSync.ts index 79d59677..de7a2f19 100644 --- a/src/hooks/useTimelineScrollSync.ts +++ b/src/hooks/useTimelineScrollSync.ts @@ -71,8 +71,10 @@ export function useTimelineScrollSync({ ); if (targetScrollLeft !== container.scrollLeft) { - programmaticScrollLeftRef.current = targetScrollLeft; container.scrollLeft = targetScrollLeft; + // Read back: the browser clamps to the scrollable range, and the + // suppression check must match the position events will report. + programmaticScrollLeftRef.current = container.scrollLeft; } // Mount-only positioning: intentionally does not re-run when scrollTo/day // change afterwards (one-way ownership, URL -> scroll only on mount). From 0dba8f0415b1f5c3f296fde4269ba0432e9f32fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 14:20:13 +0000 Subject: [PATCH 5/5] fix(timeline): drop removed view param from url-state select The rebase onto main brought in #199's removal of the view search param; the select still read it, failing typecheck. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/hooks/useTimelineUrlState.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/useTimelineUrlState.ts b/src/hooks/useTimelineUrlState.ts index 85834f6b..c7b1fac9 100644 --- a/src/hooks/useTimelineUrlState.ts +++ b/src/hooks/useTimelineUrlState.ts @@ -13,7 +13,6 @@ export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") { const state = useSearch({ from: route, select: (search) => ({ - view: search.view, day: search.day, time: search.time, stages: search.stages,