From 8975e81d65b598153a5f9ad1192b884cd0060d5c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:20:18 +0000 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 1ede0170ba2a779712fcf22277e3249320525f9e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:33:30 +0000 Subject: [PATCH 7/9] 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 8/9] 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 9/9] 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,