Skip to content
125 changes: 125 additions & 0 deletions src/hooks/useTimelineScrollSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
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<HTMLDivElement>;
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 }),
});
const navigate = useNavigate({ from: route });

const hasCenteredOnMountRef = useRef(false);
// Position of the last programmatic scroll; scroll events reporting this
// position are ignored (a browser may fire more than one for a single
// scrollLeft write), so only genuine user scrolling reaches the URL.
const programmaticScrollLeftRef = useRef<number | null>(null);

useLayoutEffect(() => {
if (hasCenteredOnMountRef.current) return;
const container = scrollContainerRef.current;
if (!container) return;
hasCenteredOnMountRef.current = true;

const moment = resolveTimelineMountMoment({
scrollTo,
day,
timezone,
festivalStart,
});

const targetScrollLeft = Math.max(
0,
timeToOffset(moment, festivalStart) - container.clientWidth / 2,
);

if (targetScrollLeft !== container.scrollLeft) {
container.scrollLeft = targetScrollLeft;
// Read back: the browser clamps to the scrollable range, and the
// suppression check must match the position events will report.
programmaticScrollLeftRef.current = container.scrollLeft;
}
// Mount-only positioning: intentionally does not re-run when scrollTo/day
// change afterwards (one-way ownership, URL -> scroll only on mount).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollContainerRef]);

useEffect(() => {
Comment thread
chiptus marked this conversation as resolved.
const container = scrollContainerRef.current;
if (!container) return;

let debounceTimer: ReturnType<typeof setTimeout> | undefined;

container.addEventListener("scroll", handleScroll, { passive: true });
return () => {
container.removeEventListener("scroll", handleScroll);
if (debounceTimer) clearTimeout(debounceTimer);
};

function handleScroll() {
Comment thread
chiptus marked this conversation as resolved.
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);
Comment thread
chiptus marked this conversation as resolved.
debounceTimer = setTimeout(() => {
const el = scrollContainerRef.current;
if (!el) return;

const centerOffset = el.scrollLeft + el.clientWidth / 2;
const centerMoment = offsetToTime(centerOffset, festivalStart);
const rounded = roundToNearestMinutes(
Comment thread
chiptus marked this conversation as resolved.
centerMoment,
SCROLL_ROUND_MINUTES,
);

navigate({
to: ".",
search: (prev) => ({ ...prev, scrollTo: rounded.toISOString() }),
replace: true,
});
}, SCROLL_DEBOUNCE_MS);
}
}, [scrollContainerRef, festivalStart, navigate]);
}
5 changes: 5 additions & 0 deletions src/hooks/useTimelineUrlState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") {
`/festivals/$festivalSlug/editions/$editionSlug/schedule/${tab}` as const;
const state = useSearch({
from: route,
select: (search) => ({
day: search.day,
time: search.time,
stages: search.stages,
}),
});
const navigate = useNavigate({ from: route });

Expand Down
1 change: 1 addition & 0 deletions src/lib/searchSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const timelineSearchSchema = z.object({
day: z.string().catch("all"),
time: z.enum(["all", "morning", "afternoon", "evening"]).catch("all"),
stages: z.array(z.string()).catch([]),
scrollTo: z.string().optional().catch(undefined),
});

export type TimelineSearch = z.infer<typeof timelineSearchSchema>;
Expand Down
113 changes: 113 additions & 0 deletions src/lib/timelineMountMoment.test.ts
Original file line number Diff line number Diff line change
@@ -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());
});
});
60 changes: 60 additions & 0 deletions src/lib/timelineMountMoment.ts
Original file line number Diff line number Diff line change
@@ -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);
}
1 change: 1 addition & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const router = createRouter({
},
defaultPreload: "intent",
defaultPreloadStaleTime: 0,
defaultStructuralSharing: true,
defaultNotFoundComponent: NotFound,
defaultPendingComponent: RouteLoadingFallback,
defaultErrorComponent: RouteErrorFallback,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,9 +15,16 @@ export function TimelineContainer({
}: TimelineContainerProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);

useTimelineScrollSync({
scrollContainerRef,
festivalStart: timelineData.festivalStart,
timezone,
});

return (
<div
ref={scrollContainerRef}
data-testid="timeline-scroll-container"
className="overflow-x-auto overflow-y-hidden pb-20"
>
{/* Time Scale */}
Expand Down
Loading
Loading