Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8975e81
refactor(timeline): geometry owns time-pixel mapping
claude Jul 14, 2026
60d54ba
fix(timeline): make time-pixel mapping continuous
claude Jul 14, 2026
74531bc
feat(timeline): scroll position lives in the url
claude Jul 14, 2026
ae0fbf4
Merge remote-tracking branch 'origin/claude/188-191-timeline-geometry…
claude Jul 14, 2026
5a3eb9c
feat(timeline): add day-jump toolbar with dated labels
claude Jul 14, 2026
9f4149f
fix(timeline): harden scroll sync against review findings
claude Jul 14, 2026
e02e5b6
Merge remote-tracking branch 'origin/claude/188-192-scrollto-url-stat…
claude Jul 14, 2026
4fbe201
fix(timeline): jump writes the settled viewport moment
claude Jul 14, 2026
1a0431b
feat(timeline): Now pill and current-time indicator
claude Jul 14, 2026
36bd2fd
Merge remote-tracking branch 'origin/claude/188-193-day-jump-toolbar'…
claude Jul 14, 2026
7676c7d
fix(timeline): gate now pill on unfiltered schedule window
claude Jul 14, 2026
1ede017
test(timeline): drive debounce waits with playwright fake clock
claude Jul 15, 2026
5fcab7c
fix(timeline): suppress mount scroll at clamped position
claude Jul 15, 2026
c22ed66
Merge remote-tracking branch 'origin/claude/188-192-scrollto-url-stat…
claude Jul 15, 2026
eef917e
docs(timeline): trim comments and move test helpers down
claude Jul 15, 2026
27d2f5e
test(timeline): move calculator helpers below cases
claude Jul 15, 2026
dc18afc
feat(timeline): scroll position lives in the url
claude Jul 14, 2026
f763177
fix(timeline): harden scroll sync against review findings
claude Jul 14, 2026
162493c
test(timeline): drive debounce waits with playwright fake clock
claude Jul 15, 2026
acf2265
fix(timeline): suppress mount scroll at clamped position
claude Jul 15, 2026
0dba8f0
fix(timeline): drop removed view param from url-state select
claude Jul 16, 2026
cda37ef
Merge remote-tracking branch 'origin/claude/188-192-scrollto-url-stat…
claude Jul 16, 2026
50114f4
Merge remote-tracking branch 'origin/claude/188-193-day-jump-toolbar'…
claude Jul 16, 2026
6da6d02
test(timeline): dedupe e2e setup, assert set link instead of skipping
claude Jul 18, 2026
1e9b23c
refactor(timeline): move scroll handler below effect wiring
claude Jul 18, 2026
e6bdfdc
Merge remote-tracking branch 'origin/claude/188-192-scrollto-url-stat…
claude Jul 18, 2026
1f0204e
Merge remote-tracking branch 'origin/claude/188-193-day-jump-toolbar'…
claude Jul 18, 2026
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
21 changes: 21 additions & 0 deletions src/hooks/useNow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useEffect, useState } from "react";

const DEFAULT_INTERVAL_MS = 60_000;

/**
* Ticks a fresh `Date` on a fixed interval (60s by default). The Now pill and
* the current-time indicator on the Timeline need a live "now" to re-render
* against; the pure functions they feed (`isNowWithinFestivalWindow`,
* `resolveTimelineMountMoment`, `timeToOffset`) never call `new Date()`
* themselves - this hook is the one place that does, on their behalf.
*/
export function useNow(intervalMs = DEFAULT_INTERVAL_MS): Date {
const [now, setNow] = useState(() => new Date());

useEffect(() => {
const id = window.setInterval(() => setNow(new Date()), intervalMs);
return () => window.clearInterval(id);
}, [intervalMs]);

return now;
}
153 changes: 153 additions & 0 deletions src/hooks/useTimelineScrollSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import { useNavigate, useSearch } from "@tanstack/react-router";
import type { RefObject } from "react";
import {
offsetToTime,
timeToOffset,
type ScheduleWindow,
} 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;
/** The UNFILTERED schedule window (`calculateScheduleWindow`), for the
* mount-precedence now-rule; null when no set has a time yet. */
scheduleWindow: ScheduleWindow | null;
timezone: string;
/** Current moment, injected by the caller (see `useNow`). Only read once,
* at mount, to resolve the "now" mount-precedence rule. */
now: Date;
}

/**
* One-way sync between the timeline viewport and the `scrollTo` URL param:
* URL -> scroll only on mount and via `jumpTo`; user scroll -> URL only
* (debounced, 5-min rounded, history replace). Never loops.
*/
export function useTimelineScrollSync({
scrollContainerRef,
festivalStart,
scheduleWindow,
timezone,
now,
}: UseTimelineScrollSyncOptions) {
const route =
"/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline" as const;

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);
// Scroll events at this position are programmatic, not user scrolling.
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,
scheduleWindow,
now,
});

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

if (targetScrollLeft !== container.scrollLeft) {
container.scrollLeft = targetScrollLeft;
// Read back: the browser clamps scrollLeft to the scrollable range.
programmaticScrollLeftRef.current = container.scrollLeft;
}
// Mount-only by design: URL -> scroll never re-runs after mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollContainerRef]);

useEffect(() => {
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() {
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);
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);
}
}, [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 };
}
9 changes: 9 additions & 0 deletions src/hooks/useTimelineUrlState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,17 @@ export type TimeFilter = TimelineSearch["time"];
export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") {
const route =
`/festivals/$festivalSlug/editions/$editionSlug/schedule/${tab}` as const;
// Select only the filter params this hook exposes, with structural sharing,
// so a `scrollTo` write (from scroll syncing) doesn't change this object's
// identity and trigger consumers to recompute the filtered schedule.
const state = useSearch({
from: route,
select: (search) => ({
day: search.day,
time: search.time,
stages: search.stages,
}),
structuralSharing: true,
});
const navigate = useNavigate({ from: route });

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
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
128 changes: 128 additions & 0 deletions src/lib/timelineCalculator.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
calculateScheduleWindow,
calculateTimelineData,
offsetToTime,
timeToOffset,
Expand Down Expand Up @@ -72,6 +73,133 @@ describe("offsetToTime", () => {
});
});

describe("calculateScheduleWindow", () => {
function makeDays(): ScheduleDay[] {
return [
{
date: "2024-07-01",
displayDate: "Jul 1",
stages: [
{
id: "stage-1",
name: "Main Stage",
stage_order: 0,
sets: [
makeSet({
id: "day1-main",
startTime: new Date("2024-07-01T10:37:00Z"),
endTime: new Date("2024-07-01T11:30:00Z"),
}),
],
},
{
id: "stage-2",
name: "Club Stage",
stage_order: 1,
sets: [
makeSet({
id: "day1-club",
startTime: new Date("2024-07-01T12:00:00Z"),
endTime: new Date("2024-07-01T13:00:00Z"),
}),
],
},
],
},
{
date: "2024-07-02",
displayDate: "Jul 2",
stages: [
{
id: "stage-1",
name: "Main Stage",
stage_order: 0,
sets: [
makeSet({
id: "day2-main",
startTime: new Date("2024-07-02T20:00:00Z"),
endTime: new Date("2024-07-02T21:15:00Z"),
}),
],
},
],
},
];
}

it("spans the earliest set start (hour-floored) to the latest set end (hour-ceiled) across all days and stages", () => {
const window = calculateScheduleWindow(makeDays());

expect(window).not.toBeNull();
expect(window!.start.getTime()).toBe(
new Date("2024-07-01T10:00:00Z").getTime(),
);
expect(window!.end.getTime()).toBe(
new Date("2024-07-02T21:59:59.999Z").getTime(),
);
});

it("returns null when no set has a time", () => {
const days: ScheduleDay[] = [
{
date: "2024-07-01",
displayDate: "Jul 1",
stages: [
{
id: "stage-1",
name: "Main Stage",
stage_order: 0,
sets: [makeSet()],
},
],
},
];

expect(calculateScheduleWindow(days)).toBeNull();
});

it("returns null for an empty schedule", () => {
expect(calculateScheduleWindow([])).toBeNull();
});

it("is unaffected by filtering only when fed the full schedule (a filtered subset yields a narrower window)", () => {
const allDays = makeDays();
const fullWindow = calculateScheduleWindow(allDays);

// Simulate a stage filter that keeps only stage-2's sets (day 1,
// 12:00-13:00 only).
const stageFilteredDays = allDays.map((day) => ({
...day,
stages: day.stages.filter((stage) => stage.id === "stage-2"),
}));
const stageFilteredWindow = calculateScheduleWindow(stageFilteredDays);

// The full window is what time-awareness must be gated on: the filtered
// subset's window has shrunk on both ends.
expect(fullWindow!.end.getTime()).toBe(
new Date("2024-07-02T21:59:59.999Z").getTime(),
);
expect(stageFilteredWindow!.start.getTime()).toBeGreaterThan(
fullWindow!.start.getTime(),
);
expect(stageFilteredWindow!.end.getTime()).toBeLessThan(
fullWindow!.end.getTime(),
);

// Simulate a day filter that drops day 2 entirely.
const dayFilteredDays = allDays.map((day) =>
day.date === "2024-07-01" ? day : { ...day, stages: [] },
);
const dayFilteredWindow = calculateScheduleWindow(dayFilteredDays);
expect(dayFilteredWindow!.end.getTime()).toBe(
new Date("2024-07-01T13:59:59.999Z").getTime(),
);
expect(dayFilteredWindow!.end.getTime()).toBeLessThan(
fullWindow!.end.getTime(),
);
});
});

describe("calculateTimelineData", () => {
it("returns null when there are no schedule days", () => {
expect(
Expand Down
Loading