Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 33 additions & 20 deletions src/hooks/useTimelineScrollSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,9 @@ interface UseTimelineScrollSyncOptions {
}

/**
* Owns the one-way sync between the timeline's scroll position and the
* `scrollTo` URL param:
*
* - On mount only: centers the viewport per `resolveTimelineMountMoment`'s
* precedence (scrollTo -> day filter -> festival start).
* - On user scroll: after the scroll settles (~300ms), writes the moment
* now centered in the viewport back to the URL (history replace),
* rounded to 5-minute granularity.
*
* These two directions never trigger each other: the mount effect runs once
* and the scroll listener only ever navigates, never touches `scrollLeft`.
* One-way sync between the timeline viewport and the `scrollTo` URL param:
* URL -> scroll only on mount and via `jumpTo`; user scroll -> URL only
* (debounced, 5-min rounded, history replace). Never loops.
*/
export function useTimelineScrollSync({
scrollContainerRef,
Expand All @@ -37,8 +29,6 @@ export function useTimelineScrollSync({
const route =
"/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline" as const;

// Narrow, structurally-shared selection: this hook only cares about
// scrollTo/day, so its own writes to scrollTo don't cascade elsewhere.
const { scrollTo, day } = useSearch({
from: route,
select: (search) => ({ scrollTo: search.scrollTo, day: search.day }),
Expand All @@ -47,9 +37,7 @@ export function useTimelineScrollSync({
const navigate = useNavigate({ from: route });

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

useLayoutEffect(() => {
Expand All @@ -72,12 +60,10 @@ export function useTimelineScrollSync({

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

Expand Down Expand Up @@ -123,4 +109,31 @@ export function useTimelineScrollSync({
if (debounceTimer) clearTimeout(debounceTimer);
};
}, [scrollContainerRef, festivalStart, navigate]);

function jumpTo(moment: Date) {
const container = scrollContainerRef.current;
if (!container) return;

const rounded = roundToNearestMinutes(moment, SCROLL_ROUND_MINUTES);
const targetScrollLeft = Math.max(
0,
timeToOffset(rounded, festivalStart) - container.clientWidth / 2,
);
// A clamped target (e.g. first-day jump) centers on a different moment
// than requested; write the one the viewport actually settles on.
const settledMoment = roundToNearestMinutes(
offsetToTime(targetScrollLeft + container.clientWidth / 2, festivalStart),
SCROLL_ROUND_MINUTES,
);

container.scrollTo({ left: targetScrollLeft, behavior: "smooth" });

navigate({
to: ".",
search: (prev) => ({ ...prev, scrollTo: settledMoment.toISOString() }),
replace: true,
});
}

return { jumpTo };
}
20 changes: 20 additions & 0 deletions src/lib/timeUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
convertLocalTimeToUTC,
getFestivalDayKey,
getFestivalDayLabel,
getFestivalDayShortLabel,
getFestivalHour,
} from "./timeUtils";

Expand Down Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions src/lib/timeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,15 @@ export function getFestivalDayLabel(dayKey: string | null): string | null {
return format(date, "EEEE, MMM d");
}

// Short weekday + date label, e.g. "Thu 13"; the date disambiguates repeated
// weekday names at multi-weekend festivals.
export function getFestivalDayShortLabel(dayKey: string | null): string | null {
if (!dayKey) return null;
const date = parseISO(dayKey);
if (!isValid(date)) return null;
return format(date, "EEE d");
}

// The wall-clock hour (0-23) a UTC timestamp falls on in the festival's
// timezone, for time-of-day filters (morning/afternoon/evening).
export function getFestivalHour(
Expand Down
86 changes: 86 additions & 0 deletions src/lib/timelineDayJump.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { getDayJumpMoment } from "./timelineDayJump";
import type { ScheduleDay } from "@/hooks/useScheduleData";

const TIMEZONE = "Europe/Lisbon"; // UTC+1 in July (WEST)

describe("getDayJumpMoment", () => {
it("returns the earliest set start across all stages", () => {
const day: ScheduleDay = {
date: "2025-07-13",
displayDate: "2025-07-13",
stages: [
{
id: "stage-1",
name: "Main Stage",
stage_order: 0,
sets: [
{
id: "set-1",
name: "Set 1",
artists: [],
startTime: new Date("2025-07-13T18:00:00Z"),
},
],
},
{
id: "stage-2",
name: "Second Stage",
stage_order: 1,
sets: [
{
id: "set-2",
name: "Set 2",
artists: [],
startTime: new Date("2025-07-13T15:00:00Z"),
},
],
},
],
};

const moment = getDayJumpMoment(day, TIMEZONE);
expect(moment.getTime()).toBe(new Date("2025-07-13T15:00:00Z").getTime());
});

it("ignores sets without a start time", () => {
const day = buildDay("2025-07-13", [
undefined,
new Date("2025-07-13T20:00:00Z"),
]);

const moment = getDayJumpMoment(day, TIMEZONE);
expect(moment.getTime()).toBe(new Date("2025-07-13T20:00:00Z").getTime());
});

it("falls back to festival-timezone midnight when the day has no timed sets", () => {
const day = buildDay("2025-07-13", []);

const moment = getDayJumpMoment(day, TIMEZONE);
// Midnight in Europe/Lisbon (UTC+1 in July) is 23:00 UTC the prior day.
expect(moment.getTime()).toBe(new Date("2025-07-12T23:00:00Z").getTime());
});
});

function buildDay(
date: string,
startTimes: (Date | undefined)[],
): ScheduleDay {
return {
date,
displayDate: date,
stages: [
{
id: "stage-1",
name: "Main Stage",
stage_order: 0,
sets: startTimes.map((startTime, index) => ({
id: `set-${index}`,
name: `Set ${index}`,
artists: [],
startTime,
})),
},
],
};
}
21 changes: 21 additions & 0 deletions src/lib/timelineDayJump.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { fromZonedTime } from "date-fns-tz";
import type { ScheduleDay } from "@/hooks/useScheduleData";

// The moment a day jump centers on: the day's earliest set start (midnight is
// usually dead timeline), falling back to festival-timezone midnight.
export function getDayJumpMoment(day: ScheduleDay, timezone: string): Date {
let earliestSetStart: Date | null = null;

day.stages.forEach((stage) => {
stage.sets.forEach((set) => {
if (
set.startTime &&
(!earliestSetStart || set.startTime < earliestSetStart)
) {
earliestSetStart = set.startTime;
}
});
});

return earliestSetStart ?? fromZonedTime(`${day.date}T00:00:00`, timezone);
}
Original file line number Diff line number Diff line change
@@ -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) => (
<Button
key={day.date}
type="button"
variant="outline"
size="sm"
data-testid={`day-jump-button-${day.date}`}
className="shrink-0 border-purple-400/40 text-purple-100 hover:bg-purple-400 hover:text-white"
onClick={() => onJumpToDay(getDayJumpMoment(day, timezone))}
>
{getFestivalDayShortLabel(day.date) || day.displayDate}
</Button>
))}
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ export function Timeline() {
<TimelineContainer
timelineData={timelineData}
timezone={festival.timezone}
scheduleDays={scheduleDays}
selectedDay={selectedDay}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,51 +1,63 @@
import { useRef } from "react";
import { TimeScale } from "./TimeScale";
import { StageRow } from "./StageRow";
import { TimelineToolbar } from "./TimelineToolbar";
import type { TimelineData } from "@/lib/timelineCalculator";
import type { ScheduleDay } from "@/hooks/useScheduleData";
import { useTimelineScrollSync } from "@/hooks/useTimelineScrollSync";

interface TimelineContainerProps {
timelineData: TimelineData;
timezone: string;
scheduleDays: ScheduleDay[];
selectedDay: string;
}

export function TimelineContainer({
timelineData,
timezone,
scheduleDays,
selectedDay,
}: TimelineContainerProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);

useTimelineScrollSync({
const { jumpTo } = 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 */}
<TimeScale
timeSlots={timelineData.timeSlots}
totalWidth={timelineData.totalWidth}
scrollContainerRef={scrollContainerRef}
<>
<TimelineToolbar
days={scheduleDays}
selectedDay={selectedDay}
timezone={timezone}
onJumpToDay={jumpTo}
/>
<div
ref={scrollContainerRef}
data-testid="timeline-scroll-container"
className="overflow-x-auto overflow-y-hidden pb-20"
>
<TimeScale
timeSlots={timelineData.timeSlots}
totalWidth={timelineData.totalWidth}
scrollContainerRef={scrollContainerRef}
timezone={timezone}
/>

{/* Stage Rows */}
<div className="space-y-12 mt-28">
{timelineData.stages.map((stage) => (
<StageRow
key={stage.name}
stage={stage}
totalWidth={timelineData.totalWidth}
timezone={timezone}
/>
))}
<div className="space-y-12 mt-28">
{timelineData.stages.map((stage) => (
<StageRow
key={stage.name}
stage={stage}
totalWidth={timelineData.totalWidth}
timezone={timezone}
/>
))}
</div>
</div>
</div>
</>
);
}
Loading