diff --git a/src/hooks/useTimelineScrollSync.ts b/src/hooks/useTimelineScrollSync.ts index de7a2f19..0a372fc5 100644 --- a/src/hooks/useTimelineScrollSync.ts +++ b/src/hooks/useTimelineScrollSync.ts @@ -17,17 +17,9 @@ interface UseTimelineScrollSyncOptions { } /** - * 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`. + * One-way sync between the timeline viewport and the `scrollTo` URL param: + * URL -> scroll only on mount and via `jumpTo`; user scroll -> URL only + * (debounced, 5-min rounded, history replace). Never loops. */ export function useTimelineScrollSync({ scrollContainerRef, @@ -37,8 +29,6 @@ export function useTimelineScrollSync({ 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 }), @@ -47,9 +37,7 @@ export function useTimelineScrollSync({ 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. + // Scroll events at this position are programmatic, not user scrolling. const programmaticScrollLeftRef = useRef(null); useLayoutEffect(() => { @@ -72,12 +60,10 @@ export function useTimelineScrollSync({ 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. + // Read back: the browser clamps scrollLeft to the scrollable range. 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). + // Mount-only by design: URL -> scroll never re-runs after mount. // eslint-disable-next-line react-hooks/exhaustive-deps }, [scrollContainerRef]); @@ -123,4 +109,31 @@ export function useTimelineScrollSync({ if (debounceTimer) clearTimeout(debounceTimer); }; }, [scrollContainerRef, festivalStart, navigate]); + + function jumpTo(moment: Date) { + const container = scrollContainerRef.current; + if (!container) return; + + const rounded = roundToNearestMinutes(moment, SCROLL_ROUND_MINUTES); + const targetScrollLeft = Math.max( + 0, + timeToOffset(rounded, festivalStart) - container.clientWidth / 2, + ); + // A clamped target (e.g. first-day jump) centers on a different moment + // than requested; write the one the viewport actually settles on. + const settledMoment = roundToNearestMinutes( + offsetToTime(targetScrollLeft + container.clientWidth / 2, festivalStart), + SCROLL_ROUND_MINUTES, + ); + + container.scrollTo({ left: targetScrollLeft, behavior: "smooth" }); + + navigate({ + to: ".", + search: (prev) => ({ ...prev, scrollTo: settledMoment.toISOString() }), + replace: true, + }); + } + + return { jumpTo }; } diff --git a/src/lib/timeUtils.test.ts b/src/lib/timeUtils.test.ts index 0632e086..62ccb5bd 100644 --- a/src/lib/timeUtils.test.ts +++ b/src/lib/timeUtils.test.ts @@ -10,6 +10,7 @@ import { convertLocalTimeToUTC, getFestivalDayKey, getFestivalDayLabel, + getFestivalDayShortLabel, getFestivalHour, } from "./timeUtils"; @@ -436,6 +437,25 @@ describe("getFestivalDayLabel", () => { }); }); +describe("getFestivalDayShortLabel", () => { + it("returns null for null input", () => { + expect(getFestivalDayShortLabel(null)).toBeNull(); + }); + + it("returns null for invalid input", () => { + expect(getFestivalDayShortLabel("invalid")).toBeNull(); + }); + + it("formats a day-key into a short weekday + date label", () => { + expect(getFestivalDayShortLabel("2024-12-16")).toBe("Mon 16"); + }); + + it("disambiguates repeated weekdays across a multi-weekend festival", () => { + expect(getFestivalDayShortLabel("2024-12-13")).toBe("Fri 13"); + expect(getFestivalDayShortLabel("2024-12-20")).toBe("Fri 20"); + }); +}); + describe("getFestivalHour", () => { it("returns null for null input", () => { expect(getFestivalHour(null, "Europe/Lisbon")).toBeNull(); diff --git a/src/lib/timeUtils.ts b/src/lib/timeUtils.ts index 99e5d899..68ce282c 100644 --- a/src/lib/timeUtils.ts +++ b/src/lib/timeUtils.ts @@ -194,6 +194,15 @@ export function getFestivalDayLabel(dayKey: string | null): string | null { return format(date, "EEEE, MMM d"); } +// Short weekday + date label, e.g. "Thu 13"; the date disambiguates repeated +// weekday names at multi-weekend festivals. +export function getFestivalDayShortLabel(dayKey: string | null): string | null { + if (!dayKey) return null; + const date = parseISO(dayKey); + if (!isValid(date)) return null; + return format(date, "EEE d"); +} + // The wall-clock hour (0-23) a UTC timestamp falls on in the festival's // timezone, for time-of-day filters (morning/afternoon/evening). export function getFestivalHour( diff --git a/src/lib/timelineDayJump.test.ts b/src/lib/timelineDayJump.test.ts new file mode 100644 index 00000000..51a9ecd2 --- /dev/null +++ b/src/lib/timelineDayJump.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { getDayJumpMoment } from "./timelineDayJump"; +import type { ScheduleDay } from "@/hooks/useScheduleData"; + +const TIMEZONE = "Europe/Lisbon"; // UTC+1 in July (WEST) + +describe("getDayJumpMoment", () => { + it("returns the earliest set start across all stages", () => { + const day: ScheduleDay = { + date: "2025-07-13", + displayDate: "2025-07-13", + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 0, + sets: [ + { + id: "set-1", + name: "Set 1", + artists: [], + startTime: new Date("2025-07-13T18:00:00Z"), + }, + ], + }, + { + id: "stage-2", + name: "Second Stage", + stage_order: 1, + sets: [ + { + id: "set-2", + name: "Set 2", + artists: [], + startTime: new Date("2025-07-13T15:00:00Z"), + }, + ], + }, + ], + }; + + const moment = getDayJumpMoment(day, TIMEZONE); + expect(moment.getTime()).toBe(new Date("2025-07-13T15:00:00Z").getTime()); + }); + + it("ignores sets without a start time", () => { + const day = buildDay("2025-07-13", [ + undefined, + new Date("2025-07-13T20:00:00Z"), + ]); + + const moment = getDayJumpMoment(day, TIMEZONE); + expect(moment.getTime()).toBe(new Date("2025-07-13T20:00:00Z").getTime()); + }); + + it("falls back to festival-timezone midnight when the day has no timed sets", () => { + const day = buildDay("2025-07-13", []); + + const moment = getDayJumpMoment(day, TIMEZONE); + // 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:00Z").getTime()); + }); +}); + +function buildDay( + date: string, + startTimes: (Date | undefined)[], +): ScheduleDay { + return { + date, + displayDate: date, + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 0, + sets: startTimes.map((startTime, index) => ({ + id: `set-${index}`, + name: `Set ${index}`, + artists: [], + startTime, + })), + }, + ], + }; +} diff --git a/src/lib/timelineDayJump.ts b/src/lib/timelineDayJump.ts new file mode 100644 index 00000000..9b0bd4a6 --- /dev/null +++ b/src/lib/timelineDayJump.ts @@ -0,0 +1,21 @@ +import { fromZonedTime } from "date-fns-tz"; +import type { ScheduleDay } from "@/hooks/useScheduleData"; + +// The moment a day jump centers on: the day's earliest set start (midnight is +// usually dead timeline), falling back to festival-timezone midnight. +export function getDayJumpMoment(day: ScheduleDay, timezone: string): Date { + let earliestSetStart: Date | null = null; + + day.stages.forEach((stage) => { + stage.sets.forEach((set) => { + if ( + set.startTime && + (!earliestSetStart || set.startTime < earliestSetStart) + ) { + earliestSetStart = set.startTime; + } + }); + }); + + return earliestSetStart ?? fromZonedTime(`${day.date}T00:00:00`, timezone); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/DayJumpButtons.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/DayJumpButtons.tsx new file mode 100644 index 00000000..5da0c399 --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/DayJumpButtons.tsx @@ -0,0 +1,34 @@ +import { Button } from "@/components/ui/button"; +import { getFestivalDayShortLabel } from "@/lib/timeUtils"; +import { getDayJumpMoment } from "@/lib/timelineDayJump"; +import type { ScheduleDay } from "@/hooks/useScheduleData"; + +interface DayJumpButtonsProps { + days: ScheduleDay[]; + timezone: string; + onJumpToDay: (moment: Date) => void; +} + +export function DayJumpButtons({ + days, + timezone, + onJumpToDay, +}: DayJumpButtonsProps) { + return ( + <> + {days.map((day) => ( + + ))} + + ); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx index 56f600b4..0056fd87 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx @@ -96,6 +96,8 @@ export function Timeline() { diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx index b2156dc3..832e1553 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx @@ -1,51 +1,63 @@ import { useRef } from "react"; import { TimeScale } from "./TimeScale"; import { StageRow } from "./StageRow"; +import { TimelineToolbar } from "./TimelineToolbar"; import type { TimelineData } from "@/lib/timelineCalculator"; +import type { ScheduleDay } from "@/hooks/useScheduleData"; import { useTimelineScrollSync } from "@/hooks/useTimelineScrollSync"; interface TimelineContainerProps { timelineData: TimelineData; timezone: string; + scheduleDays: ScheduleDay[]; + selectedDay: string; } export function TimelineContainer({ timelineData, timezone, + scheduleDays, + selectedDay, }: TimelineContainerProps) { const scrollContainerRef = useRef(null); - useTimelineScrollSync({ + const { jumpTo } = useTimelineScrollSync({ scrollContainerRef, festivalStart: timelineData.festivalStart, timezone, }); return ( -
- {/* Time Scale */} - + +
+ - {/* Stage Rows */} -
- {timelineData.stages.map((stage) => ( - - ))} +
+ {timelineData.stages.map((stage) => ( + + ))} +
-
+ ); } diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx new file mode 100644 index 00000000..4f996954 --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx @@ -0,0 +1,38 @@ +import { DayJumpButtons } from "./DayJumpButtons"; +import type { ScheduleDay } from "@/hooks/useScheduleData"; + +interface TimelineToolbarProps { + days: ScheduleDay[]; + selectedDay: string; + timezone: string; + onJumpToDay: (moment: Date) => void; +} + +// Sticky nav toolbar above the Timeline strip. Navigation scrolls, it never +// filters; with a day filter active only that day's button shows. +export function TimelineToolbar({ + days, + selectedDay, + timezone, + onJumpToDay, +}: TimelineToolbarProps) { + const visibleDays = + selectedDay === "all" + ? days + : days.filter((day) => day.date === selectedDay); + + if (visibleDays.length === 0) return null; + + return ( +
+ +
+ ); +} diff --git a/tests/e2e/timeline-day-toolbar.spec.ts b/tests/e2e/timeline-day-toolbar.spec.ts new file mode 100644 index 00000000..bb693315 --- /dev/null +++ b/tests/e2e/timeline-day-toolbar.spec.ts @@ -0,0 +1,125 @@ +import { test, expect } from "@playwright/test"; + +// Seeded in supabase/seed.sql: festival slug "test", edition slug "2025", +// three festival days (Jul 12-14, 2025) each with timed sets. +const TIMELINE_PATH = "/festivals/test/editions/2025/schedule/timeline"; +const SCROLL_ANIMATION_WAIT_MS = 800; // > smooth-scroll animation + the ~300ms debounce + +test.describe("Timeline day-jump toolbar", () => { + test("renders one sticky button per festival day, labeled weekday + date", 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 toolbar = page.getByTestId("timeline-day-toolbar"); + await expect(toolbar).toBeVisible(); + + const dayButtons = toolbar.getByRole("button"); + const count = await dayButtons.count(); + expect(count).toBeGreaterThanOrEqual(3); + + for (let i = 0; i < count; i++) { + const label = (await dayButtons.nth(i).textContent())?.trim() ?? ""; + // e.g. "Sat 12" - abbreviated weekday, then day-of-month. + expect(label).toMatch(/^[A-Za-z]{3} \d{1,2}$/); + } + }); + + test("tapping a day button writes scrollTo and smooth-scrolls the strip", 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); + + const scrollLeftBeforeJump = await scrollContainer.evaluate( + (el) => el.scrollLeft, + ); + + const dayButtons = page.getByTestId("timeline-day-toolbar").getByRole("button"); + // Jump to the last day, which should be far from the initial viewport. + await dayButtons.last().click(); + + await page.waitForTimeout(SCROLL_ANIMATION_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"); + + const scrollLeftAfterJump = await scrollContainer.evaluate( + (el) => el.scrollLeft, + ); + expect(scrollLeftAfterJump).not.toBe(scrollLeftBeforeJump); + + // The write must be stable: the post-scroll debounced write settles on + // the same moment jumpTo wrote, so the URL doesn't drift afterwards. + await page.waitForTimeout(SCROLL_ANIMATION_WAIT_MS); + expect(new URL(page.url()).searchParams.get("scrollTo")).toBe(scrollTo); + }); + + test("jumping to the first day clamps to the strip start without URL drift", 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 dayButtons = page + .getByTestId("timeline-day-toolbar") + .getByRole("button"); + // Move away first so the jump back is observable. + await dayButtons.last().click(); + await page.waitForTimeout(SCROLL_ANIMATION_WAIT_MS); + + await dayButtons.first().click(); + await page.waitForTimeout(SCROLL_ANIMATION_WAIT_MS); + + const scrollTo = new URL(page.url()).searchParams.get("scrollTo"); + expect(scrollTo).toBeTruthy(); + + await page.waitForTimeout(SCROLL_ANIMATION_WAIT_MS); + expect(new URL(page.url()).searchParams.get("scrollTo")).toBe(scrollTo); + }); + + test("with a day filter active, only that day's button renders", 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 toolbar = page.getByTestId("timeline-day-toolbar"); + const allDaysButtons = toolbar.getByRole("button"); + const totalDays = await allDaysButtons.count(); + expect(totalDays).toBeGreaterThanOrEqual(2); + + const firstDayLabel = (await allDaysButtons.first().textContent())?.trim(); + + await page.goto(`${TIMELINE_PATH}?day=2025-07-12`); + const filteredToolbar = page.getByTestId("timeline-day-toolbar"); + await expect(filteredToolbar).toBeVisible(); + + const filteredButtons = filteredToolbar.getByRole("button"); + await expect(filteredButtons).toHaveCount(1); + expect((await filteredButtons.first().textContent())?.trim()).toBe( + firstDayLabel, + ); + }); +});