diff --git a/src/lib/timelineOverviewGeometry.test.ts b/src/lib/timelineOverviewGeometry.test.ts new file mode 100644 index 00000000..8f7b0b9b --- /dev/null +++ b/src/lib/timelineOverviewGeometry.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { + calculateDayBoundaries, + calculateOverviewSetBlocks, + calculateOverviewViewport, + fractionToOffset, + offsetToPercent, +} from "./timelineOverviewGeometry"; +import type { HorizontalTimelineSet } from "./timelineCalculator"; + +const TIMEZONE = "Europe/Lisbon"; // UTC+1 in July (WEST) + +describe("offsetToPercent", () => { + it("expresses an offset as a percentage of totalWidth", () => { + expect(offsetToPercent(50, 200)).toBe(25); + }); + + it("returns 0 when totalWidth is zero or negative (no division by zero)", () => { + expect(offsetToPercent(50, 0)).toBe(0); + expect(offsetToPercent(50, -10)).toBe(0); + }); +}); + +describe("calculateOverviewSetBlocks", () => { + it("converts each set's horizontalPosition to a left/width percentage", () => { + const sets = [buildSet("set-1", 0, 100), buildSet("set-2", 100, 200)]; + + expect(calculateOverviewSetBlocks(sets, 400)).toEqual([ + { id: "set-1", leftPercent: 0, widthPercent: 25 }, + { id: "set-2", leftPercent: 25, widthPercent: 50 }, + ]); + }); + + it("skips sets without a computed horizontalPosition", () => { + const sets: HorizontalTimelineSet[] = [ + { id: "set-1", name: "set-1", artists: [] }, + buildSet("set-2", 0, 100), + ]; + + expect(calculateOverviewSetBlocks(sets, 400)).toEqual([ + { id: "set-2", leftPercent: 0, widthPercent: 25 }, + ]); + }); +}); + +describe("calculateDayBoundaries", () => { + it("places a boundary at each day's local midnight, in range", () => { + const festivalStart = new Date("2025-07-12T14:00:00Z"); + const days = [{ date: "2025-07-12" }, { date: "2025-07-13" }]; + + // Day 2 midnight (Europe/Lisbon) is 2025-07-12T23:00:00Z, 9h after start. + // PX_PER_MINUTE is 2, so offset = 9 * 60 * 2 = 1080. + const totalWidth = 2000; + const boundaries = calculateDayBoundaries( + days, + TIMEZONE, + festivalStart, + totalWidth, + ); + + expect(boundaries).toEqual([ + { date: "2025-07-13", leftPercent: offsetToPercent(1080, totalWidth) }, + ]); + }); + + it("drops boundaries outside the rendered [0, totalWidth] range", () => { + const festivalStart = new Date("2025-07-12T14:00:00Z"); + const days = [{ date: "2025-07-01" }, { date: "2025-12-25" }]; + + expect(calculateDayBoundaries(days, TIMEZONE, festivalStart, 2000)).toEqual( + [], + ); + }); + + it("returns no boundaries when totalWidth is zero", () => { + expect( + calculateDayBoundaries( + [{ date: "2025-07-12" }], + TIMEZONE, + new Date("2025-07-12T00:00:00Z"), + 0, + ), + ).toEqual([]); + }); +}); + +describe("calculateOverviewViewport", () => { + it("expresses the visible span as left/width percentages", () => { + expect(calculateOverviewViewport(100, 200, 1000)).toEqual({ + leftPercent: 10, + widthPercent: 20, + }); + }); + + it("caps widthPercent at 100 when the viewport is wider than the map", () => { + expect(calculateOverviewViewport(0, 1500, 1000)).toEqual({ + leftPercent: 0, + widthPercent: 100, + }); + }); + + it("falls back to a full-width viewport when totalWidth is zero", () => { + expect(calculateOverviewViewport(0, 500, 0)).toEqual({ + leftPercent: 0, + widthPercent: 100, + }); + }); +}); + +describe("fractionToOffset", () => { + it("scales a 0..1 fraction to the totalWidth", () => { + expect(fractionToOffset(0.25, 400)).toBe(100); + }); + + it("clamps fractions outside 0..1", () => { + expect(fractionToOffset(-0.5, 400)).toBe(0); + expect(fractionToOffset(1.5, 400)).toBe(400); + }); +}); + +function buildSet(id: string, left: number, width: number): HorizontalTimelineSet { + return { + id, + name: id, + artists: [], + horizontalPosition: { left, width }, + }; +} diff --git a/src/lib/timelineOverviewGeometry.ts b/src/lib/timelineOverviewGeometry.ts new file mode 100644 index 00000000..48da0145 --- /dev/null +++ b/src/lib/timelineOverviewGeometry.ts @@ -0,0 +1,106 @@ +import { fromZonedTime } from "date-fns-tz"; +import { timeToOffset } from "./timelineCalculator"; +import type { HorizontalTimelineSet } from "./timelineCalculator"; + +/** + * Pure geometry for the overview mini-map. There is exactly one time<->pixel + * scale in the app (`timeToOffset`/`offsetToTime` in `timelineCalculator`, + * anchored at `festivalStart`); this module never invents a second one. It + * only re-expresses those same offsets as a percentage of `totalWidth`, so + * the mini-map can render at any DOM width and still stay proportional to + * the full-size strip. + */ + +export function offsetToPercent(offset: number, totalWidth: number): number { + if (totalWidth <= 0) return 0; + return (offset / totalWidth) * 100; +} + +export interface OverviewSetBlock { + id: string; + leftPercent: number; + widthPercent: number; +} + +/** + * Proportional position/width of every set that has a computed + * `horizontalPosition`, for a single stage's overview row. + */ +export function calculateOverviewSetBlocks( + sets: HorizontalTimelineSet[], + totalWidth: number, +): OverviewSetBlock[] { + return sets + .filter((set) => !!set.horizontalPosition) + .map((set) => ({ + id: set.id, + leftPercent: offsetToPercent(set.horizontalPosition!.left, totalWidth), + widthPercent: offsetToPercent( + set.horizontalPosition!.width, + totalWidth, + ), + })); +} + +export interface OverviewDayBoundary { + date: string; + leftPercent: number; +} + +/** + * Proportional position of each day's local midnight, for the vertical + * boundary lines drawn on the map. A day whose midnight falls outside the + * currently rendered `[0, totalWidth]` range (e.g. every other day, when a + * `day` filter has narrowed the strip to a single day) is dropped - the map + * only ever shows what the strip already shows. + */ +export function calculateDayBoundaries( + days: Array<{ date: string }>, + timezone: string, + festivalStart: Date, + totalWidth: number, +): OverviewDayBoundary[] { + if (totalWidth <= 0) return []; + + return days + .map((day) => { + const midnight = fromZonedTime(`${day.date}T00:00:00`, timezone); + const offset = timeToOffset(midnight, festivalStart); + return { date: day.date, leftPercent: offsetToPercent(offset, totalWidth) }; + }) + .filter((boundary) => boundary.leftPercent >= 0 && boundary.leftPercent <= 100); +} + +export interface OverviewViewport { + leftPercent: number; + widthPercent: number; +} + +/** + * Proportional position/width of the strip's currently visible span, driven + * by the scroll container's own `scrollLeft`/`clientWidth` (read-only input + * here - this module never writes to the DOM). + */ +export function calculateOverviewViewport( + scrollLeft: number, + clientWidth: number, + totalWidth: number, +): OverviewViewport { + if (totalWidth <= 0) return { leftPercent: 0, widthPercent: 100 }; + + return { + leftPercent: offsetToPercent(scrollLeft, totalWidth), + widthPercent: Math.min(offsetToPercent(clientWidth, totalWidth), 100), + }; +} + +/** + * Converts a click/drag position on the map - expressed as a fraction of the + * map's own rendered pixel width (0..1, already clamped by the caller from a + * `getBoundingClientRect()` measurement) - into a timeline offset, using the + * same `totalWidth` scale as everything else. + */ +export function fractionToOffset(fraction: number, totalWidth: number): number { + const clamped = Math.max(0, Math.min(1, fraction)); + return clamped * totalWidth; +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/DayJumpButtons.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/DayJumpButtons.tsx index 1e30f21a..abf4cddb 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/DayJumpButtons.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/DayJumpButtons.tsx @@ -1,4 +1,4 @@ -import { Fragment } from "react"; +import { Fragment, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import { areFestivalDaysAdjacent, getFestivalDayParts } from "@/lib/timeUtils"; import { getDayJumpMoment } from "@/lib/timelineDayJump"; @@ -17,6 +17,16 @@ export function DayJumpButtons({ timezone, onJumpToDay, }: DayJumpButtonsProps) { + const activeButtonRef = useRef(null); + + useEffect(() => { + activeButtonRef.current?.scrollIntoView({ + behavior: "smooth", + inline: "center", + block: "nearest", + }); + }, [activeDay]); + return ( <> {days.map((day, index) => { @@ -37,6 +47,7 @@ export function DayJumpButtons({ type="button" role="radio" aria-checked={isActive} + ref={isActive ? activeButtonRef : undefined} onClick={() => onJumpToDay(getDayJumpMoment(day, timezone))} className={cn( "group relative shrink-0 rounded-md px-3 pb-1 pt-1.5 text-center transition-colors", diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/OverviewStageRow.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/OverviewStageRow.tsx new file mode 100644 index 00000000..c50b38d8 --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/OverviewStageRow.tsx @@ -0,0 +1,65 @@ +import { cn } from "@/lib/utils"; +import { getVoteConfig, VOTE_CONFIG } from "@/lib/voteConfig"; +import { calculateOverviewSetBlocks } from "@/lib/timelineOverviewGeometry"; +import type { HorizontalTimelineSet } from "@/lib/timelineCalculator"; +import { DEFAULT_STAGE_COLOR } from "@/lib/constants/stages"; + +interface OverviewStageRowProps { + sets: HorizontalTimelineSet[]; + totalWidth: number; + votes: Record | undefined; + stageColor: string | undefined; + top: number; + height: number; +} + +/** + * One thin density row per stage on the mini-map: a block per set, + * proportionally positioned/sized, colored by the stage's own color at low + * opacity (so the map reads as colorful before any voting happens) or the + * viewer's vote color, at full opacity, once they've voted on it. + * Absolutely positioned at a caller-computed top/height so the map's total + * height stays fixed no matter how many stages the festival has. + */ +export function OverviewStageRow({ + sets, + totalWidth, + votes, + stageColor, + top, + height, +}: OverviewStageRowProps) { + const blocks = calculateOverviewSetBlocks(sets, totalWidth); + + return ( +
+ {blocks.map((block) => { + const voteValue = votes?.[block.id]; + const voteType = + voteValue !== undefined ? getVoteConfig(voteValue) : undefined; + + return ( +
+ ); + })} +
+ ); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/OverviewViewportWindow.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/OverviewViewportWindow.tsx new file mode 100644 index 00000000..413ea9bb --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/OverviewViewportWindow.tsx @@ -0,0 +1,122 @@ +import { useRef } from "react"; +import type { RefObject } from "react"; + +interface OverviewViewportWindowProps { + leftPercent: number; + widthPercent: number; + mapRef: RefObject; + totalWidth: number; + scrollContainerRef: RefObject; +} + +interface DragState { + startClientX: number; + startScrollLeft: number; + mapWidthPx: number; +} + +/** + * The draggable indicator for the strip's currently visible span. + * + * Dragging writes `scrollContainer.scrollLeft` directly on every pointer + * move, rather than going through `jumpTo`: this is continuous, user-driven + * scrubbing, not a discrete jump, so there's no target moment to smooth- + * scroll toward and no reason to re-derive one every pixel. The existing + * scroll listener in `useTimelineScrollSync` can't tell the difference + * between this and native scrolling - it debounces whatever `scrollLeft` + * settles on into the `scrollTo` URL param exactly the same way. That keeps + * a single write path: this component only ever moves the scroll position, + * never touches the URL itself. + */ +export function OverviewViewportWindow({ + leftPercent, + widthPercent, + mapRef, + totalWidth, + scrollContainerRef, +}: OverviewViewportWindowProps) { + const dragStateRef = useRef(null); + + return ( +
event.stopPropagation()} + onPointerDown={handlePointerDown} + onKeyDown={handleKeyDown} + /> + ); + + function handlePointerDown(event: React.PointerEvent) { + const map = mapRef.current; + const container = scrollContainerRef.current; + if (!map || !container) return; + + event.stopPropagation(); + dragStateRef.current = { + startClientX: event.clientX, + startScrollLeft: container.scrollLeft, + mapWidthPx: map.getBoundingClientRect().width, + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + } + + function handlePointerMove(event: PointerEvent) { + const dragState = dragStateRef.current; + const container = scrollContainerRef.current; + if (!dragState || !container || dragState.mapWidthPx <= 0) return; + + const deltaPx = event.clientX - dragState.startClientX; + const deltaOffset = (deltaPx / dragState.mapWidthPx) * totalWidth; + const maxScrollLeft = Math.max(0, totalWidth - container.clientWidth); + + container.scrollLeft = Math.max( + 0, + Math.min(maxScrollLeft, dragState.startScrollLeft + deltaOffset), + ); + } + + function handlePointerUp() { + dragStateRef.current = null; + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + } + + function handleKeyDown(event: React.KeyboardEvent) { + const container = scrollContainerRef.current; + if (!container) return; + + const maxScrollLeft = Math.max(0, totalWidth - container.clientWidth); + const step = totalWidth * 0.05; + + let nextScrollLeft: number | null = null; + if (event.key === "ArrowLeft") { + nextScrollLeft = container.scrollLeft - step; + } else if (event.key === "ArrowRight") { + nextScrollLeft = container.scrollLeft + step; + } else if (event.key === "Home") { + nextScrollLeft = 0; + } else if (event.key === "End") { + nextScrollLeft = maxScrollLeft; + } + + if (nextScrollLeft === null) return; + + event.preventDefault(); + container.scrollLeft = Math.max( + 0, + Math.min(maxScrollLeft, nextScrollLeft), + ); + } +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx index 80e4a5fe..47cf01d2 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx @@ -1,8 +1,9 @@ -import { useRef } from "react"; +import { useRef, useState } from "react"; import { TimeScale } from "./TimeScale"; import { StageRow } from "./StageRow"; import { StageLabels } from "./StageLabels"; import { TimelineToolbar } from "./TimelineToolbar"; +import { TimelineOverview } from "./TimelineOverview"; import type { TimelineData } from "@/lib/timelineCalculator"; import type { ScheduleDay } from "@/hooks/useScheduleData"; import { useTimelineScrollSync } from "@/hooks/useTimelineScrollSync"; @@ -23,6 +24,7 @@ export function TimelineContainer({ selectedDay, }: TimelineContainerProps) { const scrollContainerRef = useRef(null); + const [isOverviewExpanded, setIsOverviewExpanded] = useState(false); useTimelineScrollSync({ scrollContainerRef, @@ -36,6 +38,15 @@ export function TimelineContainer({ festivalStart: timelineData.festivalStart, }); + function jumpTo(moment: Date, align: "center" | "start" = "center") { + const container = scrollContainerRef.current; + if (container) { + jumpToTimelineMoment(container, timelineData.festivalStart, moment, { + align, + }); + } + } + return ( <> { - const container = scrollContainerRef.current; - if (container) { - jumpToTimelineMoment( - container, - timelineData.festivalStart, - moment, - { - align: "start", - }, - ); - } - }} + onJumpToDay={(moment) => jumpTo(moment, "start")} + isOverviewExpanded={isOverviewExpanded} + onToggleOverview={() => setIsOverviewExpanded((prev) => !prev)} /> + {isOverviewExpanded && ( + jumpTo(moment, "center")} + /> + )}
; + onJump: (moment: Date) => void; +} + +/** + * Collapsible mini-map of the full (filtered) edition: one thin row per + * stage showing set density, day boundary lines, the viewer's voted sets in + * their vote color, and a draggable window over the strip's visible span. + * Built entirely from the same `timelineData` the strip renders, so it + * never shows more (or less) than the strip already does. + */ +export function TimelineOverview({ + timelineData, + scheduleDays, + timezone, + scrollContainerRef, + onJump, +}: TimelineOverviewProps) { + const mapRef = useRef(null); + const { user } = useAuth(); + const { data: userVotes } = useUserVotes(user?.id); + + const [viewportSize, setViewportSize] = useState(() => + readViewportSize(scrollContainerRef.current), + ); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + let rafId: number | null = null; + + function sync() { + setViewportSize(readViewportSize(container)); + } + + function scheduleSync() { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + sync(); + }); + } + + sync(); + container.addEventListener("scroll", scheduleSync, { passive: true }); + window.addEventListener("resize", scheduleSync); + return () => { + if (rafId !== null) cancelAnimationFrame(rafId); + container.removeEventListener("scroll", scheduleSync); + window.removeEventListener("resize", scheduleSync); + }; + }, [scrollContainerRef]); + + const dayBoundaries = calculateDayBoundaries( + scheduleDays, + timezone, + timelineData.festivalStart, + timelineData.totalWidth, + ); + + const viewportWindow = calculateOverviewViewport( + viewportSize.scrollLeft, + viewportSize.clientWidth, + timelineData.totalWidth, + ); + + const stageCount = timelineData.stages.length; + const rowHeight = + stageCount > 0 + ? Math.max(3, Math.floor((MAP_HEIGHT_PX - LABEL_HEIGHT_PX - 4) / stageCount)) + : 4; + + return ( +
+
+ {dayBoundaries.map((boundary) => { + const parts = getFestivalDayParts(boundary.date); + return ( +
+ {parts && ( + + {parts.weekday} {parts.dayOfMonth} + + )} +
+ ); + })} + + {timelineData.stages.map((stage, index) => ( + + ))} + + +
+
+ ); + + function handleMapClick(event: React.MouseEvent) { + const map = mapRef.current; + if (!map) return; + + const rect = map.getBoundingClientRect(); + if (rect.width <= 0) return; + + const fraction = (event.clientX - rect.left) / rect.width; + const offset = fractionToOffset(fraction, timelineData.totalWidth); + onJump(offsetToTime(offset, timelineData.festivalStart)); + } +} + +function readViewportSize(container: HTMLDivElement | null) { + return { + scrollLeft: container?.scrollLeft ?? 0, + clientWidth: container?.clientWidth ?? 0, + }; +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx index 6fb4a098..039ce1b6 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx @@ -1,4 +1,7 @@ +import { useEffect, useRef, useState } from "react"; +import { Map } from "lucide-react"; import { DayJumpButtons } from "./DayJumpButtons"; +import { Button } from "@/components/ui/button"; import type { ScheduleDay } from "@/hooks/useScheduleData"; interface TimelineToolbarProps { @@ -7,8 +10,13 @@ interface TimelineToolbarProps { activeDay: string | null; timezone: string; onJumpToDay: (moment: Date) => void; + isOverviewExpanded: boolean; + onToggleOverview: () => void; } +// Width of the edge-fade hinting that the day row scrolls further that way. +const SCROLL_FADE_PX = 24; + // 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({ @@ -17,27 +25,93 @@ export function TimelineToolbar({ activeDay, timezone, onJumpToDay, + isOverviewExpanded, + onToggleOverview, }: TimelineToolbarProps) { + const dayRowRef = useRef(null); + const [scrollFade, setScrollFade] = useState({ left: false, right: false }); + const visibleDays = selectedDay === "all" ? days : days.filter((day) => day.date === selectedDay); + useEffect(() => { + const el = dayRowRef.current; + if (!el) return; + + let rafId: number | null = null; + + function sync() { + const node = el!; + setScrollFade({ + left: node.scrollLeft > 1, + right: node.scrollLeft + node.clientWidth < node.scrollWidth - 1, + }); + } + + function scheduleSync() { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + sync(); + }); + } + + sync(); + el.addEventListener("scroll", scheduleSync, { passive: true }); + window.addEventListener("resize", scheduleSync); + return () => { + if (rafId !== null) cancelAnimationFrame(rafId); + el.removeEventListener("scroll", scheduleSync); + window.removeEventListener("resize", scheduleSync); + }; + }, [visibleDays.length]); + if (visibleDays.length === 0) return null; + const maskImage = `linear-gradient(to right, ${ + scrollFade.left ? "transparent" : "black" + } 0, black ${SCROLL_FADE_PX}px, black calc(100% - ${SCROLL_FADE_PX}px), ${ + scrollFade.right ? "transparent" : "black" + } 100%)`; + return (
- +
+ +
+
); } diff --git a/tests/e2e/timeline-overview.spec.ts b/tests/e2e/timeline-overview.spec.ts new file mode 100644 index 00000000..5bf6b567 --- /dev/null +++ b/tests/e2e/timeline-overview.spec.ts @@ -0,0 +1,125 @@ +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 +const DEBOUNCE_WAIT_MS = 600; // > the ~300ms debounce in useTimelineScrollSync + +test.describe("Timeline overview mini-map", () => { + test("collapsed by default; toggle expands and collapses it", async ({ + page, + }) => { + await page.goto(TIMELINE_PATH); + + const scrollContainer = page.getByTestId("timeline-scroll-container"); + await expect(scrollContainer).toBeVisible({ timeout: 15000 }); + + const overview = page.getByTestId("timeline-overview"); + await expect(overview).not.toBeVisible(); + + const toggle = page.getByTestId("timeline-overview-toggle"); + await expect(toggle).toHaveText("Show overview"); + + await toggle.click(); + await expect(overview).toBeVisible(); + await expect(toggle).toHaveText("Hide overview"); + + await toggle.click(); + await expect(overview).not.toBeVisible(); + await expect(toggle).toHaveText("Show overview"); + }); + + test("clicking the map jumps the strip and writes scrollTo", async ({ + page, + }) => { + await page.goto(TIMELINE_PATH); + + const scrollContainer = page.getByTestId("timeline-scroll-container"); + await expect(scrollContainer).toBeVisible({ timeout: 15000 }); + + await page.getByTestId("timeline-overview-toggle").click(); + const map = page.getByTestId("timeline-overview-map"); + await expect(map).toBeVisible(); + + const scrollLeftBeforeJump = await scrollContainer.evaluate( + (el) => el.scrollLeft, + ); + + const mapBox = await map.boundingBox(); + if (!mapBox) throw new Error("overview map has no bounding box"); + + // Click near the right edge of the map, far from wherever the strip + // is currently centered. + await page.mouse.click(mapBox.x + mapBox.width * 0.9, mapBox.y + mapBox.height / 2); + + 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("dragging the viewport window scrubs the strip and settles into scrollTo", async ({ + page, + }) => { + await page.goto(TIMELINE_PATH); + + const scrollContainer = page.getByTestId("timeline-scroll-container"); + await expect(scrollContainer).toBeVisible({ timeout: 15000 }); + + await page.getByTestId("timeline-overview-toggle").click(); + const viewportWindow = page.getByTestId("timeline-overview-viewport"); + await expect(viewportWindow).toBeVisible(); + + const scrollLeftBeforeDrag = await scrollContainer.evaluate( + (el) => el.scrollLeft, + ); + + const handleBox = await viewportWindow.boundingBox(); + if (!handleBox) throw new Error("viewport window has no bounding box"); + + const startX = handleBox.x + handleBox.width / 2; + const startY = handleBox.y + handleBox.height / 2; + + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(startX + 150, startY, { steps: 10 }); + + // The strip should already have moved mid-drag: dragging writes + // scrollLeft directly, not through the debounced/smooth jumpTo path. + const scrollLeftMidDrag = await scrollContainer.evaluate( + (el) => el.scrollLeft, + ); + expect(scrollLeftMidDrag).not.toBe(scrollLeftBeforeDrag); + + await page.mouse.up(); + + await page.waitForTimeout(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"); + }); + + test("with a day filter active, the map reflects only that day", async ({ + page, + }) => { + await page.goto(`${TIMELINE_PATH}?day=2025-07-12`); + + const scrollContainer = page.getByTestId("timeline-scroll-container"); + await expect(scrollContainer).toBeVisible({ timeout: 15000 }); + + await page.getByTestId("timeline-overview-toggle").click(); + await expect(page.getByTestId("timeline-overview")).toBeVisible(); + + const stageRows = page.getByTestId("timeline-overview-stage-row"); + expect(await stageRows.count()).toBeGreaterThan(0); + }); +});