diff --git a/src/hooks/useTimelineScrollSync.ts b/src/hooks/useTimelineScrollSync.ts new file mode 100644 index 00000000..de7a2f19 --- /dev/null +++ b/src/hooks/useTimelineScrollSync.ts @@ -0,0 +1,126 @@ +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); + // 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; + 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) { + 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). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scrollContainerRef]); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + let debounceTimer: ReturnType | undefined; + + function handleScroll() { + 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); + 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..c7b1fac9 100644 --- a/src/hooks/useTimelineUrlState.ts +++ b/src/hooks/useTimelineUrlState.ts @@ -7,8 +7,17 @@ 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) => ({ + 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..17334de7 100644 --- a/src/lib/searchSchemas.ts +++ b/src/lib/searchSchemas.ts @@ -38,6 +38,7 @@ 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([]), + 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..8b26ba1e --- /dev/null +++ b/tests/e2e/timeline-scroll.spec.ts @@ -0,0 +1,156 @@ +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"; +// 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"); + 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.clock.install(); + 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.clock.fastForward(100); + expect(new URL(page.url()).searchParams.has("scrollTo")).toBe(false); + + await page.clock.fastForward(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.clock.fastForward(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.clock.install(); + 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.clock.fastForward(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.clock.install(); + 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.clock.fastForward(SCROLL_DEBOUNCE_WAIT_MS); + + const urlWithScroll = page.url(); + const scrollLeftBeforeNav = await scrollContainer.evaluate( + (el) => el.scrollLeft, + ); + + const setLink = page.locator('a[href*="/sets/"]').first(); + 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. + 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, + ); + }); +});