diff --git a/src/hooks/useNow.ts b/src/hooks/useNow.ts new file mode 100644 index 00000000..4f32db26 --- /dev/null +++ b/src/hooks/useNow.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react"; + +const DEFAULT_INTERVAL_MS = 60_000; + +export function useNow(intervalMs = DEFAULT_INTERVAL_MS): Date { + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const id = window.setInterval(() => setNow(new Date()), intervalMs); + return () => window.clearInterval(id); + }, [intervalMs]); + + return now; +} diff --git a/src/hooks/useTimelineScrollSync.ts b/src/hooks/useTimelineScrollSync.ts index 99f785ec..33a5e570 100644 --- a/src/hooks/useTimelineScrollSync.ts +++ b/src/hooks/useTimelineScrollSync.ts @@ -1,7 +1,11 @@ import { useEffect, useLayoutEffect, useRef } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import type { RefObject } from "react"; -import { offsetToTime, timeToOffset } from "@/lib/timelineCalculator"; +import { + offsetToTime, + timeToOffset, + type ScheduleWindow, +} from "@/lib/timelineCalculator"; import { resolveTimelineMountMoment, roundToNearestMinutes, @@ -13,19 +17,21 @@ const SCROLL_ROUND_MINUTES = 5; interface UseTimelineScrollSyncOptions { scrollContainerRef: RefObject; festivalStart: Date; + scheduleWindow: ScheduleWindow | null; timezone: string; + now: Date; } /** - * One-way sync between the timeline viewport and the `scrollTo` URL param: - * URL -> scroll only on mount; user scroll -> URL only (debounced, 5-min - * rounded, history replace). Never loops. See `jumpToTimelineMoment` for - * imperative jumps. + * One-way sync: URL -> scroll only on mount; user scroll -> URL only + * (debounced, 5-min rounded, history replace). Never loops. */ export function useTimelineScrollSync({ scrollContainerRef, festivalStart, + scheduleWindow, timezone, + now, }: UseTimelineScrollSyncOptions) { const route = "/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline" as const; @@ -51,6 +57,8 @@ export function useTimelineScrollSync({ day, timezone, festivalStart, + scheduleWindow, + now, }); const targetScrollLeft = Math.max( diff --git a/src/lib/timelineCalculator.test.ts b/src/lib/timelineCalculator.test.ts index 1752b89a..4b307983 100644 --- a/src/lib/timelineCalculator.test.ts +++ b/src/lib/timelineCalculator.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest"; import { + calculateScheduleWindow, calculateTimelineData, offsetToTime, timeToOffset, } from "./timelineCalculator"; -import type { ScheduleSet } from "@/hooks/useScheduleData"; +import type { ScheduleDay, ScheduleSet } from "@/hooks/useScheduleData"; import { makeScheduleDay, makeStage } from "@/__tests__/fixtures"; const PX_PER_MINUTE = 2; @@ -72,6 +73,80 @@ describe("offsetToTime", () => { }); }); +describe("calculateScheduleWindow", () => { + 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( @@ -243,3 +318,48 @@ function makeSet(overrides: Partial = {}): ScheduleSet { }; } +function makeDays(): ScheduleDay[] { + return [ + makeScheduleDay("2024-07-01", [ + { + 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"), + }), + ], + }, + ]), + makeScheduleDay("2024-07-02", [ + { + 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"), + }), + ], + }, + ]), + ]; +} + diff --git a/src/lib/timelineCalculator.ts b/src/lib/timelineCalculator.ts index 9edec888..c2a8212e 100644 --- a/src/lib/timelineCalculator.ts +++ b/src/lib/timelineCalculator.ts @@ -15,6 +15,24 @@ export function offsetToTime(offset: number, origin: Date): Date { return new Date(origin.getTime() + minutes * 60 * 1000); } +export interface ScheduleWindow { + start: Date; + end: Date; +} + +/** + * Earliest set start through latest set end, across the UNFILTERED schedule + * days (unlike TimelineData.festivalStart/End, which shrink with filters). + */ +export function calculateScheduleWindow( + scheduleDays: ScheduleDay[], +): ScheduleWindow | null { + const start = calculateEarliestSetTime(scheduleDays); + const end = calculateLatestSetTime(scheduleDays); + if (!start || !end) return null; + return { start, end }; +} + export interface HorizontalTimelineSet extends ScheduleSet { horizontalPosition?: { left: number; diff --git a/src/lib/timelineMountMoment.test.ts b/src/lib/timelineMountMoment.test.ts index fbab3ed2..3a02ff1b 100644 --- a/src/lib/timelineMountMoment.test.ts +++ b/src/lib/timelineMountMoment.test.ts @@ -1,11 +1,20 @@ import { describe, expect, it } from "vitest"; import { + isNowWithinFestivalWindow, resolveTimelineMountMoment, roundToNearestMinutes, } from "./timelineMountMoment"; const TIMEZONE = "Europe/Lisbon"; // UTC+1 in July (WEST) const FESTIVAL_START = new Date("2025-07-12T10:00:00Z"); +const FESTIVAL_END = new Date("2025-07-14T23:00:00Z"); +const SCHEDULE_WINDOW = { start: FESTIVAL_START, end: FESTIVAL_END }; +// Comfortably inside the schedule window. +const NOW_INSIDE_WINDOW = new Date("2025-07-13T20:00:00Z"); +// Before the festival window opens. +const NOW_BEFORE_WINDOW = new Date("2025-07-10T10:00:00Z"); +// After the festival window closes. +const NOW_AFTER_WINDOW = new Date("2025-07-15T10:00:00Z"); describe("resolveTimelineMountMoment", () => { it("prefers scrollTo when present and valid", () => { @@ -14,6 +23,8 @@ describe("resolveTimelineMountMoment", () => { day: "2025-07-12", timezone: TIMEZONE, festivalStart: FESTIVAL_START, + scheduleWindow: SCHEDULE_WINDOW, + now: NOW_INSIDE_WINDOW, }); expect(moment.getTime()).toBe( @@ -27,6 +38,8 @@ describe("resolveTimelineMountMoment", () => { day: "2025-07-13", timezone: TIMEZONE, festivalStart: FESTIVAL_START, + scheduleWindow: SCHEDULE_WINDOW, + now: NOW_INSIDE_WINDOW, }); // Midnight in Europe/Lisbon (UTC+1 in July) is 23:00 UTC the prior day. @@ -41,6 +54,8 @@ describe("resolveTimelineMountMoment", () => { day: "2025-07-13", timezone: TIMEZONE, festivalStart: FESTIVAL_START, + scheduleWindow: SCHEDULE_WINDOW, + now: NOW_INSIDE_WINDOW, }); expect(moment.getTime()).toBe( @@ -48,23 +63,85 @@ describe("resolveTimelineMountMoment", () => { ); }); - it("falls back to festivalStart when day filter is 'all' and scrollTo is absent", () => { + it("falls back to now minus ~1h when day filter is 'all', scrollTo is absent, and now is inside the window", () => { const moment = resolveTimelineMountMoment({ scrollTo: undefined, day: "all", timezone: TIMEZONE, festivalStart: FESTIVAL_START, + scheduleWindow: SCHEDULE_WINDOW, + now: NOW_INSIDE_WINDOW, + }); + + expect(moment.getTime()).toBe( + NOW_INSIDE_WINDOW.getTime() - 60 * 60 * 1000, + ); + }); + + it("clamps the now rule to the window start when now is within the window's first hour", () => { + // Window starting later than festivalStart, so a clamped result is + // distinguishable from the festivalStart fallback. + const windowStart = new Date("2025-07-12T16:00:00Z"); + const nowNearWindowStart = new Date("2025-07-12T16:10:00Z"); // 10 minutes in + const moment = resolveTimelineMountMoment({ + scrollTo: undefined, + day: "all", + timezone: TIMEZONE, + festivalStart: FESTIVAL_START, + scheduleWindow: { start: windowStart, end: FESTIVAL_END }, + now: nowNearWindowStart, + }); + + expect(moment.getTime()).toBe(windowStart.getTime()); + }); + + it("falls back to festivalStart when the schedule window is null (no timed sets)", () => { + const moment = resolveTimelineMountMoment({ + scrollTo: undefined, + day: "all", + timezone: TIMEZONE, + festivalStart: FESTIVAL_START, + scheduleWindow: null, + now: NOW_INSIDE_WINDOW, + }); + + expect(moment.getTime()).toBe(FESTIVAL_START.getTime()); + }); + + it("falls back to festivalStart when now is before the festival window", () => { + const moment = resolveTimelineMountMoment({ + scrollTo: undefined, + day: "all", + timezone: TIMEZONE, + festivalStart: FESTIVAL_START, + scheduleWindow: SCHEDULE_WINDOW, + now: NOW_BEFORE_WINDOW, }); expect(moment.getTime()).toBe(FESTIVAL_START.getTime()); }); - it("falls back to festivalStart when scrollTo is invalid and day is 'all'", () => { + it("falls back to festivalStart when now is after the festival window", () => { + const moment = resolveTimelineMountMoment({ + scrollTo: undefined, + day: "all", + timezone: TIMEZONE, + festivalStart: FESTIVAL_START, + scheduleWindow: SCHEDULE_WINDOW, + now: NOW_AFTER_WINDOW, + }); + + expect(moment.getTime()).toBe(FESTIVAL_START.getTime()); + }); + + it("falls back to festivalStart when scrollTo is invalid, day is 'all', and now is outside the window", () => { const moment = resolveTimelineMountMoment({ scrollTo: "garbage", day: "all", timezone: TIMEZONE, festivalStart: FESTIVAL_START, + scheduleWindow: SCHEDULE_WINDOW, + now: NOW_AFTER_WINDOW, }); expect(moment.getTime()).toBe(FESTIVAL_START.getTime()); @@ -76,12 +153,61 @@ describe("resolveTimelineMountMoment", () => { day: "2025-07-13", timezone: TIMEZONE, festivalStart: FESTIVAL_START, + scheduleWindow: SCHEDULE_WINDOW, + now: NOW_INSIDE_WINDOW, }); expect(moment.getTime()).toBe( new Date("2025-07-14T12:00:00.000Z").getTime(), ); }); + + it("the day filter takes precedence over the now rule", () => { + const moment = resolveTimelineMountMoment({ + scrollTo: undefined, + day: "2025-07-13", + timezone: TIMEZONE, + festivalStart: FESTIVAL_START, + scheduleWindow: SCHEDULE_WINDOW, + now: NOW_INSIDE_WINDOW, + }); + + expect(moment.getTime()).toBe( + new Date("2025-07-12T23:00:00.000Z").getTime(), + ); + }); +}); + +describe("isNowWithinFestivalWindow", () => { + it("is true strictly inside the window", () => { + expect( + isNowWithinFestivalWindow(NOW_INSIDE_WINDOW, FESTIVAL_START, FESTIVAL_END), + ).toBe(true); + }); + + it("is true exactly at the window's start (inclusive)", () => { + expect( + isNowWithinFestivalWindow(FESTIVAL_START, FESTIVAL_START, FESTIVAL_END), + ).toBe(true); + }); + + it("is true exactly at the window's end (inclusive)", () => { + expect( + isNowWithinFestivalWindow(FESTIVAL_END, FESTIVAL_START, FESTIVAL_END), + ).toBe(true); + }); + + it("is false before the window opens", () => { + expect( + isNowWithinFestivalWindow(NOW_BEFORE_WINDOW, FESTIVAL_START, FESTIVAL_END), + ).toBe(false); + }); + + it("is false after the window closes", () => { + expect( + isNowWithinFestivalWindow(NOW_AFTER_WINDOW, FESTIVAL_START, FESTIVAL_END), + ).toBe(false); + }); }); describe("roundToNearestMinutes", () => { diff --git a/src/lib/timelineMountMoment.ts b/src/lib/timelineMountMoment.ts index 24cb169b..d9f061ef 100644 --- a/src/lib/timelineMountMoment.ts +++ b/src/lib/timelineMountMoment.ts @@ -1,39 +1,45 @@ import { isValid, parseISO } from "date-fns"; import { fromZonedTime } from "date-fns-tz"; +import type { ScheduleWindow } from "@/lib/timelineCalculator"; export interface TimelineMountMomentInput { - /** Raw `scrollTo` search param, if present in the URL. */ 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; + scheduleWindow: ScheduleWindow | null; + now: 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). - */ +const NOW_CONTEXT_MS = 60 * 60 * 1000; // ~1h of context before "now" + +/** Mount-precedence order: scrollTo -> day filter -> now-1h -> festival start. */ export function resolveTimelineMountMoment( input: TimelineMountMomentInput, ): Date { return ( momentFromScrollTo(input.scrollTo) ?? momentFromDayFilter(input.day, input.timezone) ?? + momentFromNow(input.now, input.scheduleWindow) ?? input.festivalStart ); } +/** + * Inclusive check of `now` against a festival window (set times, not the + * edition's raw UTC-midnight start/end dates). + */ +export function isNowWithinFestivalWindow( + now: Date, + festivalStart: Date, + festivalEnd: Date, +): boolean { + return ( + now.getTime() >= festivalStart.getTime() && + now.getTime() <= festivalEnd.getTime() + ); +} + function momentFromScrollTo(scrollTo: string | undefined): Date | null { if (!scrollTo) return null; const parsed = parseISO(scrollTo); @@ -50,10 +56,18 @@ function momentFromDayFilter(day: string, timezone: string): Date | null { } } -/** - * Rounds a moment to the nearest multiple of `minutes` (default 5), used to - * keep `scrollTo` URL writes coarse-grained instead of pixel-precise. - */ +function momentFromNow( + now: Date, + scheduleWindow: ScheduleWindow | null, +): Date | null { + if (!scheduleWindow) return null; + if (!isNowWithinFestivalWindow(now, scheduleWindow.start, scheduleWindow.end)) + return null; + return new Date( + Math.max(now.getTime() - NOW_CONTEXT_MS, scheduleWindow.start.getTime()), + ); +} + export function roundToNearestMinutes(date: Date, minutes = 5): Date { const ms = minutes * 60 * 1000; return new Date(Math.round(date.getTime() / ms) * ms); diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/CurrentTimeIndicator.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/CurrentTimeIndicator.tsx new file mode 100644 index 00000000..00ded44c --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/CurrentTimeIndicator.tsx @@ -0,0 +1,15 @@ +interface CurrentTimeIndicatorProps { + left: number; +} + +export function CurrentTimeIndicator({ left }: CurrentTimeIndicatorProps) { + return ( +
+
+
+ ); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/NowButton.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/NowButton.tsx new file mode 100644 index 00000000..85a35f24 --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/NowButton.tsx @@ -0,0 +1,24 @@ +import { Button } from "@/components/ui/button"; + +interface NowButtonProps { + onJumpToNow: () => void; +} + +export function NowButton({ onJumpToNow }: NowButtonProps) { + return ( + + ); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx index 61e649b7..88498c42 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx @@ -2,11 +2,15 @@ import { useMemo } from "react"; import { useSuspenseQuery } from "@tanstack/react-query"; import { useRouteContext } from "@tanstack/react-router"; import { useScheduleData } from "@/hooks/useScheduleData"; -import { calculateTimelineData } from "@/lib/timelineCalculator"; +import { + calculateScheduleWindow, + calculateTimelineData, +} from "@/lib/timelineCalculator"; import { TimelineContainer } from "./TimelineContainer"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { useSetsByEditionQuery as useEditionSetsQuery } from "@/api/sets/useSetsByEdition"; import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; +import { useNow } from "@/hooks/useNow"; import { filterScheduleDays } from "@/lib/scheduleFilter"; import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; import { useScheduleReveal } from "@/hooks/useScheduleReveal"; @@ -18,6 +22,7 @@ export function Timeline() { from: "/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline", }); const { canShowTime } = useScheduleReveal(); + const now = useNow(); const { data: editionSets = [], isLoading: setsLoading } = useEditionSetsQuery(edition.id); const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); @@ -33,6 +38,11 @@ export function Timeline() { stages: selectedStages, } = useTimelineUrlState("timeline"); + const scheduleWindow = useMemo( + () => calculateScheduleWindow(scheduleDays), + [scheduleDays], + ); + const timelineData = useMemo(() => { if (!edition.start_date || !edition.end_date) { return null; @@ -96,6 +106,8 @@ export function Timeline() { timezone={festival.timezone} scheduleDays={scheduleDays} selectedDay={selectedDay} + scheduleWindow={scheduleWindow} + now={now} />
diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx index 47cf01d2..0d330606 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx @@ -4,7 +4,13 @@ import { StageRow } from "./StageRow"; import { StageLabels } from "./StageLabels"; import { TimelineToolbar } from "./TimelineToolbar"; import { TimelineOverview } from "./TimelineOverview"; -import type { TimelineData } from "@/lib/timelineCalculator"; +import { CurrentTimeIndicator } from "./CurrentTimeIndicator"; +import { + timeToOffset, + type ScheduleWindow, + type TimelineData, +} from "@/lib/timelineCalculator"; +import { isNowWithinFestivalWindow } from "@/lib/timelineMountMoment"; import type { ScheduleDay } from "@/hooks/useScheduleData"; import { useTimelineScrollSync } from "@/hooks/useTimelineScrollSync"; import { jumpToTimelineMoment } from "@/lib/timelineDayJump"; @@ -15,6 +21,8 @@ interface TimelineContainerProps { timezone: string; scheduleDays: ScheduleDay[]; selectedDay: string; + scheduleWindow: ScheduleWindow | null; + now: Date; } export function TimelineContainer({ @@ -22,6 +30,8 @@ export function TimelineContainer({ timezone, scheduleDays, selectedDay, + scheduleWindow, + now, }: TimelineContainerProps) { const scrollContainerRef = useRef(null); const [isOverviewExpanded, setIsOverviewExpanded] = useState(false); @@ -29,7 +39,9 @@ export function TimelineContainer({ useTimelineScrollSync({ scrollContainerRef, festivalStart: timelineData.festivalStart, + scheduleWindow, timezone, + now, }); const activeDay = useActiveTimelineDay({ scrollContainerRef, @@ -47,6 +59,13 @@ export function TimelineContainer({ } } + // Gated on the unfiltered scheduleWindow so a stage/time filter can't hide these. + const isNowInFestivalWindow = + scheduleWindow !== null && + isNowWithinFestivalWindow(now, scheduleWindow.start, scheduleWindow.end); + const showNowButton = isNowInFestivalWindow; + const showNowIndicator = isNowInFestivalWindow; + return ( <> jumpTo(moment, "start")} isOverviewExpanded={isOverviewExpanded} onToggleOverview={() => setIsOverviewExpanded((prev) => !prev)} + showNowButton={showNowButton} + onJumpToNow={() => jumpTo(now)} /> {isOverviewExpanded && ( - +
+ + +
+ {timelineData.stages.map((stage) => ( + + ))} +
-
- {timelineData.stages.map((stage) => ( - - ))} + )}
diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx index ba13aa0e..a5bd3d95 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx @@ -1,6 +1,7 @@ import { useRef } from "react"; import { Map } from "lucide-react"; import { DayJumpButtons } from "./DayJumpButtons"; +import { NowButton } from "./NowButton"; import { Button } from "@/components/ui/button"; import type { ScheduleDay } from "@/hooks/useScheduleData"; import { useScrollEdgeFade } from "./useScrollEdgeFade"; @@ -13,6 +14,8 @@ interface TimelineToolbarProps { onJumpToDay: (moment: Date) => void; isOverviewExpanded: boolean; onToggleOverview: () => void; + showNowButton: boolean; + onJumpToNow: () => void; } // Width of the edge-fade hinting that the day row scrolls further that way. @@ -28,6 +31,8 @@ export function TimelineToolbar({ onJumpToDay, isOverviewExpanded, onToggleOverview, + showNowButton, + onJumpToNow, }: TimelineToolbarProps) { const dayRowRef = useRef(null); @@ -38,7 +43,7 @@ export function TimelineToolbar({ const scrollFade = useScrollEdgeFade(dayRowRef, [visibleDays.length]); - if (visibleDays.length === 0) return null; + if (visibleDays.length === 0 && !showNowButton) return null; const maskImage = `linear-gradient(to right, ${ scrollFade.left ? "transparent" : "black" @@ -67,21 +72,24 @@ export function TimelineToolbar({ onJumpToDay={onJumpToDay} /> - +
+ {showNowButton && } + +
); } diff --git a/tests/e2e/timeline-now-indicator.spec.ts b/tests/e2e/timeline-now-indicator.spec.ts new file mode 100644 index 00000000..0c45cb26 --- /dev/null +++ b/tests/e2e/timeline-now-indicator.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from "@playwright/test"; + +// Seeded in supabase/seed.sql: festival slug "test", edition slug "2025", +// three festival days (Jul 12-14, 2025). Sets span from Fri Jul 12 16:00 UTC +// (earliest set start) through Sun Jul 14 23:00 UTC (latest set end) - the +// rendered timeline's festival window. +const TIMELINE_PATH = "/festivals/test/editions/2025/schedule/timeline"; +const SCROLL_ANIMATION_WAIT_MS = 800; // > smooth-scroll animation + the ~300ms debounce + +// Comfortably inside the seeded festival window. +const NOW_INSIDE_WINDOW = new Date("2025-07-13T12:00:00Z"); +// Two days before the earliest seeded set. +const NOW_BEFORE_WINDOW = new Date("2025-07-10T12:00:00Z"); +// Two days after the latest seeded set ends. +const NOW_AFTER_WINDOW = new Date("2025-07-16T12:00:00Z"); + +test.describe("Timeline Now pill and current-time indicator", () => { + test("render when now falls inside the festival window", async ({ + page, + }) => { + await page.clock.setFixedTime(NOW_INSIDE_WINDOW); + await page.goto(TIMELINE_PATH); + + const scrollContainer = page.getByTestId("timeline-scroll-container"); + if (!(await scrollContainer.isVisible().catch(() => false))) { + test.skip(true, "Schedule not revealed in this environment"); + } + + const nowButton = page.getByTestId("now-jump-button"); + await expect(nowButton).toBeVisible(); + + const indicator = page.getByTestId("timeline-now-indicator"); + await expect(indicator).toBeVisible(); + + const left = await indicator.evaluate((el) => + parseFloat((el as HTMLElement).style.left), + ); + expect(left).toBeGreaterThan(0); + expect(Number.isFinite(left)).toBe(true); + }); + + test("are absent when now is before the festival window", async ({ + page, + }) => { + await page.clock.setFixedTime(NOW_BEFORE_WINDOW); + await page.goto(TIMELINE_PATH); + + const scrollContainer = page.getByTestId("timeline-scroll-container"); + if (!(await scrollContainer.isVisible().catch(() => false))) { + test.skip(true, "Schedule not revealed in this environment"); + } + + await expect(page.getByTestId("now-jump-button")).toHaveCount(0); + await expect(page.getByTestId("timeline-now-indicator")).toHaveCount(0); + }); + + test("are absent when now is after the festival window", async ({ + page, + }) => { + await page.clock.setFixedTime(NOW_AFTER_WINDOW); + await page.goto(TIMELINE_PATH); + + const scrollContainer = page.getByTestId("timeline-scroll-container"); + if (!(await scrollContainer.isVisible().catch(() => false))) { + test.skip(true, "Schedule not revealed in this environment"); + } + + await expect(page.getByTestId("now-jump-button")).toHaveCount(0); + await expect(page.getByTestId("timeline-now-indicator")).toHaveCount(0); + }); + + test("tapping Now writes scrollTo and smooth-scrolls to the current time", async ({ + page, + }) => { + await page.clock.setFixedTime(NOW_INSIDE_WINDOW); + await page.goto(TIMELINE_PATH); + + const scrollContainer = page.getByTestId("timeline-scroll-container"); + if (!(await scrollContainer.isVisible().catch(() => false))) { + test.skip(true, "Schedule not revealed in this environment"); + } + + const nowButton = page.getByTestId("now-jump-button"); + await expect(nowButton).toBeVisible(); + + // Scroll away first so the jump is observable. + await scrollContainer.evaluate((el) => { + el.scrollLeft = 0; + }); + + await nowButton.click(); + await page.waitForTimeout(SCROLL_ANIMATION_WAIT_MS); + + await expect(page).toHaveURL(/scrollTo=/); + const scrollTo = new URL(page.url()).searchParams.get("scrollTo"); + expect(scrollTo).toBeTruthy(); + + const scrollToDate = new Date(scrollTo as string); + expect(scrollToDate.toString()).not.toBe("Invalid Date"); + // Rounded to 5-minute granularity, so allow a small window either side + // of the fixed "now". + expect( + Math.abs(scrollToDate.getTime() - NOW_INSIDE_WINDOW.getTime()), + ).toBeLessThan(5 * 60 * 1000); + + const scrollLeftAfterJump = await scrollContainer.evaluate( + (el) => el.scrollLeft, + ); + expect(scrollLeftAfterJump).toBeGreaterThan(0); + }); +}); diff --git a/tests/e2e/timeline-scroll.spec.ts b/tests/e2e/timeline-scroll.spec.ts index 91c59f7e..ff10169a 100644 --- a/tests/e2e/timeline-scroll.spec.ts +++ b/tests/e2e/timeline-scroll.spec.ts @@ -95,6 +95,39 @@ test.describe("Timeline scroll position (scrollTo URL state)", () => { expect(Math.abs(scrollLeftOnLoad - scrollLeftAfterScroll)).toBeLessThan(10); }); + test("back from a set detail page restores the viewport position", async ({ + page, + }) => { + const scrollContainer = await openTimeline(page); + + 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(); + await expect(setLink).toBeVisible(); + await setLink.click(); + await page.goBack(); + await expect(page).toHaveURL(urlWithScroll); + + const restoredContainer = page.getByTestId("timeline-scroll-container"); + await expect(restoredContainer).toBeVisible(); + await expect + .poll(async () => + Math.abs( + (await restoredContainer.evaluate((el) => el.scrollLeft)) - + scrollLeftBeforeNav, + ), + ) + .toBeLessThan(10); + }); + test("a full reload restores the viewport position from scrollTo", async ({ page, }) => {