From 8975e81d65b598153a5f9ad1192b884cd0060d5c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:20:18 +0000 Subject: [PATCH 01/26] refactor(timeline): geometry owns time-pixel mapping Extract timeToOffset/offsetToTime as the single place the 2px/minute timeline scale lives; TimeScale and set positioning now consume them instead of re-deriving the 2/120 constants. Closes #191 --- src/lib/timelineCalculator.test.ts | 265 ++++++++++++++++++ src/lib/timelineCalculator.ts | 43 ++- .../tabs/ScheduleTab/horizontal/TimeScale.tsx | 10 +- 3 files changed, 289 insertions(+), 29 deletions(-) create mode 100644 src/lib/timelineCalculator.test.ts diff --git a/src/lib/timelineCalculator.test.ts b/src/lib/timelineCalculator.test.ts new file mode 100644 index 00000000..df0c9452 --- /dev/null +++ b/src/lib/timelineCalculator.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vitest"; +import { + calculateTimelineData, + offsetToTime, + timeToOffset, +} from "./timelineCalculator"; +import type { ScheduleDay, ScheduleSet } from "@/hooks/useScheduleData"; +import type { Stage } from "@/api/stages/types"; + +const PX_PER_MINUTE = 2; + +function makeSet(overrides: Partial = {}): ScheduleSet { + return { + id: "set-1", + name: "Artist", + artists: [], + ...overrides, + }; +} + +function makeStage(overrides: Partial = {}): Stage { + return { + id: "stage-1", + name: "Main Stage", + color: "#ff0000", + stage_order: 0, + ...overrides, + } as unknown as Stage; +} + +describe("timeToOffset", () => { + it("returns 0 when moment equals origin", () => { + const origin = new Date("2024-07-01T10:00:00Z"); + expect(timeToOffset(origin, origin)).toBe(0); + }); + + it("converts minutes after origin at 2px per minute", () => { + const origin = new Date("2024-07-01T10:00:00Z"); + const moment = new Date("2024-07-01T11:00:00Z"); // +60 minutes + expect(timeToOffset(moment, origin)).toBe(60 * PX_PER_MINUTE); + }); + + it("returns a negative offset when moment is before origin", () => { + const origin = new Date("2024-07-01T10:00:00Z"); + const moment = new Date("2024-07-01T09:30:00Z"); // -30 minutes + expect(timeToOffset(moment, origin)).toBe(-30 * PX_PER_MINUTE); + }); + + it("matches the legacy hour-scale constant (120px per hour)", () => { + const origin = new Date("2024-07-01T00:00:00Z"); + const moment = new Date("2024-07-01T03:00:00Z"); // 3 hours + expect(timeToOffset(moment, origin)).toBe(3 * 120); + }); +}); + +describe("offsetToTime", () => { + it("returns the origin when offset is 0", () => { + const origin = new Date("2024-07-01T10:00:00Z"); + expect(offsetToTime(0, origin).getTime()).toBe(origin.getTime()); + }); + + it("converts a pixel offset back into minutes after origin", () => { + const origin = new Date("2024-07-01T10:00:00Z"); + const offset = 60 * PX_PER_MINUTE; // 60 minutes + const result = offsetToTime(offset, origin); + expect(result.getTime()).toBe( + origin.getTime() + 60 * 60 * 1000, + ); + }); + + it("is the inverse of timeToOffset for whole-minute moments", () => { + const origin = new Date("2024-07-01T08:00:00Z"); + const moment = new Date("2024-07-01T10:37:00Z"); + const offset = timeToOffset(moment, origin); + expect(offsetToTime(offset, origin).getTime()).toBe(moment.getTime()); + }); + + it("round-trips through timeToOffset for negative offsets", () => { + const origin = new Date("2024-07-01T12:00:00Z"); + const moment = new Date("2024-07-01T10:15:00Z"); + const offset = timeToOffset(moment, origin); + expect(offset).toBeLessThan(0); + expect(offsetToTime(offset, origin).getTime()).toBe(moment.getTime()); + }); +}); + +describe("calculateTimelineData", () => { + it("returns null when there are no schedule days", () => { + expect( + calculateTimelineData(new Date(), new Date(), [], []), + ).toBeNull(); + }); + + it("returns null when festival dates are missing", () => { + const days: ScheduleDay[] = [ + { date: "2024-07-01", displayDate: "Jul 1", stages: [] }, + ]; + expect( + calculateTimelineData( + null as unknown as Date, + null as unknown as Date, + days, + [], + ), + ).toBeNull(); + }); + + it("rounds the earliest set time down to the top of the hour", () => { + const stage = makeStage(); + const days: ScheduleDay[] = [ + { + date: "2024-07-01", + displayDate: "Jul 1", + stages: [ + { + id: stage.id, + name: stage.name, + stage_order: 0, + sets: [ + makeSet({ + startTime: new Date("2024-07-01T10:37:00Z"), + endTime: new Date("2024-07-01T11:37:00Z"), + }), + ], + }, + ], + }, + ]; + + const data = calculateTimelineData( + new Date("2024-07-01T00:00:00Z"), + new Date("2024-07-02T00:00:00Z"), + days, + [stage], + ); + + expect(data).not.toBeNull(); + // Earliest set starts at 10:37 -> rounded down to 10:00 + expect(data!.festivalStart.getTime()).toBe( + new Date("2024-07-01T10:00:00Z").getTime(), + ); + // The set itself is offset from that rounded-down origin (37 minutes in) + const set = data!.stages[0].sets[0]; + expect(set.horizontalPosition?.left).toBe(37 * PX_PER_MINUTE); + }); + + it("rounds the latest set time up to the end of the hour", () => { + const stage = makeStage(); + const days: ScheduleDay[] = [ + { + date: "2024-07-01", + displayDate: "Jul 1", + stages: [ + { + id: stage.id, + name: stage.name, + stage_order: 0, + sets: [ + makeSet({ + startTime: new Date("2024-07-01T10:00:00Z"), + endTime: new Date("2024-07-01T11:15:00Z"), + }), + ], + }, + ], + }, + ]; + + const data = calculateTimelineData( + new Date("2024-07-01T00:00:00Z"), + new Date("2024-07-02T00:00:00Z"), + days, + [stage], + ); + + expect(data).not.toBeNull(); + // Latest set ends at 11:15 -> rounded up to 11:59:59.999 + expect(data!.festivalEnd.getTime()).toBe( + new Date("2024-07-01T11:59:59.999Z").getTime(), + ); + // totalWidth spans the full rounded-up hour boundary (2 hours from 10:00 to 12:00) + expect(data!.totalWidth).toBe(timeToOffset( + new Date("2024-07-01T12:00:00Z"), + new Date("2024-07-01T10:00:00Z"), + )); + }); + + it("applies the 100px minimum width to short sets without affecting the scale", () => { + const stage = makeStage(); + const days: ScheduleDay[] = [ + { + date: "2024-07-01", + displayDate: "Jul 1", + stages: [ + { + id: stage.id, + name: stage.name, + stage_order: 0, + sets: [ + makeSet({ + id: "short-set", + startTime: new Date("2024-07-01T10:00:00Z"), + endTime: new Date("2024-07-01T10:10:00Z"), // 10 minutes -> 20px raw + }), + makeSet({ + id: "long-set", + startTime: new Date("2024-07-01T11:00:00Z"), + endTime: new Date("2024-07-01T12:00:00Z"), // 60 minutes -> 120px raw + }), + ], + }, + ], + }, + ]; + + const data = calculateTimelineData( + new Date("2024-07-01T00:00:00Z"), + new Date("2024-07-02T00:00:00Z"), + days, + [stage], + ); + + const sets = data!.stages[0].sets; + const shortSet = sets.find((s) => s.id === "short-set"); + const longSet = sets.find((s) => s.id === "long-set"); + + expect(shortSet?.horizontalPosition?.width).toBe(100); // clamped to the minimum + expect(longSet?.horizontalPosition?.width).toBe(60 * PX_PER_MINUTE); // scale-derived, not clamped + }); + + it("positions the last time slot at the total width (axis upper boundary)", () => { + const stage = makeStage(); + const days: ScheduleDay[] = [ + { + date: "2024-07-01", + displayDate: "Jul 1", + stages: [ + { + id: stage.id, + name: stage.name, + stage_order: 0, + sets: [ + makeSet({ + startTime: new Date("2024-07-01T10:00:00Z"), + endTime: new Date("2024-07-01T11:00:00Z"), + }), + ], + }, + ], + }, + ]; + + const data = calculateTimelineData( + new Date("2024-07-01T00:00:00Z"), + new Date("2024-07-02T00:00:00Z"), + days, + [stage], + ); + + const lastSlot = data!.timeSlots[data!.timeSlots.length - 1]; + expect(timeToOffset(lastSlot, data!.festivalStart)).toBe( + data!.totalWidth, + ); + }); +}); diff --git a/src/lib/timelineCalculator.ts b/src/lib/timelineCalculator.ts index 9eefac13..df1c35b0 100644 --- a/src/lib/timelineCalculator.ts +++ b/src/lib/timelineCalculator.ts @@ -3,6 +3,17 @@ import type { ScheduleSet, ScheduleDay } from "@/hooks/useScheduleData"; import type { Stage } from "@/api/stages/types"; import { sortStagesByOrder } from "@/lib/stageUtils"; +const PX_PER_MINUTE = 2; + +export function timeToOffset(moment: Date, origin: Date): number { + return differenceInMinutes(moment, origin) * PX_PER_MINUTE; +} + +export function offsetToTime(offset: number, origin: Date): Date { + const minutes = offset / PX_PER_MINUTE; + return new Date(origin.getTime() + minutes * 60 * 1000); +} + export interface HorizontalTimelineSet extends ScheduleSet { horizontalPosition?: { left: number; @@ -48,15 +59,11 @@ export function calculateTimelineData( scheduleDays, stages, earliestTime, - ( - set: ScheduleSet, - startMinutes: number, - duration: number, - ): HorizontalTimelineSet => { + (set: ScheduleSet, origin: Date): HorizontalTimelineSet => { if (!set.startTime || !set.endTime) return set; - const left = startMinutes * 2; - const width = Math.max(duration * 2, 100); + const left = timeToOffset(set.startTime, origin); + const width = Math.max(timeToOffset(set.endTime, set.startTime), 100); return { ...set, @@ -68,10 +75,12 @@ export function calculateTimelineData( }, ); + const lastTimeSlot = timeSlots[timeSlots.length - 1]; + return { timeSlots, stages: unifiedStages, - totalWidth: totalHours * 120, + totalWidth: timeToOffset(lastTimeSlot, earliestTime), festivalStart: earliestTime, festivalEnd: latestTime, }; @@ -161,11 +170,7 @@ function processStageGroups( scheduleDays: ScheduleDay[], stages: Stage[], earliestTime: Date, - positionCalculator: ( - set: ScheduleSet, - startMinutes: number, - duration: number, - ) => T, + positionCalculator: (set: ScheduleSet, origin: Date) => T, ): Array<{ name: string; color: string | undefined; @@ -180,15 +185,9 @@ function processStageGroups( allStageGroups[stage.id] = []; } - const enhancedSets = stage.sets.map((set): T => { - if (!set.startTime || !set.endTime) - return positionCalculator(set, 0, 0); - - const startMinutes = differenceInMinutes(set.startTime, earliestTime); - const duration = differenceInMinutes(set.endTime, set.startTime); - - return positionCalculator(set, startMinutes, duration); - }); + const enhancedSets = stage.sets.map((set): T => + positionCalculator(set, earliestTime), + ); allStageGroups[stage.id].push(...enhancedSets); }); diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScale.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScale.tsx index ccd06353..f77a691f 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScale.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScale.tsx @@ -1,6 +1,6 @@ -import { differenceInMinutes } from "date-fns"; import { formatInTimeZone, fromZonedTime } from "date-fns-tz"; import { useEffect, useState, useRef } from "react"; +import { timeToOffset } from "@/lib/timelineCalculator"; interface TimeScaleProps { timeSlots: Date[]; @@ -41,11 +41,7 @@ export function TimeScale({ // Calculate position relative to festival start const festivalStart = timeSlots[0]; - const minutesFromStart = differenceInMinutes( - midnightOfNewDate, - festivalStart, - ); - const position = minutesFromStart * 2 + 20; // 2px per minute + const position = timeToOffset(midnightOfNewDate, festivalStart) + 20; changes.push({ date: midnightOfNewDate, position }); } @@ -175,7 +171,7 @@ export function TimeScale({
{formatInTimeZone(timeSlot, timezone, "HH:mm")} From 60d54ba72ecd72ac934cbd73c4420b970565a005 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:26:20 +0000 Subject: [PATCH 02/26] fix(timeline): make time-pixel mapping continuous differenceInMinutes truncates to whole minutes, so timeToOffset and offsetToTime did not round-trip for sub-minute moments. Base the conversion on epoch milliseconds; output is unchanged for whole-minute inputs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/lib/timelineCalculator.test.ts | 7 +++++++ src/lib/timelineCalculator.ts | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lib/timelineCalculator.test.ts b/src/lib/timelineCalculator.test.ts index df0c9452..ae868e7b 100644 --- a/src/lib/timelineCalculator.test.ts +++ b/src/lib/timelineCalculator.test.ts @@ -75,6 +75,13 @@ describe("offsetToTime", () => { expect(offsetToTime(offset, origin).getTime()).toBe(moment.getTime()); }); + it("is the inverse of timeToOffset for sub-minute moments", () => { + const origin = new Date("2024-07-01T08:00:00Z"); + const moment = new Date("2024-07-01T10:37:42.500Z"); + const offset = timeToOffset(moment, origin); + expect(offsetToTime(offset, origin).getTime()).toBe(moment.getTime()); + }); + it("round-trips through timeToOffset for negative offsets", () => { const origin = new Date("2024-07-01T12:00:00Z"); const moment = new Date("2024-07-01T10:15:00Z"); diff --git a/src/lib/timelineCalculator.ts b/src/lib/timelineCalculator.ts index df1c35b0..9edec888 100644 --- a/src/lib/timelineCalculator.ts +++ b/src/lib/timelineCalculator.ts @@ -6,7 +6,8 @@ import { sortStagesByOrder } from "@/lib/stageUtils"; const PX_PER_MINUTE = 2; export function timeToOffset(moment: Date, origin: Date): number { - return differenceInMinutes(moment, origin) * PX_PER_MINUTE; + const minutes = (moment.getTime() - origin.getTime()) / (60 * 1000); + return minutes * PX_PER_MINUTE; } export function offsetToTime(offset: number, origin: Date): Date { From 74531bcdb7bee252c63a29e6232ae2c40d243791 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:33:48 +0000 Subject: [PATCH 03/26] 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 3e83bff5..f611812a 100644 --- a/src/hooks/useTimelineUrlState.ts +++ b/src/hooks/useTimelineUrlState.ts @@ -8,8 +8,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 06f7d8ef..a5667417 100644 --- a/src/lib/searchSchemas.ts +++ b/src/lib/searchSchemas.ts @@ -40,6 +40,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 5a3eb9cd24392bf0604c8ea8786ca039016af68d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:42:48 +0000 Subject: [PATCH 04/26] feat(timeline): add day-jump toolbar with dated labels Sticky toolbar above the Timeline strip with one button per festival day (weekday + date, e.g. "Thu 13"). Tapping a day writes scrollTo and smooth-scrolls to that day's first set, via a new jumpTo(moment) on useTimelineScrollSync. When a day filter is active, only that day's button renders. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/hooks/useTimelineScrollSync.ts | 34 ++++++- src/lib/timeUtils.test.ts | 20 ++++ src/lib/timeUtils.ts | 10 ++ src/lib/timelineDayJump.test.ts | 86 +++++++++++++++++ src/lib/timelineDayJump.ts | 26 ++++++ .../ScheduleTab/horizontal/DayJumpButtons.tsx | 34 +++++++ .../tabs/ScheduleTab/horizontal/Timeline.tsx | 2 + .../horizontal/TimelineContainer.tsx | 58 +++++++----- .../horizontal/TimelineToolbar.tsx | 45 +++++++++ tests/e2e/timeline-day-toolbar.spec.ts | 93 +++++++++++++++++++ 10 files changed, 384 insertions(+), 24 deletions(-) create mode 100644 src/lib/timelineDayJump.test.ts create mode 100644 src/lib/timelineDayJump.ts create mode 100644 src/pages/EditionView/tabs/ScheduleTab/horizontal/DayJumpButtons.tsx create mode 100644 src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx create mode 100644 tests/e2e/timeline-day-toolbar.spec.ts diff --git a/src/hooks/useTimelineScrollSync.ts b/src/hooks/useTimelineScrollSync.ts index d7264d15..d67ce119 100644 --- a/src/hooks/useTimelineScrollSync.ts +++ b/src/hooks/useTimelineScrollSync.ts @@ -25,9 +25,18 @@ interface UseTimelineScrollSyncOptions { * - 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. + * - On demand, via the returned `jumpTo(moment)`: writes `scrollTo` + * immediately (history replace) and smooth-scrolls the container to + * center that moment. The scroll events fired mid-animation keep + * resetting the debounce above, so no intermediate value is written; + * once the animation settles, the debounce fires once more and writes + * back the actual centered moment, which lands on the same value (no + * feedback loop, no history spam - both writes use `replace`). * - * These two directions never trigger each other: the mount effect runs once - * and the scroll listener only ever navigates, never touches `scrollLeft`. + * These directions never trigger each other into a loop: the mount effect + * runs once, the scroll listener only ever navigates (never touches + * `scrollLeft`), and `jumpTo` is the only path that both navigates and + * scrolls, driven solely by explicit calls (day-jump toolbar clicks). */ export function useTimelineScrollSync({ scrollContainerRef, @@ -114,4 +123,25 @@ 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, + ); + + container.scrollTo({ left: targetScrollLeft, behavior: "smooth" }); + + navigate({ + to: ".", + search: (prev) => ({ ...prev, scrollTo: rounded.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..8a0ba77d 100644 --- a/src/lib/timeUtils.ts +++ b/src/lib/timeUtils.ts @@ -194,6 +194,16 @@ export function getFestivalDayLabel(dayKey: string | null): string | null { return format(date, "EEEE, MMM d"); } +// Short weekday + date label for a day-key, e.g. "Thu 13" - used where space +// is tight (day-jump toolbar buttons) and multi-weekend festivals need the +// date to disambiguate repeated weekday names. +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..f74adc7e --- /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) + +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, + })), + }, + ], + }; +} + +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()); + }); +}); diff --git a/src/lib/timelineDayJump.ts b/src/lib/timelineDayJump.ts new file mode 100644 index 00000000..719326c3 --- /dev/null +++ b/src/lib/timelineDayJump.ts @@ -0,0 +1,26 @@ +import { fromZonedTime } from "date-fns-tz"; +import type { ScheduleDay } from "@/hooks/useScheduleData"; + +/** + * The moment the timeline viewport should center on when jumping to a day + * from the day-jump toolbar: the day's earliest set start, since that's what + * a viewer actually wants centered (the day's midnight is usually a stretch + * of dead timeline with nothing scheduled). Falls back to festival-timezone + * midnight for a day with no timed sets yet. + */ +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 5cd0c0f4..2d8de42a 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx @@ -134,6 +134,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..da72f69f 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx @@ -1,51 +1,65 @@ 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 */} - + +
+ {/* Time Scale */} + - {/* Stage Rows */} -
- {timelineData.stages.map((stage) => ( - - ))} + {/* Stage Rows */} +
+ {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..b33b95f5 --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx @@ -0,0 +1,45 @@ +import { DayJumpButtons } from "./DayJumpButtons"; +import type { ScheduleDay } from "@/hooks/useScheduleData"; + +interface TimelineToolbarProps { + days: ScheduleDay[]; + selectedDay: string; + timezone: string; + onJumpToDay: (moment: Date) => void; +} + +/** + * Slim sticky toolbar above the Timeline strip. Only hosts day-jump buttons + * for now; the Now pill, "Show overview" toggle, and Filters trigger + * (upcoming stacked tickets) will render alongside them here. + * + * Navigation only ever scrolls - it never filters the strip. When a `day` + * filter is active, nav operates on what's rendered, so 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..fe5e9fc7 --- /dev/null +++ b/tests/e2e/timeline-day-toolbar.spec.ts @@ -0,0 +1,93 @@ +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); + }); + + 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, + ); + }); +}); From 9f4149f6038b0b573c51c67f0e7e5f1135d2bba4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:46:07 +0000 Subject: [PATCH 05/26] 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 4fbe2014522301e498cdaa09633b4b79cd4f73ee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:54:25 +0000 Subject: [PATCH 06/26] fix(timeline): jump writes the settled viewport moment When a day jump clamps at the strip start (first festival day), the requested moment is unreachable and the debounced post-scroll write would silently replace scrollTo with a different value. Write the clamped center moment up front so the URL never drifts; e2e now asserts write stability and covers the first-day clamp. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/hooks/useTimelineScrollSync.ts | 10 +++++++- tests/e2e/timeline-day-toolbar.spec.ts | 32 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/hooks/useTimelineScrollSync.ts b/src/hooks/useTimelineScrollSync.ts index c16e6b1a..6c078b14 100644 --- a/src/hooks/useTimelineScrollSync.ts +++ b/src/hooks/useTimelineScrollSync.ts @@ -140,12 +140,20 @@ export function useTimelineScrollSync({ 0, timeToOffset(rounded, festivalStart) - container.clientWidth / 2, ); + // Write the moment the viewport actually settles on: when the target + // clamps at the strip start (e.g. jumping to the first day), the real + // center differs from the requested moment, and the post-scroll + // debounced write must land on the same value. + const settledMoment = roundToNearestMinutes( + offsetToTime(targetScrollLeft + container.clientWidth / 2, festivalStart), + SCROLL_ROUND_MINUTES, + ); container.scrollTo({ left: targetScrollLeft, behavior: "smooth" }); navigate({ to: ".", - search: (prev) => ({ ...prev, scrollTo: rounded.toISOString() }), + search: (prev) => ({ ...prev, scrollTo: settledMoment.toISOString() }), replace: true, }); } diff --git a/tests/e2e/timeline-day-toolbar.spec.ts b/tests/e2e/timeline-day-toolbar.spec.ts index fe5e9fc7..bb693315 100644 --- a/tests/e2e/timeline-day-toolbar.spec.ts +++ b/tests/e2e/timeline-day-toolbar.spec.ts @@ -61,6 +61,38 @@ test.describe("Timeline day-jump toolbar", () => { (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 ({ From 1a0431bbe0726a79447c217c91492f1086930241 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:55:09 +0000 Subject: [PATCH 07/26] feat(timeline): Now pill and current-time indicator Adds a Now pill in the day-jump toolbar and a dashed current-time indicator on the Timeline strip, both rendered only while now (festival timezone) falls inside the rendered timeline's festival window. Extends resolveTimelineMountMoment's precedence with a now-1h rule (scrollTo -> day filter -> now-1h inside window -> festival start), backed by a new isNowWithinFestivalWindow pure function and a useNow(60s) hook that is the sole place `new Date()` is read for this feature. Closes #194 --- src/hooks/useNow.ts | 21 ++++ src/hooks/useTimelineScrollSync.ts | 11 +- src/lib/timelineMountMoment.test.ts | 99 +++++++++++++++- src/lib/timelineMountMoment.ts | 50 +++++++- .../horizontal/CurrentTimeIndicator.tsx | 22 ++++ .../tabs/ScheduleTab/horizontal/NowButton.tsx | 25 ++++ .../tabs/ScheduleTab/horizontal/Timeline.tsx | 3 + .../horizontal/TimelineContainer.tsx | 56 ++++++--- .../horizontal/TimelineToolbar.tsx | 14 ++- tests/e2e/timeline-now-indicator.spec.ts | 111 ++++++++++++++++++ 10 files changed, 382 insertions(+), 30 deletions(-) create mode 100644 src/hooks/useNow.ts create mode 100644 src/pages/EditionView/tabs/ScheduleTab/horizontal/CurrentTimeIndicator.tsx create mode 100644 src/pages/EditionView/tabs/ScheduleTab/horizontal/NowButton.tsx create mode 100644 tests/e2e/timeline-now-indicator.spec.ts diff --git a/src/hooks/useNow.ts b/src/hooks/useNow.ts new file mode 100644 index 00000000..0a56a452 --- /dev/null +++ b/src/hooks/useNow.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; + +const DEFAULT_INTERVAL_MS = 60_000; + +/** + * Ticks a fresh `Date` on a fixed interval (60s by default). The Now pill and + * the current-time indicator on the Timeline need a live "now" to re-render + * against; the pure functions they feed (`isNowWithinFestivalWindow`, + * `resolveTimelineMountMoment`, `timeToOffset`) never call `new Date()` + * themselves - this hook is the one place that does, on their behalf. + */ +export function useNow(intervalMs = DEFAULT_INTERVAL_MS): Date { + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const id = window.setInterval(() => setNow(new Date()), intervalMs); + return () => window.clearInterval(id); + }, [intervalMs]); + + return now; +} diff --git a/src/hooks/useTimelineScrollSync.ts b/src/hooks/useTimelineScrollSync.ts index c16e6b1a..9ae34628 100644 --- a/src/hooks/useTimelineScrollSync.ts +++ b/src/hooks/useTimelineScrollSync.ts @@ -13,7 +13,11 @@ const SCROLL_ROUND_MINUTES = 5; interface UseTimelineScrollSyncOptions { scrollContainerRef: RefObject; festivalStart: Date; + festivalEnd: Date; timezone: string; + /** Current moment, injected by the caller (see `useNow`). Only read once, + * at mount, to resolve the "now" mount-precedence rule. */ + now: Date; } /** @@ -21,7 +25,8 @@ interface UseTimelineScrollSyncOptions { * `scrollTo` URL param: * * - On mount only: centers the viewport per `resolveTimelineMountMoment`'s - * precedence (scrollTo -> day filter -> festival start). + * precedence (scrollTo -> day filter -> now-1h inside the festival + * window -> 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. @@ -41,7 +46,9 @@ interface UseTimelineScrollSyncOptions { export function useTimelineScrollSync({ scrollContainerRef, festivalStart, + festivalEnd, timezone, + now, }: UseTimelineScrollSyncOptions) { const route = "/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline" as const; @@ -72,6 +79,8 @@ export function useTimelineScrollSync({ day, timezone, festivalStart, + festivalEnd, + now, }); const targetScrollLeft = Math.max( diff --git a/src/lib/timelineMountMoment.test.ts b/src/lib/timelineMountMoment.test.ts index fbab3ed2..48810195 100644 --- a/src/lib/timelineMountMoment.test.ts +++ b/src/lib/timelineMountMoment.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it } from "vitest"; import { + isNowWithinFestivalWindow, resolveTimelineMountMoment, roundToNearestMinutes, } from "./timelineMountMoment"; const TIMEZONE = "Europe/Lisbon"; // UTC+1 in July (WEST) const FESTIVAL_START = new Date("2025-07-12T10:00:00Z"); +const FESTIVAL_END = new Date("2025-07-14T23:00:00Z"); +// Comfortably inside [FESTIVAL_START, FESTIVAL_END]. +const NOW_INSIDE_WINDOW = new Date("2025-07-13T20:00:00Z"); +// Before the festival window opens. +const NOW_BEFORE_WINDOW = new Date("2025-07-10T10:00:00Z"); +// After the festival window closes. +const NOW_AFTER_WINDOW = new Date("2025-07-15T10:00:00Z"); describe("resolveTimelineMountMoment", () => { it("prefers scrollTo when present and valid", () => { @@ -14,6 +22,8 @@ describe("resolveTimelineMountMoment", () => { day: "2025-07-12", timezone: TIMEZONE, festivalStart: FESTIVAL_START, + festivalEnd: FESTIVAL_END, + now: NOW_INSIDE_WINDOW, }); expect(moment.getTime()).toBe( @@ -27,6 +37,8 @@ describe("resolveTimelineMountMoment", () => { day: "2025-07-13", timezone: TIMEZONE, festivalStart: FESTIVAL_START, + festivalEnd: FESTIVAL_END, + now: NOW_INSIDE_WINDOW, }); // Midnight in Europe/Lisbon (UTC+1 in July) is 23:00 UTC the prior day. @@ -41,6 +53,8 @@ describe("resolveTimelineMountMoment", () => { day: "2025-07-13", timezone: TIMEZONE, festivalStart: FESTIVAL_START, + festivalEnd: FESTIVAL_END, + now: NOW_INSIDE_WINDOW, }); expect(moment.getTime()).toBe( @@ -48,23 +62,55 @@ describe("resolveTimelineMountMoment", () => { ); }); - it("falls back to festivalStart when day filter is 'all' and scrollTo is absent", () => { + it("falls back to now minus ~1h when day filter is 'all', scrollTo is absent, and now is inside the window", () => { const moment = resolveTimelineMountMoment({ scrollTo: undefined, day: "all", timezone: TIMEZONE, festivalStart: FESTIVAL_START, + festivalEnd: FESTIVAL_END, + now: NOW_INSIDE_WINDOW, + }); + + expect(moment.getTime()).toBe( + NOW_INSIDE_WINDOW.getTime() - 60 * 60 * 1000, + ); + }); + + it("falls back to festivalStart when now is before the festival window", () => { + const moment = resolveTimelineMountMoment({ + scrollTo: undefined, + day: "all", + timezone: TIMEZONE, + festivalStart: FESTIVAL_START, + festivalEnd: FESTIVAL_END, + now: NOW_BEFORE_WINDOW, }); expect(moment.getTime()).toBe(FESTIVAL_START.getTime()); }); - it("falls back to festivalStart when scrollTo is invalid and day is 'all'", () => { + it("falls back to festivalStart when now is after the festival window", () => { + const moment = resolveTimelineMountMoment({ + scrollTo: undefined, + day: "all", + timezone: TIMEZONE, + festivalStart: FESTIVAL_START, + festivalEnd: FESTIVAL_END, + now: NOW_AFTER_WINDOW, + }); + + expect(moment.getTime()).toBe(FESTIVAL_START.getTime()); + }); + + it("falls back to festivalStart when scrollTo is invalid, day is 'all', and now is outside the window", () => { const moment = resolveTimelineMountMoment({ scrollTo: "garbage", day: "all", timezone: TIMEZONE, festivalStart: FESTIVAL_START, + festivalEnd: FESTIVAL_END, + now: NOW_AFTER_WINDOW, }); expect(moment.getTime()).toBe(FESTIVAL_START.getTime()); @@ -76,12 +122,61 @@ describe("resolveTimelineMountMoment", () => { day: "2025-07-13", timezone: TIMEZONE, festivalStart: FESTIVAL_START, + festivalEnd: FESTIVAL_END, + now: NOW_INSIDE_WINDOW, }); expect(moment.getTime()).toBe( new Date("2025-07-14T12:00:00.000Z").getTime(), ); }); + + it("the day filter takes precedence over the now rule", () => { + const moment = resolveTimelineMountMoment({ + scrollTo: undefined, + day: "2025-07-13", + timezone: TIMEZONE, + festivalStart: FESTIVAL_START, + festivalEnd: FESTIVAL_END, + now: NOW_INSIDE_WINDOW, + }); + + expect(moment.getTime()).toBe( + new Date("2025-07-12T23:00:00.000Z").getTime(), + ); + }); +}); + +describe("isNowWithinFestivalWindow", () => { + it("is true strictly inside the window", () => { + expect( + isNowWithinFestivalWindow(NOW_INSIDE_WINDOW, FESTIVAL_START, FESTIVAL_END), + ).toBe(true); + }); + + it("is true exactly at the window's start (inclusive)", () => { + expect( + isNowWithinFestivalWindow(FESTIVAL_START, FESTIVAL_START, FESTIVAL_END), + ).toBe(true); + }); + + it("is true exactly at the window's end (inclusive)", () => { + expect( + isNowWithinFestivalWindow(FESTIVAL_END, FESTIVAL_START, FESTIVAL_END), + ).toBe(true); + }); + + it("is false before the window opens", () => { + expect( + isNowWithinFestivalWindow(NOW_BEFORE_WINDOW, FESTIVAL_START, FESTIVAL_END), + ).toBe(false); + }); + + it("is false after the window closes", () => { + expect( + isNowWithinFestivalWindow(NOW_AFTER_WINDOW, FESTIVAL_START, FESTIVAL_END), + ).toBe(false); + }); }); describe("roundToNearestMinutes", () => { diff --git a/src/lib/timelineMountMoment.ts b/src/lib/timelineMountMoment.ts index 24cb169b..71937de0 100644 --- a/src/lib/timelineMountMoment.ts +++ b/src/lib/timelineMountMoment.ts @@ -8,21 +8,28 @@ export interface TimelineMountMomentInput { day: string; /** Festival's IANA timezone, used to resolve the day filter's start. */ timezone: string; - /** Timeline geometry origin (earliest moment on the timeline). */ + /** + * Timeline geometry origin (earliest moment on the timeline) - also the + * start of the "festival window" the now-rule checks against, see + * `isNowWithinFestivalWindow`. + */ festivalStart: Date; + /** End of the rendered timeline strip - the window's other bound. */ + festivalEnd: Date; + /** Current moment, injected by the caller (never computed in here). */ + now: Date; } +const NOW_CONTEXT_MS = 60 * 60 * 1000; // ~1h of context before "now" + /** * 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). + * 3. Now minus ~1h of context, if `now` falls inside the festival window. + * 4. The festival start (timeline origin), as the final fallback. */ export function resolveTimelineMountMoment( input: TimelineMountMomentInput, @@ -30,10 +37,32 @@ export function resolveTimelineMountMoment( return ( momentFromScrollTo(input.scrollTo) ?? momentFromDayFilter(input.day, input.timezone) ?? + momentFromNow(input.now, input.festivalStart, input.festivalEnd) ?? input.festivalStart ); } +/** + * Whether `now` falls inside the festival window - the rendered timeline's + * own `festivalStart`/`festivalEnd` bounds (from `TimelineData`), not the + * edition's raw `start_date`/`end_date`. Those bounds already fold in the + * actual earliest/latest set times, so a moment inside this window is + * guaranteed to land on the rendered strip when converted via + * `timeToOffset` - the edition's calendar dates alone (parsed at UTC + * midnight) would risk the last festival day's evening sets reading as + * "outside the window". Inclusive of both ends. + */ +export function isNowWithinFestivalWindow( + now: Date, + festivalStart: Date, + festivalEnd: Date, +): boolean { + return ( + now.getTime() >= festivalStart.getTime() && + now.getTime() <= festivalEnd.getTime() + ); +} + function momentFromScrollTo(scrollTo: string | undefined): Date | null { if (!scrollTo) return null; const parsed = parseISO(scrollTo); @@ -50,6 +79,15 @@ function momentFromDayFilter(day: string, timezone: string): Date | null { } } +function momentFromNow( + now: Date, + festivalStart: Date, + festivalEnd: Date, +): Date | null { + if (!isNowWithinFestivalWindow(now, festivalStart, festivalEnd)) return null; + return new Date(now.getTime() - NOW_CONTEXT_MS); +} + /** * Rounds a moment to the nearest multiple of `minutes` (default 5), used to * keep `scrollTo` URL writes coarse-grained instead of pixel-precise. diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/CurrentTimeIndicator.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/CurrentTimeIndicator.tsx new file mode 100644 index 00000000..1d80a889 --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/CurrentTimeIndicator.tsx @@ -0,0 +1,22 @@ +interface CurrentTimeIndicatorProps { + left: number; +} + +/** + * Vertical dashed line marking "now" on the timeline strip - the treatment + * that tested best in the prototype (see issue #194). Rendered only while + * `isNowWithinFestivalWindow` is true; its `left` position is recomputed by + * the caller every 60s (via `useNow`), but the viewport is never scrolled to + * follow it. + */ +export function CurrentTimeIndicator({ left }: CurrentTimeIndicatorProps) { + return ( +
+
+
+ ); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/NowButton.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/NowButton.tsx new file mode 100644 index 00000000..1287496c --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/NowButton.tsx @@ -0,0 +1,25 @@ +import { Button } from "@/components/ui/button"; + +interface NowButtonProps { + onJumpToNow: () => void; +} + +/** + * The "Now" pill in the day-jump toolbar. Rendered only while the current + * time (festival timezone) falls inside the festival window - see + * `isNowWithinFestivalWindow` - never disabled, just absent otherwise. + */ +export function NowButton({ onJumpToNow }: NowButtonProps) { + return ( + + ); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx index 2d8de42a..9046d938 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx @@ -8,6 +8,7 @@ import { TimelineContainer } from "./TimelineContainer"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { useSetsByEditionQuery as useEditionSetsQuery } from "@/api/sets/useSetsByEdition"; import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; +import { useNow } from "@/hooks/useNow"; import { getFestivalHour } from "@/lib/timeUtils"; import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; import { useScheduleReveal } from "@/hooks/useScheduleReveal"; @@ -19,6 +20,7 @@ export function Timeline() { from: "/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline", }); const { canShowTime } = useScheduleReveal(); + const now = useNow(); const { data: editionSets = [], isLoading: setsLoading } = useEditionSetsQuery(edition.id); const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); @@ -136,6 +138,7 @@ export function Timeline() { timezone={festival.timezone} scheduleDays={scheduleDays} selectedDay={selectedDay} + now={now} />
diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx index da72f69f..07218224 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx @@ -2,7 +2,9 @@ import { useRef } from "react"; import { TimeScale } from "./TimeScale"; import { StageRow } from "./StageRow"; import { TimelineToolbar } from "./TimelineToolbar"; -import type { TimelineData } from "@/lib/timelineCalculator"; +import { CurrentTimeIndicator } from "./CurrentTimeIndicator"; +import { timeToOffset, type TimelineData } from "@/lib/timelineCalculator"; +import { isNowWithinFestivalWindow } from "@/lib/timelineMountMoment"; import type { ScheduleDay } from "@/hooks/useScheduleData"; import { useTimelineScrollSync } from "@/hooks/useTimelineScrollSync"; @@ -11,6 +13,7 @@ interface TimelineContainerProps { timezone: string; scheduleDays: ScheduleDay[]; selectedDay: string; + now: Date; } export function TimelineContainer({ @@ -18,15 +21,24 @@ export function TimelineContainer({ timezone, scheduleDays, selectedDay, + now, }: TimelineContainerProps) { const scrollContainerRef = useRef(null); const { jumpTo } = useTimelineScrollSync({ scrollContainerRef, festivalStart: timelineData.festivalStart, + festivalEnd: timelineData.festivalEnd, timezone, + now, }); + const showNowIndicator = isNowWithinFestivalWindow( + now, + timelineData.festivalStart, + timelineData.festivalEnd, + ); + return ( <> jumpTo(now)} />
- {/* Time Scale */} - +
+ {/* Time Scale */} + + + {/* Stage Rows */} +
+ {timelineData.stages.map((stage) => ( + + ))} +
- {/* Stage Rows */} -
- {timelineData.stages.map((stage) => ( - - ))} + )}
diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx index b33b95f5..aebf4613 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx @@ -1,4 +1,5 @@ import { DayJumpButtons } from "./DayJumpButtons"; +import { NowButton } from "./NowButton"; import type { ScheduleDay } from "@/hooks/useScheduleData"; interface TimelineToolbarProps { @@ -6,12 +7,14 @@ interface TimelineToolbarProps { selectedDay: string; timezone: string; onJumpToDay: (moment: Date) => void; + showNowButton: boolean; + onJumpToNow: () => void; } /** - * Slim sticky toolbar above the Timeline strip. Only hosts day-jump buttons - * for now; the Now pill, "Show overview" toggle, and Filters trigger - * (upcoming stacked tickets) will render alongside them here. + * Slim sticky toolbar above the Timeline strip. Hosts day-jump buttons and + * the Now pill; the "Show overview" toggle and Filters trigger (upcoming + * stacked tickets) will render alongside them here. * * Navigation only ever scrolls - it never filters the strip. When a `day` * filter is active, nav operates on what's rendered, so only that day's @@ -22,13 +25,15 @@ export function TimelineToolbar({ selectedDay, timezone, onJumpToDay, + showNowButton, + onJumpToNow, }: TimelineToolbarProps) { const visibleDays = selectedDay === "all" ? days : days.filter((day) => day.date === selectedDay); - if (visibleDays.length === 0) return null; + if (visibleDays.length === 0 && !showNowButton) return null; return (
+ {showNowButton && }
); } diff --git a/tests/e2e/timeline-now-indicator.spec.ts b/tests/e2e/timeline-now-indicator.spec.ts new file mode 100644 index 00000000..0c45cb26 --- /dev/null +++ b/tests/e2e/timeline-now-indicator.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from "@playwright/test"; + +// Seeded in supabase/seed.sql: festival slug "test", edition slug "2025", +// three festival days (Jul 12-14, 2025). Sets span from Fri Jul 12 16:00 UTC +// (earliest set start) through Sun Jul 14 23:00 UTC (latest set end) - the +// rendered timeline's festival window. +const TIMELINE_PATH = "/festivals/test/editions/2025/schedule/timeline"; +const SCROLL_ANIMATION_WAIT_MS = 800; // > smooth-scroll animation + the ~300ms debounce + +// Comfortably inside the seeded festival window. +const NOW_INSIDE_WINDOW = new Date("2025-07-13T12:00:00Z"); +// Two days before the earliest seeded set. +const NOW_BEFORE_WINDOW = new Date("2025-07-10T12:00:00Z"); +// Two days after the latest seeded set ends. +const NOW_AFTER_WINDOW = new Date("2025-07-16T12:00:00Z"); + +test.describe("Timeline Now pill and current-time indicator", () => { + test("render when now falls inside the festival window", async ({ + page, + }) => { + await page.clock.setFixedTime(NOW_INSIDE_WINDOW); + 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 nowButton = page.getByTestId("now-jump-button"); + await expect(nowButton).toBeVisible(); + + const indicator = page.getByTestId("timeline-now-indicator"); + await expect(indicator).toBeVisible(); + + const left = await indicator.evaluate((el) => + parseFloat((el as HTMLElement).style.left), + ); + expect(left).toBeGreaterThan(0); + expect(Number.isFinite(left)).toBe(true); + }); + + test("are absent when now is before the festival window", async ({ + page, + }) => { + await page.clock.setFixedTime(NOW_BEFORE_WINDOW); + 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 expect(page.getByTestId("now-jump-button")).toHaveCount(0); + await expect(page.getByTestId("timeline-now-indicator")).toHaveCount(0); + }); + + test("are absent when now is after the festival window", async ({ + page, + }) => { + await page.clock.setFixedTime(NOW_AFTER_WINDOW); + 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 expect(page.getByTestId("now-jump-button")).toHaveCount(0); + await expect(page.getByTestId("timeline-now-indicator")).toHaveCount(0); + }); + + test("tapping Now writes scrollTo and smooth-scrolls to the current time", async ({ + page, + }) => { + await page.clock.setFixedTime(NOW_INSIDE_WINDOW); + 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 nowButton = page.getByTestId("now-jump-button"); + await expect(nowButton).toBeVisible(); + + // Scroll away first so the jump is observable. + await scrollContainer.evaluate((el) => { + el.scrollLeft = 0; + }); + + await nowButton.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(); + + const scrollToDate = new Date(scrollTo as string); + expect(scrollToDate.toString()).not.toBe("Invalid Date"); + // Rounded to 5-minute granularity, so allow a small window either side + // of the fixed "now". + expect( + Math.abs(scrollToDate.getTime() - NOW_INSIDE_WINDOW.getTime()), + ).toBeLessThan(5 * 60 * 1000); + + const scrollLeftAfterJump = await scrollContainer.evaluate( + (el) => el.scrollLeft, + ); + expect(scrollLeftAfterJump).toBeGreaterThan(0); + }); +}); From 7676c7dc34db8e6ecd49c20e9b211981acad47c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 19:06:29 +0000 Subject: [PATCH 08/26] fix(timeline): gate now pill on unfiltered schedule window Active stage/time filters shrank TimelineData's bounds and hid the Now pill mid-festival; the pill and the mount now-rule are now gated on a new calculateScheduleWindow(scheduleDays) computed from the unfiltered schedule, while the indicator stays gated on the rendered strip's own bounds. Also clamps the mount now-rule to max(now - 1h, window start) so a now within the window's first hour can't resolve before it opens. --- src/hooks/useTimelineScrollSync.ts | 18 ++- src/lib/timelineCalculator.test.ts | 128 ++++++++++++++++++ src/lib/timelineCalculator.ts | 23 ++++ src/lib/timelineMountMoment.test.ts | 51 +++++-- src/lib/timelineMountMoment.ts | 52 ++++--- .../tabs/ScheduleTab/horizontal/Timeline.tsx | 13 +- .../horizontal/TimelineContainer.tsx | 19 ++- 7 files changed, 264 insertions(+), 40 deletions(-) diff --git a/src/hooks/useTimelineScrollSync.ts b/src/hooks/useTimelineScrollSync.ts index 4c4dbdcf..3bed407e 100644 --- a/src/hooks/useTimelineScrollSync.ts +++ b/src/hooks/useTimelineScrollSync.ts @@ -1,7 +1,11 @@ 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 { + offsetToTime, + timeToOffset, + type ScheduleWindow, +} from "@/lib/timelineCalculator"; import { resolveTimelineMountMoment, roundToNearestMinutes, @@ -13,7 +17,9 @@ const SCROLL_ROUND_MINUTES = 5; interface UseTimelineScrollSyncOptions { scrollContainerRef: RefObject; festivalStart: Date; - festivalEnd: Date; + /** The UNFILTERED schedule window (`calculateScheduleWindow`), for the + * mount-precedence now-rule; null when no set has a time yet. */ + scheduleWindow: ScheduleWindow | null; timezone: string; /** Current moment, injected by the caller (see `useNow`). Only read once, * at mount, to resolve the "now" mount-precedence rule. */ @@ -25,8 +31,8 @@ interface UseTimelineScrollSyncOptions { * `scrollTo` URL param: * * - On mount only: centers the viewport per `resolveTimelineMountMoment`'s - * precedence (scrollTo -> day filter -> now-1h inside the festival - * window -> festival start). + * precedence (scrollTo -> day filter -> now-1h inside the unfiltered + * schedule window -> 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. @@ -46,7 +52,7 @@ interface UseTimelineScrollSyncOptions { export function useTimelineScrollSync({ scrollContainerRef, festivalStart, - festivalEnd, + scheduleWindow, timezone, now, }: UseTimelineScrollSyncOptions) { @@ -79,7 +85,7 @@ export function useTimelineScrollSync({ day, timezone, festivalStart, - festivalEnd, + scheduleWindow, now, }); diff --git a/src/lib/timelineCalculator.test.ts b/src/lib/timelineCalculator.test.ts index ae868e7b..43285e10 100644 --- a/src/lib/timelineCalculator.test.ts +++ b/src/lib/timelineCalculator.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + calculateScheduleWindow, calculateTimelineData, offsetToTime, timeToOffset, @@ -91,6 +92,133 @@ describe("offsetToTime", () => { }); }); +describe("calculateScheduleWindow", () => { + function makeDays(): ScheduleDay[] { + return [ + { + date: "2024-07-01", + displayDate: "Jul 1", + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 0, + sets: [ + makeSet({ + id: "day1-main", + startTime: new Date("2024-07-01T10:37:00Z"), + endTime: new Date("2024-07-01T11:30:00Z"), + }), + ], + }, + { + id: "stage-2", + name: "Club Stage", + stage_order: 1, + sets: [ + makeSet({ + id: "day1-club", + startTime: new Date("2024-07-01T12:00:00Z"), + endTime: new Date("2024-07-01T13:00:00Z"), + }), + ], + }, + ], + }, + { + date: "2024-07-02", + displayDate: "Jul 2", + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 0, + sets: [ + makeSet({ + id: "day2-main", + startTime: new Date("2024-07-02T20:00:00Z"), + endTime: new Date("2024-07-02T21:15:00Z"), + }), + ], + }, + ], + }, + ]; + } + + it("spans the earliest set start (hour-floored) to the latest set end (hour-ceiled) across all days and stages", () => { + const window = calculateScheduleWindow(makeDays()); + + expect(window).not.toBeNull(); + expect(window!.start.getTime()).toBe( + new Date("2024-07-01T10:00:00Z").getTime(), + ); + expect(window!.end.getTime()).toBe( + new Date("2024-07-02T21:59:59.999Z").getTime(), + ); + }); + + it("returns null when no set has a time", () => { + const days: ScheduleDay[] = [ + { + date: "2024-07-01", + displayDate: "Jul 1", + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 0, + sets: [makeSet()], + }, + ], + }, + ]; + + expect(calculateScheduleWindow(days)).toBeNull(); + }); + + it("returns null for an empty schedule", () => { + expect(calculateScheduleWindow([])).toBeNull(); + }); + + it("is unaffected by filtering only when fed the full schedule (a filtered subset yields a narrower window)", () => { + const allDays = makeDays(); + const fullWindow = calculateScheduleWindow(allDays); + + // Simulate a stage filter that keeps only stage-2's sets (day 1, + // 12:00-13:00 only). + const stageFilteredDays = allDays.map((day) => ({ + ...day, + stages: day.stages.filter((stage) => stage.id === "stage-2"), + })); + const stageFilteredWindow = calculateScheduleWindow(stageFilteredDays); + + // The full window is what time-awareness must be gated on: the filtered + // subset's window has shrunk on both ends. + expect(fullWindow!.end.getTime()).toBe( + new Date("2024-07-02T21:59:59.999Z").getTime(), + ); + expect(stageFilteredWindow!.start.getTime()).toBeGreaterThan( + fullWindow!.start.getTime(), + ); + expect(stageFilteredWindow!.end.getTime()).toBeLessThan( + fullWindow!.end.getTime(), + ); + + // Simulate a day filter that drops day 2 entirely. + const dayFilteredDays = allDays.map((day) => + day.date === "2024-07-01" ? day : { ...day, stages: [] }, + ); + const dayFilteredWindow = calculateScheduleWindow(dayFilteredDays); + expect(dayFilteredWindow!.end.getTime()).toBe( + new Date("2024-07-01T13:59:59.999Z").getTime(), + ); + expect(dayFilteredWindow!.end.getTime()).toBeLessThan( + fullWindow!.end.getTime(), + ); + }); +}); + describe("calculateTimelineData", () => { it("returns null when there are no schedule days", () => { expect( diff --git a/src/lib/timelineCalculator.ts b/src/lib/timelineCalculator.ts index 9edec888..14e8374a 100644 --- a/src/lib/timelineCalculator.ts +++ b/src/lib/timelineCalculator.ts @@ -15,6 +15,29 @@ export function offsetToTime(offset: number, origin: Date): Date { return new Date(origin.getTime() + minutes * 60 * 1000); } +export interface ScheduleWindow { + start: Date; + end: Date; +} + +/** + * The schedule's overall time window: earliest set start (floored to the + * hour) through latest set end (ceiled to the hour), scanned across ALL + * days/stages passed in. Callers deciding time-awareness (the Now pill, the + * mount-precedence now-rule) must feed this the UNFILTERED schedule days - + * unlike `TimelineData.festivalStart`/`festivalEnd`, which are computed from + * the filtered subset and shrink when a stage or time-of-day filter is + * active. Returns null when no set has a time yet. + */ +export function calculateScheduleWindow( + scheduleDays: ScheduleDay[], +): ScheduleWindow | null { + const start = calculateEarliestSetTime(scheduleDays); + const end = calculateLatestSetTime(scheduleDays); + if (!start || !end) return null; + return { start, end }; +} + export interface HorizontalTimelineSet extends ScheduleSet { horizontalPosition?: { left: number; diff --git a/src/lib/timelineMountMoment.test.ts b/src/lib/timelineMountMoment.test.ts index 48810195..3a02ff1b 100644 --- a/src/lib/timelineMountMoment.test.ts +++ b/src/lib/timelineMountMoment.test.ts @@ -8,7 +8,8 @@ import { const TIMEZONE = "Europe/Lisbon"; // UTC+1 in July (WEST) const FESTIVAL_START = new Date("2025-07-12T10:00:00Z"); const FESTIVAL_END = new Date("2025-07-14T23:00:00Z"); -// Comfortably inside [FESTIVAL_START, FESTIVAL_END]. +const SCHEDULE_WINDOW = { start: FESTIVAL_START, end: FESTIVAL_END }; +// Comfortably inside the schedule window. const NOW_INSIDE_WINDOW = new Date("2025-07-13T20:00:00Z"); // Before the festival window opens. const NOW_BEFORE_WINDOW = new Date("2025-07-10T10:00:00Z"); @@ -22,7 +23,7 @@ describe("resolveTimelineMountMoment", () => { day: "2025-07-12", timezone: TIMEZONE, festivalStart: FESTIVAL_START, - festivalEnd: FESTIVAL_END, + scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, }); @@ -37,7 +38,7 @@ describe("resolveTimelineMountMoment", () => { day: "2025-07-13", timezone: TIMEZONE, festivalStart: FESTIVAL_START, - festivalEnd: FESTIVAL_END, + scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, }); @@ -53,7 +54,7 @@ describe("resolveTimelineMountMoment", () => { day: "2025-07-13", timezone: TIMEZONE, festivalStart: FESTIVAL_START, - festivalEnd: FESTIVAL_END, + scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, }); @@ -68,7 +69,7 @@ describe("resolveTimelineMountMoment", () => { day: "all", timezone: TIMEZONE, festivalStart: FESTIVAL_START, - festivalEnd: FESTIVAL_END, + scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, }); @@ -77,13 +78,43 @@ describe("resolveTimelineMountMoment", () => { ); }); + it("clamps the now rule to the window start when now is within the window's first hour", () => { + // Window starting later than festivalStart, so a clamped result is + // distinguishable from the festivalStart fallback. + const windowStart = new Date("2025-07-12T16:00:00Z"); + const nowNearWindowStart = new Date("2025-07-12T16:10:00Z"); // 10 minutes in + const moment = resolveTimelineMountMoment({ + scrollTo: undefined, + day: "all", + timezone: TIMEZONE, + festivalStart: FESTIVAL_START, + scheduleWindow: { start: windowStart, end: FESTIVAL_END }, + now: nowNearWindowStart, + }); + + expect(moment.getTime()).toBe(windowStart.getTime()); + }); + + it("falls back to festivalStart when the schedule window is null (no timed sets)", () => { + const moment = resolveTimelineMountMoment({ + scrollTo: undefined, + day: "all", + timezone: TIMEZONE, + festivalStart: FESTIVAL_START, + scheduleWindow: null, + now: NOW_INSIDE_WINDOW, + }); + + expect(moment.getTime()).toBe(FESTIVAL_START.getTime()); + }); + it("falls back to festivalStart when now is before the festival window", () => { const moment = resolveTimelineMountMoment({ scrollTo: undefined, day: "all", timezone: TIMEZONE, festivalStart: FESTIVAL_START, - festivalEnd: FESTIVAL_END, + scheduleWindow: SCHEDULE_WINDOW, now: NOW_BEFORE_WINDOW, }); @@ -96,7 +127,7 @@ describe("resolveTimelineMountMoment", () => { day: "all", timezone: TIMEZONE, festivalStart: FESTIVAL_START, - festivalEnd: FESTIVAL_END, + scheduleWindow: SCHEDULE_WINDOW, now: NOW_AFTER_WINDOW, }); @@ -109,7 +140,7 @@ describe("resolveTimelineMountMoment", () => { day: "all", timezone: TIMEZONE, festivalStart: FESTIVAL_START, - festivalEnd: FESTIVAL_END, + scheduleWindow: SCHEDULE_WINDOW, now: NOW_AFTER_WINDOW, }); @@ -122,7 +153,7 @@ describe("resolveTimelineMountMoment", () => { day: "2025-07-13", timezone: TIMEZONE, festivalStart: FESTIVAL_START, - festivalEnd: FESTIVAL_END, + scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, }); @@ -137,7 +168,7 @@ describe("resolveTimelineMountMoment", () => { day: "2025-07-13", timezone: TIMEZONE, festivalStart: FESTIVAL_START, - festivalEnd: FESTIVAL_END, + scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, }); diff --git a/src/lib/timelineMountMoment.ts b/src/lib/timelineMountMoment.ts index 71937de0..c632fedf 100644 --- a/src/lib/timelineMountMoment.ts +++ b/src/lib/timelineMountMoment.ts @@ -1,5 +1,6 @@ import { isValid, parseISO } from "date-fns"; import { fromZonedTime } from "date-fns-tz"; +import type { ScheduleWindow } from "@/lib/timelineCalculator"; export interface TimelineMountMomentInput { /** Raw `scrollTo` search param, if present in the URL. */ @@ -8,14 +9,14 @@ export interface TimelineMountMomentInput { day: string; /** Festival's IANA timezone, used to resolve the day filter's start. */ timezone: string; + /** Timeline geometry origin (earliest moment on the rendered strip). */ + festivalStart: Date; /** - * Timeline geometry origin (earliest moment on the timeline) - also the - * start of the "festival window" the now-rule checks against, see - * `isNowWithinFestivalWindow`. + * The UNFILTERED schedule's window (see `calculateScheduleWindow`), used + * by the now-rule so an active stage/time filter can't shrink the window + * and suppress the rule mid-festival. Null when no set has a time yet. */ - festivalStart: Date; - /** End of the rendered timeline strip - the window's other bound. */ - festivalEnd: Date; + scheduleWindow: ScheduleWindow | null; /** Current moment, injected by the caller (never computed in here). */ now: Date; } @@ -28,7 +29,8 @@ const NOW_CONTEXT_MS = 60 * 60 * 1000; // ~1h of context before "now" * * 1. `scrollTo` from the URL, if present and parseable. * 2. The start of the active `day` filter, if one is set. - * 3. Now minus ~1h of context, if `now` falls inside the festival window. + * 3. Now minus ~1h of context (clamped to the window start), if `now` + * falls inside the unfiltered schedule window. * 4. The festival start (timeline origin), as the final fallback. */ export function resolveTimelineMountMoment( @@ -37,20 +39,25 @@ export function resolveTimelineMountMoment( return ( momentFromScrollTo(input.scrollTo) ?? momentFromDayFilter(input.day, input.timezone) ?? - momentFromNow(input.now, input.festivalStart, input.festivalEnd) ?? + momentFromNow(input.now, input.scheduleWindow) ?? input.festivalStart ); } /** - * Whether `now` falls inside the festival window - the rendered timeline's - * own `festivalStart`/`festivalEnd` bounds (from `TimelineData`), not the - * edition's raw `start_date`/`end_date`. Those bounds already fold in the - * actual earliest/latest set times, so a moment inside this window is - * guaranteed to land on the rendered strip when converted via - * `timeToOffset` - the edition's calendar dates alone (parsed at UTC - * midnight) would risk the last festival day's evening sets reading as - * "outside the window". Inclusive of both ends. + * Whether `now` falls inside a festival window (inclusive of both ends). + * The window comes from actual set times rather than the edition's raw + * `start_date`/`end_date` calendar dates, which - parsed at UTC midnight - + * would risk the last festival day's evening sets reading as "outside". + * Two windows are relevant, and they differ when filters are active: + * + * - The UNFILTERED schedule window (`calculateScheduleWindow`) gates the + * Now pill and the mount-precedence now-rule, so a stage/time filter + * can't hide time-awareness mid-festival as a side effect. + * - The rendered strip's bounds (`TimelineData.festivalStart`/ + * `festivalEnd`, computed from the FILTERED subset) gate the + * current-time indicator, which can only be drawn meaningfully on the + * strip that's actually rendered. */ export function isNowWithinFestivalWindow( now: Date, @@ -81,11 +88,16 @@ function momentFromDayFilter(day: string, timezone: string): Date | null { function momentFromNow( now: Date, - festivalStart: Date, - festivalEnd: Date, + scheduleWindow: ScheduleWindow | null, ): Date | null { - if (!isNowWithinFestivalWindow(now, festivalStart, festivalEnd)) return null; - return new Date(now.getTime() - NOW_CONTEXT_MS); + if (!scheduleWindow) return null; + if (!isNowWithinFestivalWindow(now, scheduleWindow.start, scheduleWindow.end)) + return null; + // Clamped so a "now" within the window's first hour doesn't resolve to a + // moment before the window even opens. + return new Date( + Math.max(now.getTime() - NOW_CONTEXT_MS, scheduleWindow.start.getTime()), + ); } /** diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx index 9046d938..843acf25 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx @@ -2,7 +2,10 @@ import { useMemo } from "react"; import { useSuspenseQuery } from "@tanstack/react-query"; import { useRouteContext } from "@tanstack/react-router"; import { useScheduleData } from "@/hooks/useScheduleData"; -import { calculateTimelineData } from "@/lib/timelineCalculator"; +import { + calculateScheduleWindow, + calculateTimelineData, +} from "@/lib/timelineCalculator"; import { StageLabels } from "./StageLabels"; import { TimelineContainer } from "./TimelineContainer"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; @@ -36,6 +39,13 @@ export function Timeline() { stages: selectedStages, } = useTimelineUrlState("timeline"); + // From the UNFILTERED schedule: the window that gates time-awareness (Now + // pill, mount-time now-rule), immune to the filters applied below. + const scheduleWindow = useMemo( + () => calculateScheduleWindow(scheduleDays), + [scheduleDays], + ); + const timelineData = useMemo(() => { if (!edition.start_date || !edition.end_date) { return null; @@ -138,6 +148,7 @@ export function Timeline() { timezone={festival.timezone} scheduleDays={scheduleDays} selectedDay={selectedDay} + scheduleWindow={scheduleWindow} now={now} />
diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx index 07218224..e032dc46 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx @@ -3,7 +3,11 @@ import { TimeScale } from "./TimeScale"; import { StageRow } from "./StageRow"; import { TimelineToolbar } from "./TimelineToolbar"; import { CurrentTimeIndicator } from "./CurrentTimeIndicator"; -import { timeToOffset, type TimelineData } from "@/lib/timelineCalculator"; +import { + timeToOffset, + type ScheduleWindow, + type TimelineData, +} from "@/lib/timelineCalculator"; import { isNowWithinFestivalWindow } from "@/lib/timelineMountMoment"; import type { ScheduleDay } from "@/hooks/useScheduleData"; import { useTimelineScrollSync } from "@/hooks/useTimelineScrollSync"; @@ -13,6 +17,7 @@ interface TimelineContainerProps { timezone: string; scheduleDays: ScheduleDay[]; selectedDay: string; + scheduleWindow: ScheduleWindow | null; now: Date; } @@ -21,6 +26,7 @@ export function TimelineContainer({ timezone, scheduleDays, selectedDay, + scheduleWindow, now, }: TimelineContainerProps) { const scrollContainerRef = useRef(null); @@ -28,11 +34,18 @@ export function TimelineContainer({ const { jumpTo } = useTimelineScrollSync({ scrollContainerRef, festivalStart: timelineData.festivalStart, - festivalEnd: timelineData.festivalEnd, + scheduleWindow, timezone, now, }); + // The pill is gated on the UNFILTERED schedule window so an active + // stage/time filter can't hide it mid-festival as a side effect; the + // indicator is gated on the rendered strip's own (filtered) bounds, since + // it can only be drawn meaningfully inside the strip. + const showNowButton = + scheduleWindow !== null && + isNowWithinFestivalWindow(now, scheduleWindow.start, scheduleWindow.end); const showNowIndicator = isNowWithinFestivalWindow( now, timelineData.festivalStart, @@ -46,7 +59,7 @@ export function TimelineContainer({ selectedDay={selectedDay} timezone={timezone} onJumpToDay={jumpTo} - showNowButton={showNowIndicator} + showNowButton={showNowButton} onJumpToNow={() => jumpTo(now)} />
Date: Wed, 15 Jul 2026 19:33:30 +0000 Subject: [PATCH 09/26] 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 a5667417..d98ca6ab 100644 --- a/src/lib/searchSchemas.ts +++ b/src/lib/searchSchemas.ts @@ -40,8 +40,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 5fcab7c578032e4416bd760c73e23b8bfd992c2f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:43:18 +0000 Subject: [PATCH 10/26] 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 eef917eb4b3c193b6858c41c82ab0bfab699bf3e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:46:29 +0000 Subject: [PATCH 11/26] docs(timeline): trim comments and move test helpers down Per review: shorter docblocks on the scroll-sync hook, toolbar, and day-jump helper; JSX section comments removed; test helpers moved below the cases. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/hooks/useTimelineScrollSync.ts | 41 ++++------------- src/lib/timeUtils.ts | 5 +- src/lib/timelineDayJump.test.ts | 46 +++++++++---------- src/lib/timelineDayJump.ts | 9 +--- .../horizontal/TimelineContainer.tsx | 2 - .../horizontal/TimelineToolbar.tsx | 11 +---- 6 files changed, 37 insertions(+), 77 deletions(-) diff --git a/src/hooks/useTimelineScrollSync.ts b/src/hooks/useTimelineScrollSync.ts index 6b93a19e..0a372fc5 100644 --- a/src/hooks/useTimelineScrollSync.ts +++ b/src/hooks/useTimelineScrollSync.ts @@ -17,26 +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. - * - On demand, via the returned `jumpTo(moment)`: writes `scrollTo` - * immediately (history replace) and smooth-scrolls the container to - * center that moment. The scroll events fired mid-animation keep - * resetting the debounce above, so no intermediate value is written; - * once the animation settles, the debounce fires once more and writes - * back the actual centered moment, which lands on the same value (no - * feedback loop, no history spam - both writes use `replace`). - * - * These directions never trigger each other into a loop: the mount effect - * runs once, the scroll listener only ever navigates (never touches - * `scrollLeft`), and `jumpTo` is the only path that both navigates and - * scrolls, driven solely by explicit calls (day-jump toolbar clicks). + * 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, @@ -46,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 }), @@ -56,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(() => { @@ -81,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]); @@ -142,10 +119,8 @@ export function useTimelineScrollSync({ 0, timeToOffset(rounded, festivalStart) - container.clientWidth / 2, ); - // Write the moment the viewport actually settles on: when the target - // clamps at the strip start (e.g. jumping to the first day), the real - // center differs from the requested moment, and the post-scroll - // debounced write must land on the same value. + // 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, diff --git a/src/lib/timeUtils.ts b/src/lib/timeUtils.ts index 8a0ba77d..68ce282c 100644 --- a/src/lib/timeUtils.ts +++ b/src/lib/timeUtils.ts @@ -194,9 +194,8 @@ export function getFestivalDayLabel(dayKey: string | null): string | null { return format(date, "EEEE, MMM d"); } -// Short weekday + date label for a day-key, e.g. "Thu 13" - used where space -// is tight (day-jump toolbar buttons) and multi-weekend festivals need the -// date to disambiguate repeated weekday names. +// 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); diff --git a/src/lib/timelineDayJump.test.ts b/src/lib/timelineDayJump.test.ts index f74adc7e..51a9ecd2 100644 --- a/src/lib/timelineDayJump.test.ts +++ b/src/lib/timelineDayJump.test.ts @@ -4,29 +4,6 @@ import type { ScheduleDay } from "@/hooks/useScheduleData"; const TIMEZONE = "Europe/Lisbon"; // UTC+1 in July (WEST) -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, - })), - }, - ], - }; -} - describe("getDayJumpMoment", () => { it("returns the earliest set start across all stages", () => { const day: ScheduleDay = { @@ -84,3 +61,26 @@ describe("getDayJumpMoment", () => { 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 index 719326c3..9b0bd4a6 100644 --- a/src/lib/timelineDayJump.ts +++ b/src/lib/timelineDayJump.ts @@ -1,13 +1,8 @@ import { fromZonedTime } from "date-fns-tz"; import type { ScheduleDay } from "@/hooks/useScheduleData"; -/** - * The moment the timeline viewport should center on when jumping to a day - * from the day-jump toolbar: the day's earliest set start, since that's what - * a viewer actually wants centered (the day's midnight is usually a stretch - * of dead timeline with nothing scheduled). Falls back to festival-timezone - * midnight for a day with no timed sets yet. - */ +// 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; diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx index da72f69f..832e1553 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx @@ -40,7 +40,6 @@ export function TimelineContainer({ data-testid="timeline-scroll-container" className="overflow-x-auto overflow-y-hidden pb-20" > - {/* Time Scale */} - {/* Stage Rows */}
{timelineData.stages.map((stage) => ( void; } -/** - * Slim sticky toolbar above the Timeline strip. Only hosts day-jump buttons - * for now; the Now pill, "Show overview" toggle, and Filters trigger - * (upcoming stacked tickets) will render alongside them here. - * - * Navigation only ever scrolls - it never filters the strip. When a `day` - * filter is active, nav operates on what's rendered, so only that day's - * button shows. - */ +// 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, From 27d2f5e4bea560a218aff3a115b170a9abdcb284 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:47:46 +0000 Subject: [PATCH 12/26] test(timeline): move calculator helpers below cases Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/lib/timelineCalculator.test.ts | 38 +++++++++++++++--------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/lib/timelineCalculator.test.ts b/src/lib/timelineCalculator.test.ts index 43285e10..20897efc 100644 --- a/src/lib/timelineCalculator.test.ts +++ b/src/lib/timelineCalculator.test.ts @@ -10,25 +10,6 @@ import type { Stage } from "@/api/stages/types"; const PX_PER_MINUTE = 2; -function makeSet(overrides: Partial = {}): ScheduleSet { - return { - id: "set-1", - name: "Artist", - artists: [], - ...overrides, - }; -} - -function makeStage(overrides: Partial = {}): Stage { - return { - id: "stage-1", - name: "Main Stage", - color: "#ff0000", - stage_order: 0, - ...overrides, - } as unknown as Stage; -} - describe("timeToOffset", () => { it("returns 0 when moment equals origin", () => { const origin = new Date("2024-07-01T10:00:00Z"); @@ -398,3 +379,22 @@ describe("calculateTimelineData", () => { ); }); }); + +function makeSet(overrides: Partial = {}): ScheduleSet { + return { + id: "set-1", + name: "Artist", + artists: [], + ...overrides, + }; +} + +function makeStage(overrides: Partial = {}): Stage { + return { + id: "stage-1", + name: "Main Stage", + color: "#ff0000", + stage_order: 0, + ...overrides, + } as unknown as Stage; +} From dc18afcadd8e5d906d25bd7ea93edbbb58e1df17 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:33:48 +0000 Subject: [PATCH 13/26] 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 14/26] 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 15/26] 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 16/26] 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 17/26] 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, From 6da6d0200e2a23df488db661c4b77886aa3889d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 11:54:19 +0000 Subject: [PATCH 18/26] test(timeline): dedupe e2e setup, assert set link instead of skipping Extracts the repeated clock-install/goto/skip-if-hidden block into openTimeline(), and turns the set-detail-link skip in the back-navigation test into an assertion so missing seed data fails loudly instead of silently dropping that acceptance criterion's coverage. --- tests/e2e/timeline-scroll.spec.ts | 51 +++++++++++-------------------- 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/tests/e2e/timeline-scroll.spec.ts b/tests/e2e/timeline-scroll.spec.ts index 8b26ba1e..d51402ff 100644 --- a/tests/e2e/timeline-scroll.spec.ts +++ b/tests/e2e/timeline-scroll.spec.ts @@ -1,19 +1,25 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } 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; +async function openTimeline(page: 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"); + } + + return scrollContainer; +} + 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"); - } + await openTimeline(page); expect(new URL(page.url()).searchParams.has("scrollTo")).toBe(false); }); @@ -21,13 +27,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"); - if (!(await scrollContainer.isVisible().catch(() => false))) { - test.skip(true, "Schedule not revealed in this environment"); - } + const scrollContainer = await openTimeline(page); const historyLengthBeforeScroll = await page.evaluate( () => window.history.length, @@ -66,13 +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"); - if (!(await scrollContainer.isVisible().catch(() => false))) { - test.skip(true, "Schedule not revealed in this environment"); - } + const scrollContainer = await openTimeline(page); // Scroll to discover a real, in-range moment to jump back to. await scrollContainer.evaluate((el) => { @@ -105,13 +99,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"); - if (!(await scrollContainer.isVisible().catch(() => false))) { - test.skip(true, "Schedule not revealed in this environment"); - } + const scrollContainer = await openTimeline(page); await scrollContainer.evaluate((el) => { el.scrollLeft = el.scrollLeft + 500; @@ -124,10 +112,7 @@ test.describe("Timeline scroll position (scrollTo URL state)", () => { ); const setLink = page.locator('a[href*="/sets/"]').first(); - test.skip( - !(await setLink.isVisible().catch(() => false)), - "No set detail link rendered in this environment", - ); + await expect(setLink).toBeVisible(); await setLink.click(); await page.goBack(); await expect(page).toHaveURL(urlWithScroll); From 1e9b23c5e39bd53654ee35a540936ce9f82204d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 12:27:37 +0000 Subject: [PATCH 19/26] refactor(timeline): move scroll handler below effect wiring Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/hooks/useTimelineScrollSync.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/hooks/useTimelineScrollSync.ts b/src/hooks/useTimelineScrollSync.ts index de7a2f19..54340cd9 100644 --- a/src/hooks/useTimelineScrollSync.ts +++ b/src/hooks/useTimelineScrollSync.ts @@ -87,6 +87,12 @@ export function useTimelineScrollSync({ let debounceTimer: ReturnType | undefined; + container.addEventListener("scroll", handleScroll, { passive: true }); + return () => { + container.removeEventListener("scroll", handleScroll); + if (debounceTimer) clearTimeout(debounceTimer); + }; + function handleScroll() { const programmaticLeft = programmaticScrollLeftRef.current; if (programmaticLeft !== null) { @@ -116,11 +122,5 @@ export function useTimelineScrollSync({ }); }, SCROLL_DEBOUNCE_MS); } - - container.addEventListener("scroll", handleScroll, { passive: true }); - return () => { - container.removeEventListener("scroll", handleScroll); - if (debounceTimer) clearTimeout(debounceTimer); - }; }, [scrollContainerRef, festivalStart, navigate]); } From e81c05e8e75414fa248b0d5d5cea681d0c206dea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 19:48:51 +0000 Subject: [PATCH 20/26] fix(timeline): restore now-indicator positioning context, drop redundant structuralSharing option --- src/hooks/useTimelineUrlState.ts | 9 ---- .../horizontal/TimelineContainer.tsx | 42 ++++++++++--------- 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/src/hooks/useTimelineUrlState.ts b/src/hooks/useTimelineUrlState.ts index c7b1fac9..3cd439b9 100644 --- a/src/hooks/useTimelineUrlState.ts +++ b/src/hooks/useTimelineUrlState.ts @@ -7,17 +7,8 @@ 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/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx index edddf3e7..be731670 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx @@ -101,29 +101,31 @@ export function TimelineContainer({ data-testid="timeline-scroll-container" className="overflow-x-auto overflow-y-hidden pb-20" > - +
+ + +
+ {timelineData.stages.map((stage) => ( + + ))} +
-
- {timelineData.stages.map((stage) => ( - - ))} + )}
- - {showNowIndicator && ( - - )}
From f95329dc5dbb21592f6807f120ee1acbbf0f60bc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 19:57:04 +0000 Subject: [PATCH 21/26] fix(timeline): move Now control out of scrolling day row, match toolbar style Anchors it beside Show overview so it can't scroll out of view on mobile, and swaps the mismatched filled white pill for a ghost button with a pulsing dot, matching the toolbar's flat fuchsia/purple palette. --- .../tabs/ScheduleTab/horizontal/NowButton.tsx | 16 +++++++--- .../horizontal/TimelineToolbar.tsx | 32 ++++++++++--------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/NowButton.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/NowButton.tsx index 1287496c..ec9e26d0 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/NowButton.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/NowButton.tsx @@ -5,20 +5,26 @@ interface NowButtonProps { } /** - * The "Now" pill in the day-jump toolbar. Rendered only while the current - * time (festival timezone) falls inside the festival window - see - * `isNowWithinFestivalWindow` - never disabled, just absent otherwise. + * The "Now" control anchored beside "Show overview", outside the scrolling + * day row so it can't be scrolled out of view on narrow screens. Rendered + * only while the current time (festival timezone) falls inside the festival + * window - see `isNowWithinFestivalWindow` - never disabled, just absent + * otherwise. */ export function NowButton({ onJumpToNow }: NowButtonProps) { return ( ); diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx index 8b172eea..a5bd3d95 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx @@ -71,23 +71,25 @@ export function TimelineToolbar({ timezone={timezone} onJumpToDay={onJumpToDay} /> +
+
{showNowButton && } +
-
); } From aa5de104143f3ab4e3f8e8bf85cabb7034c6f3ec Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 20:07:10 +0000 Subject: [PATCH 22/26] fix(timeline): gate now indicator on unfiltered schedule window Matches the Now pill's precedent so an active stage/time filter can't hide the indicator mid-festival as a side effect. Addresses PR #204 review comment. --- .../horizontal/TimelineContainer.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx index be731670..46a8fe4e 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx @@ -59,18 +59,16 @@ export function TimelineContainer({ } } - // The pill is gated on the UNFILTERED schedule window so an active - // stage/time filter can't hide it mid-festival as a side effect; the - // indicator is gated on the rendered strip's own (filtered) bounds, since - // it can only be drawn meaningfully inside the strip. - const showNowButton = + // Both the pill and the indicator are gated on the UNFILTERED schedule + // window so an active stage/time filter can't hide them mid-festival as a + // side effect (see #194); the indicator's pixel position still uses the + // rendered (possibly filtered) strip's own origin, so it may land outside + // the visible strip while a filter is narrowing it. + const isNowInFestivalWindow = scheduleWindow !== null && isNowWithinFestivalWindow(now, scheduleWindow.start, scheduleWindow.end); - const showNowIndicator = isNowWithinFestivalWindow( - now, - timelineData.festivalStart, - timelineData.festivalEnd, - ); + const showNowButton = isNowInFestivalWindow; + const showNowIndicator = isNowInFestivalWindow; return ( <> From b4f2a400e7483eeda721974542ae52892da8e4ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 20:16:42 +0000 Subject: [PATCH 23/26] refactor(timeline): trim excessive comments across now-indicator files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CLAUDE.md ("don't add comments unless really necessary") and PR review feedback — strip JSDoc blocks from small hooks/components and cut verbose inline comments down to one line where the context still needs stating. --- src/hooks/useNow.ts | 7 --- src/hooks/useTimelineScrollSync.ts | 12 +---- src/lib/timelineCalculator.ts | 11 +---- src/lib/timelineMountMoment.ts | 44 ++----------------- .../horizontal/CurrentTimeIndicator.tsx | 7 --- .../tabs/ScheduleTab/horizontal/NowButton.tsx | 7 --- .../tabs/ScheduleTab/horizontal/Timeline.tsx | 2 - .../horizontal/TimelineContainer.tsx | 6 +-- 8 files changed, 8 insertions(+), 88 deletions(-) diff --git a/src/hooks/useNow.ts b/src/hooks/useNow.ts index 0a56a452..4f32db26 100644 --- a/src/hooks/useNow.ts +++ b/src/hooks/useNow.ts @@ -2,13 +2,6 @@ import { useEffect, useState } from "react"; const DEFAULT_INTERVAL_MS = 60_000; -/** - * Ticks a fresh `Date` on a fixed interval (60s by default). The Now pill and - * the current-time indicator on the Timeline need a live "now" to re-render - * against; the pure functions they feed (`isNowWithinFestivalWindow`, - * `resolveTimelineMountMoment`, `timeToOffset`) never call `new Date()` - * themselves - this hook is the one place that does, on their behalf. - */ export function useNow(intervalMs = DEFAULT_INTERVAL_MS): Date { const [now, setNow] = useState(() => new Date()); diff --git a/src/hooks/useTimelineScrollSync.ts b/src/hooks/useTimelineScrollSync.ts index 3a23e87b..b1c286b0 100644 --- a/src/hooks/useTimelineScrollSync.ts +++ b/src/hooks/useTimelineScrollSync.ts @@ -17,21 +17,13 @@ const SCROLL_ROUND_MINUTES = 5; interface UseTimelineScrollSyncOptions { scrollContainerRef: RefObject; festivalStart: Date; - /** The UNFILTERED schedule window (`calculateScheduleWindow`), for the - * mount-precedence now-rule; null when no set has a time yet. */ scheduleWindow: ScheduleWindow | null; timezone: string; - /** Current moment, injected by the caller (see `useNow`). Only read once, - * at mount, to resolve the "now" mount-precedence rule. */ now: Date; } -/** - * One-way sync between the timeline viewport and the `scrollTo` URL param: - * URL -> scroll only on mount; user scroll -> URL only (debounced, 5-min - * rounded, history replace). Never loops. See `jumpToTimelineMoment` for - * imperative jumps. - */ +// One-way sync: URL -> scroll only on mount; user scroll -> URL only +// (debounced, 5-min rounded, history replace). Never loops. export function useTimelineScrollSync({ scrollContainerRef, festivalStart, diff --git a/src/lib/timelineCalculator.ts b/src/lib/timelineCalculator.ts index 14e8374a..49197d02 100644 --- a/src/lib/timelineCalculator.ts +++ b/src/lib/timelineCalculator.ts @@ -20,15 +20,8 @@ export interface ScheduleWindow { end: Date; } -/** - * The schedule's overall time window: earliest set start (floored to the - * hour) through latest set end (ceiled to the hour), scanned across ALL - * days/stages passed in. Callers deciding time-awareness (the Now pill, the - * mount-precedence now-rule) must feed this the UNFILTERED schedule days - - * unlike `TimelineData.festivalStart`/`festivalEnd`, which are computed from - * the filtered subset and shrink when a stage or time-of-day filter is - * active. Returns null when no set has a time yet. - */ +// Earliest set start through latest set end, across the UNFILTERED schedule +// days (unlike TimelineData.festivalStart/End, which shrink with filters). export function calculateScheduleWindow( scheduleDays: ScheduleDay[], ): ScheduleWindow | null { diff --git a/src/lib/timelineMountMoment.ts b/src/lib/timelineMountMoment.ts index c632fedf..a559bab7 100644 --- a/src/lib/timelineMountMoment.ts +++ b/src/lib/timelineMountMoment.ts @@ -3,36 +3,17 @@ import { fromZonedTime } from "date-fns-tz"; import type { ScheduleWindow } from "@/lib/timelineCalculator"; 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 rendered strip). */ festivalStart: Date; - /** - * The UNFILTERED schedule's window (see `calculateScheduleWindow`), used - * by the now-rule so an active stage/time filter can't shrink the window - * and suppress the rule mid-festival. Null when no set has a time yet. - */ scheduleWindow: ScheduleWindow | null; - /** Current moment, injected by the caller (never computed in here). */ now: Date; } const NOW_CONTEXT_MS = 60 * 60 * 1000; // ~1h of context before "now" -/** - * 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. Now minus ~1h of context (clamped to the window start), if `now` - * falls inside the unfiltered schedule window. - * 4. The festival start (timeline origin), as the final fallback. - */ +// Mount-precedence order: scrollTo -> day filter -> now-1h -> festival start. export function resolveTimelineMountMoment( input: TimelineMountMomentInput, ): Date { @@ -44,21 +25,8 @@ export function resolveTimelineMountMoment( ); } -/** - * Whether `now` falls inside a festival window (inclusive of both ends). - * The window comes from actual set times rather than the edition's raw - * `start_date`/`end_date` calendar dates, which - parsed at UTC midnight - - * would risk the last festival day's evening sets reading as "outside". - * Two windows are relevant, and they differ when filters are active: - * - * - The UNFILTERED schedule window (`calculateScheduleWindow`) gates the - * Now pill and the mount-precedence now-rule, so a stage/time filter - * can't hide time-awareness mid-festival as a side effect. - * - The rendered strip's bounds (`TimelineData.festivalStart`/ - * `festivalEnd`, computed from the FILTERED subset) gate the - * current-time indicator, which can only be drawn meaningfully on the - * strip that's actually rendered. - */ +// Inclusive check of `now` against a festival window (set times, not the +// edition's raw UTC-midnight start/end dates). export function isNowWithinFestivalWindow( now: Date, festivalStart: Date, @@ -93,17 +61,11 @@ function momentFromNow( if (!scheduleWindow) return null; if (!isNowWithinFestivalWindow(now, scheduleWindow.start, scheduleWindow.end)) return null; - // Clamped so a "now" within the window's first hour doesn't resolve to a - // moment before the window even opens. return new Date( Math.max(now.getTime() - NOW_CONTEXT_MS, scheduleWindow.start.getTime()), ); } -/** - * 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/CurrentTimeIndicator.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/CurrentTimeIndicator.tsx index 1d80a889..00ded44c 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/CurrentTimeIndicator.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/CurrentTimeIndicator.tsx @@ -2,13 +2,6 @@ interface CurrentTimeIndicatorProps { left: number; } -/** - * Vertical dashed line marking "now" on the timeline strip - the treatment - * that tested best in the prototype (see issue #194). Rendered only while - * `isNowWithinFestivalWindow` is true; its `left` position is recomputed by - * the caller every 60s (via `useNow`), but the viewport is never scrolled to - * follow it. - */ export function CurrentTimeIndicator({ left }: CurrentTimeIndicatorProps) { return (
void; } -/** - * The "Now" control anchored beside "Show overview", outside the scrolling - * day row so it can't be scrolled out of view on narrow screens. Rendered - * only while the current time (festival timezone) falls inside the festival - * window - see `isNowWithinFestivalWindow` - never disabled, just absent - * otherwise. - */ export function NowButton({ onJumpToNow }: NowButtonProps) { return (