From 26140926e85e57b31d97059e62cd55f5742d22e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:19:25 +0000 Subject: [PATCH 1/3] refactor(schedule): extract shared pure schedule filter Timeline and ListSchedule each had their own inline day/time/stage filter predicates. Extract filterScheduleDays into src/lib/scheduleFilter.ts so both views share one pure, unit-tested implementation, and remove a stray debug console.log from ListSchedule in the process. Closes #190 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/lib/scheduleFilter.test.ts | 298 ++++++++++++++++++ src/lib/scheduleFilter.ts | 73 +++++ .../tabs/ScheduleTab/horizontal/Timeline.tsx | 50 +-- .../tabs/ScheduleTab/list/ListSchedule.tsx | 61 +--- 4 files changed, 389 insertions(+), 93 deletions(-) create mode 100644 src/lib/scheduleFilter.test.ts create mode 100644 src/lib/scheduleFilter.ts diff --git a/src/lib/scheduleFilter.test.ts b/src/lib/scheduleFilter.test.ts new file mode 100644 index 00000000..e08939cf --- /dev/null +++ b/src/lib/scheduleFilter.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, it } from "vitest"; +import { filterScheduleDays, type ScheduleFilterCriteria } from "./scheduleFilter"; +import type { ScheduleDay, ScheduleSet } from "@/hooks/useScheduleData"; + +const TIMEZONE = "Europe/Lisbon"; + +function makeSet(overrides: Partial = {}): ScheduleSet { + return { + id: "set-1", + name: "A set", + artists: [], + startTime: new Date("2024-07-15T10:00:00Z"), + ...overrides, + }; +} + +function makeDay(overrides: Partial = {}): ScheduleDay { + return { + date: "2024-07-15", + displayDate: "Monday, Jul 15", + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 1, + sets: [makeSet()], + }, + ], + ...overrides, + }; +} + +function baseCriteria( + overrides: Partial = {}, +): ScheduleFilterCriteria { + return { + day: "all", + time: "all", + stages: [], + ...overrides, + }; +} + +describe("filterScheduleDays", () => { + describe("day predicate", () => { + it("keeps all days when day is 'all'", () => { + const days = [ + makeDay({ date: "2024-07-15" }), + makeDay({ date: "2024-07-16" }), + ]; + + const result = filterScheduleDays(days, baseCriteria(), TIMEZONE); + + expect(result[0].stages).toHaveLength(1); + expect(result[1].stages).toHaveLength(1); + }); + + it("keeps the matching day's stages and empties non-matching days", () => { + const days = [ + makeDay({ date: "2024-07-15" }), + makeDay({ date: "2024-07-16" }), + ]; + + const result = filterScheduleDays( + days, + baseCriteria({ day: "2024-07-15" }), + TIMEZONE, + ); + + expect(result).toHaveLength(2); + expect(result[0].date).toBe("2024-07-15"); + expect(result[0].stages).toHaveLength(1); + expect(result[1].date).toBe("2024-07-16"); + expect(result[1].stages).toEqual([]); + }); + }); + + describe("time-of-day predicate (festival timezone buckets)", () => { + it("keeps sets in the morning bucket (6-12 festival hour)", () => { + // 10:00 UTC is 11:00 in Lisbon (WEST, UTC+1 in July) - morning. + const days = [ + makeDay({ + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 1, + sets: [makeSet({ startTime: new Date("2024-07-15T10:00:00Z") })], + }, + ], + }), + ]; + + const result = filterScheduleDays( + days, + baseCriteria({ time: "morning" }), + TIMEZONE, + ); + + expect(result[0].stages[0].sets).toHaveLength(1); + }); + + it("drops sets outside the requested bucket", () => { + // 22:00 UTC is 23:00 in Lisbon in July - evening, not morning. + const days = [ + makeDay({ + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 1, + sets: [makeSet({ startTime: new Date("2024-07-15T22:00:00Z") })], + }, + ], + }), + ]; + + const result = filterScheduleDays( + days, + baseCriteria({ time: "morning" }), + TIMEZONE, + ); + + expect(result[0].stages[0].sets).toHaveLength(0); + }); + + it("computes buckets in the festival timezone, not UTC", () => { + // 23:30 UTC on Jul 15 is 00:30 on Jul 16 in Lisbon (WEST, UTC+1) - morning-adjacent + // "night" hour 0, which is neither morning, afternoon nor evening. + const days = [ + makeDay({ + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 1, + sets: [makeSet({ startTime: new Date("2024-07-15T23:30:00Z") })], + }, + ], + }), + ]; + + const morning = filterScheduleDays( + days, + baseCriteria({ time: "morning" }), + TIMEZONE, + ); + const evening = filterScheduleDays( + days, + baseCriteria({ time: "evening" }), + TIMEZONE, + ); + + expect(morning[0].stages[0].sets).toHaveLength(0); + expect(evening[0].stages[0].sets).toHaveLength(0); + }); + + it("passes sets without a startTime through unfiltered", () => { + const days = [ + makeDay({ + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 1, + sets: [makeSet({ startTime: undefined })], + }, + ], + }), + ]; + + const result = filterScheduleDays( + days, + baseCriteria({ time: "evening" }), + TIMEZONE, + ); + + expect(result[0].stages[0].sets).toHaveLength(1); + }); + + it("keeps all sets when time is 'all'", () => { + const days = [makeDay()]; + + const result = filterScheduleDays(days, baseCriteria(), TIMEZONE); + + expect(result[0].stages[0].sets).toHaveLength(1); + }); + }); + + describe("stage predicate", () => { + it("keeps all stages when the selection is empty", () => { + const days = [ + makeDay({ + stages: [ + { id: "stage-1", name: "Main Stage", stage_order: 1, sets: [] }, + { id: "stage-2", name: "Second Stage", stage_order: 2, sets: [] }, + ], + }), + ]; + + const result = filterScheduleDays(days, baseCriteria(), TIMEZONE); + + expect(result[0].stages.map((s) => s.id)).toEqual([ + "stage-1", + "stage-2", + ]); + }); + + it("matches stages by id, dropping unselected stages", () => { + const days = [ + makeDay({ + stages: [ + { id: "stage-1", name: "Main Stage", stage_order: 1, sets: [] }, + { id: "stage-2", name: "Second Stage", stage_order: 2, sets: [] }, + ], + }), + ]; + + const result = filterScheduleDays( + days, + baseCriteria({ stages: ["stage-2"] }), + TIMEZONE, + ); + + expect(result[0].stages.map((s) => s.id)).toEqual(["stage-2"]); + }); + }); + + describe("combinations", () => { + it("applies day, time and stage predicates together", () => { + const days = [ + makeDay({ + date: "2024-07-15", + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 1, + sets: [ + makeSet({ + id: "morning-set", + startTime: new Date("2024-07-15T10:00:00Z"), + }), + makeSet({ + id: "evening-set", + startTime: new Date("2024-07-15T22:00:00Z"), + }), + ], + }, + { + id: "stage-2", + name: "Second Stage", + stage_order: 2, + sets: [ + makeSet({ + id: "other-stage-morning-set", + startTime: new Date("2024-07-15T10:00:00Z"), + }), + ], + }, + ], + }), + makeDay({ date: "2024-07-16" }), + ]; + + const result = filterScheduleDays( + days, + baseCriteria({ + day: "2024-07-15", + time: "morning", + stages: ["stage-1"], + }), + TIMEZONE, + ); + + expect(result).toHaveLength(2); + expect(result[0].stages).toHaveLength(1); + expect(result[0].stages[0].id).toBe("stage-1"); + expect(result[0].stages[0].sets.map((s) => s.id)).toEqual([ + "morning-set", + ]); + expect(result[1].stages).toEqual([]); + }); + + it("does not mutate the input", () => { + const days = [makeDay()]; + const snapshot = JSON.parse(JSON.stringify(days)); + + filterScheduleDays( + days, + baseCriteria({ day: "2024-07-16", time: "morning", stages: ["x"] }), + TIMEZONE, + ); + + expect(JSON.parse(JSON.stringify(days))).toEqual(snapshot); + }); + }); +}); diff --git a/src/lib/scheduleFilter.ts b/src/lib/scheduleFilter.ts new file mode 100644 index 00000000..56fbe61c --- /dev/null +++ b/src/lib/scheduleFilter.ts @@ -0,0 +1,73 @@ +import { getFestivalHour } from "@/lib/timeUtils"; +import type { TimelineSearch } from "@/lib/searchSchemas"; +import type { + ScheduleDay, + ScheduleSet, + ScheduleStage, +} from "@/hooks/useScheduleData"; + +export type ScheduleTimeFilter = TimelineSearch["time"]; + +export interface ScheduleFilterCriteria { + // "all" or a festival day key (yyyy-MM-dd, see getFestivalDayKey). + day: string; + // Time-of-day bucket, computed in the festival timezone via getFestivalHour. + time: ScheduleTimeFilter; + // Stage ids to include; an empty array means all stages. + stages: string[]; + // Future (PRD #188 - vote-type filtering): an optional field such as + // `voteTypes?: VoteType[]`, OR-ed against the viewer's own votes on each + // set, will slot in here without reshaping this interface. +} + +function matchesTimeOfDay( + set: ScheduleSet, + time: ScheduleTimeFilter, + timezone: string, +): boolean { + if (time === "all" || !set.startTime) return true; + + const hour = getFestivalHour(set.startTime.toISOString(), timezone); + if (hour === null) return false; + + switch (time) { + case "morning": + return hour >= 6 && hour < 12; + case "afternoon": + return hour >= 12 && hour < 18; + case "evening": + return hour >= 18 && hour < 24; + default: + return true; + } +} + +// Applies the day / time-of-day / stage predicates shared by both Schedule +// views (Timeline and List). A day that doesn't match `criteria.day` is kept +// as an entry with empty stages (rather than dropped), matching the +// Timeline's day-strip layout, which needs every day present even when empty. +export function filterScheduleDays( + scheduleDays: ScheduleDay[], + criteria: ScheduleFilterCriteria, + timezone: string, +): ScheduleDay[] { + return scheduleDays.map((day) => { + if (criteria.day !== "all" && day.date !== criteria.day) { + return { ...day, stages: [] }; + } + + const filteredStages: ScheduleStage[] = day.stages + .filter( + (stage) => + criteria.stages.length === 0 || criteria.stages.includes(stage.id), + ) + .map((stage) => ({ + ...stage, + sets: stage.sets.filter((set) => + matchesTimeOfDay(set, criteria.time, timezone), + ), + })); + + return { ...day, stages: filteredStages }; + }); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx index 5cd0c0f4..56f600b4 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx @@ -8,7 +8,7 @@ import { TimelineContainer } from "./TimelineContainer"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { useSetsByEditionQuery as useEditionSetsQuery } from "@/api/sets/useSetsByEdition"; import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; -import { getFestivalHour } from "@/lib/timeUtils"; +import { filterScheduleDays } from "@/lib/scheduleFilter"; import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; import { useScheduleReveal } from "@/hooks/useScheduleReveal"; import { ScheduleNotRevealedPlaceholder } from "../ScheduleNotRevealedPlaceholder"; @@ -39,49 +39,11 @@ export function Timeline() { return null; } - // Apply filters to scheduleDays - const filteredScheduleDays = scheduleDays.map((day) => { - // Filter by day - if (selectedDay !== "all" && day.date !== selectedDay) { - return { ...day, stages: [] }; // Empty day if not selected - } - - // Filter stages and sets - const filteredStages = day.stages - .filter((stage) => { - // Filter by stage - if (selectedStages.length > 0 && !selectedStages.includes(stage.id)) { - return false; - } - return true; - }) - .map((stage) => ({ - ...stage, - sets: stage.sets.filter((set) => { - // Filter by time - if (selectedTime !== "all" && set.startTime) { - const hour = getFestivalHour( - set.startTime.toISOString(), - festival.timezone, - ); - if (hour === null) return false; - switch (selectedTime) { - case "morning": - return hour >= 6 && hour < 12; - case "afternoon": - return hour >= 12 && hour < 18; - case "evening": - return hour >= 18 && hour < 24; - default: - return true; - } - } - return true; - }), - })); - - return { ...day, stages: filteredStages }; - }); + const filteredScheduleDays = filterScheduleDays( + scheduleDays, + { day: selectedDay, time: selectedTime, stages: selectedStages }, + festival.timezone, + ); return calculateTimelineData( new Date(edition.start_date), diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx index 5667bee9..7b4b1511 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx @@ -4,7 +4,8 @@ import { useRouteContext } from "@tanstack/react-router"; import { useScheduleData } from "@/hooks/useScheduleData"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { useSetsByEditionQuery as useEditionSetsQuery } from "@/api/sets/useSetsByEdition"; -import { getFestivalDayKey, getFestivalHour } from "@/lib/timeUtils"; +import { getFestivalDayKey } from "@/lib/timeUtils"; +import { filterScheduleDays } from "@/lib/scheduleFilter"; import { TimeSlotGroup } from "./TimeSlotGroup"; import type { ScheduleSet } from "@/hooks/useScheduleData"; import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; @@ -40,64 +41,26 @@ export function ListSchedule() { const timeSlots = useMemo(() => { if (!scheduleDays.length) return []; - // Helper function to check if a set matches the day filter - function matchesDay(set: ScheduleSet) { - if (selectedDay === "all") return true; - if (!set.startTime) return false; - - const setDate = getFestivalDayKey( - set.startTime.toISOString(), - festival.timezone, - ); - return setDate === selectedDay; - } - - // Helper function to check if a set matches the time filter - function matchesTime(set: ScheduleSet) { - if (selectedTime === "all") return true; - if (!set.startTime) return false; - - const hour = getFestivalHour( - set.startTime.toISOString(), - festival.timezone, - ); - if (hour === null) return false; - switch (selectedTime) { - case "morning": - return hour >= 6 && hour < 12; - case "afternoon": - return hour >= 12 && hour < 18; - case "evening": - return hour >= 18 && hour < 24; - default: - return true; - } - } - - // Helper function to check if a set matches the stage filter - function matchesStage(stageName: string) { - if (selectedStages.length === 0) return true; - return selectedStages.includes(stageName); - } + const filteredScheduleDays = filterScheduleDays( + scheduleDays, + { day: selectedDay, time: selectedTime, stages: selectedStages }, + festival.timezone, + ); - // Collect all unique start times with filtering + // Flatten filtered days/stages into a single list, enriching each set + // with the stage name/color the group view needs. Sets without a + // startTime can't be placed into a time slot, so they're dropped here. const allSets: (ScheduleSet & { stageName: string; stageColor?: string; })[] = []; - scheduleDays.forEach((day) => { + filteredScheduleDays.forEach((day) => { day.stages.forEach((stage) => { - if (!matchesStage(stage.id)) { - console.log("Skipping stage:", stage.name, selectedStages); - return; - } - - // Find the stage data to get color information const stageData = stages.find((s) => s.id === stage.id); stage.sets.forEach((set) => { - if (set.startTime && matchesDay(set) && matchesTime(set)) { + if (set.startTime) { allSets.push({ ...set, stageName: stage.name, From 976f8a15b9a0dc349a9f4a4ca664b5dbb883dadb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:56:29 +0000 Subject: [PATCH 2/3] feat(schedule): shared filter bottom sheet for both views Move day/time/stage filters into a single ScheduleFilterSheet (shadcn bottom sheet) rendered by both Timeline and List views, replacing the List view's old inline expanding panel. The Filters trigger joins the Timeline toolbar row inline, with an active-filter count badge. Filter state stays shared URL state via useTimelineUrlState, applied through filterScheduleDays on both views; opening/applying/clearing filters never scrolls the viewport. Closes #195 --- .../tabs/ScheduleTab/DayFilterSelect.tsx | 5 +- .../tabs/ScheduleTab/ScheduleFilterSheet.tsx | 132 ++++++++++++++++++ .../tabs/ScheduleTab/TimeFilterSelect.tsx | 5 +- .../horizontal/TimelineToolbar.tsx | 33 +++-- .../tabs/ScheduleTab/list/ListFilters.tsx | 63 --------- .../tabs/ScheduleTab/list/ListSchedule.tsx | 7 +- .../tabs/ScheduleTab/list/ListTab.tsx | 11 +- tests/e2e/schedule-filter-sheet.spec.ts | 120 ++++++++++++++++ tests/e2e/timeline-day-toolbar.spec.ts | 17 ++- 9 files changed, 305 insertions(+), 88 deletions(-) create mode 100644 src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx delete mode 100644 src/pages/EditionView/tabs/ScheduleTab/list/ListFilters.tsx create mode 100644 tests/e2e/schedule-filter-sheet.spec.ts diff --git a/src/pages/EditionView/tabs/ScheduleTab/DayFilterSelect.tsx b/src/pages/EditionView/tabs/ScheduleTab/DayFilterSelect.tsx index 91a72aa9..38e7100a 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/DayFilterSelect.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/DayFilterSelect.tsx @@ -49,7 +49,10 @@ export function DayFilterSelect({ - + diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx index b33b95f5..d08f4042 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx @@ -1,4 +1,5 @@ import { DayJumpButtons } from "./DayJumpButtons"; +import { ScheduleFilterSheet } from "../ScheduleFilterSheet"; import type { ScheduleDay } from "@/hooks/useScheduleData"; interface TimelineToolbarProps { @@ -9,9 +10,14 @@ interface TimelineToolbarProps { } /** - * Slim sticky toolbar above the Timeline strip. Only hosts day-jump buttons - * for now; the Now pill, "Show overview" toggle, and Filters trigger - * (upcoming stacked tickets) will render alongside them here. + * Sticky toolbar above the Timeline strip. Hosts day-jump buttons and the + * Filters trigger (bottom sheet) inline in the same row - the Filters + * trigger never renders on its own line. The Now pill and "Show overview" + * toggle (upcoming stacked tickets) will join them here too. + * + * Day-jump buttons live inside their own `timeline-day-buttons` group so + * tests (and future additions to this row) can target them without also + * picking up the Filters trigger or other buttons that share the toolbar. * * Navigation only ever scrolls - it never filters the strip. When a `day` * filter is active, nav operates on what's rendered, so only that day's @@ -28,18 +34,23 @@ export function TimelineToolbar({ ? days : days.filter((day) => day.date === selectedDay); - if (visibleDays.length === 0) return null; - return (
- + {visibleDays.length > 0 && ( +
+ +
+ )} +
+ +
); } diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/ListFilters.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/ListFilters.tsx deleted file mode 100644 index 17b106de..00000000 --- a/src/pages/EditionView/tabs/ScheduleTab/list/ListFilters.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useState } from "react"; -import { DayFilterSelect } from "../DayFilterSelect"; -import { TimeFilterSelect } from "../TimeFilterSelect"; -import { StageFilterButtons } from "../StageFilterButtons"; -import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; -import { FilterToggle } from "@/components/filters/FilterToggle"; -import { FilterContainer } from "@/components/filters/FilterContainer"; - -export function ListFilters() { - const [isExpanded, setIsExpanded] = useState(false); - const { - day, - time, - stages, - updateDay, - updateTime, - updateStages, - clearFilters, - } = useTimelineUrlState("list"); - - function handleStageToggle(stageId: string) { - const newStages = stages.includes(stageId) - ? stages.filter((id) => id !== stageId) - : [...stages, stageId]; - updateStages(newStages); - } - - const activeFilterCount = - (day !== "all" ? 1 : 0) + (time !== "all" ? 1 : 0) + stages.length; - const hasActiveFilters = activeFilterCount > 0; - const shouldShowFilters = isExpanded; - - return ( - -
-

Filters

-
- - setIsExpanded(!isExpanded)} - hasActiveFilters={hasActiveFilters} - activeFilterCount={activeFilterCount} - label="Filters" - onClearFilters={hasActiveFilters ? clearFilters : undefined} - /> -
- - {shouldShowFilters && ( -
-
- - - -
-
- )} - - ); -} diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx index 7b4b1511..78bba4bf 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx @@ -134,16 +134,13 @@ export function ListSchedule() { } return ( -
+
{timeSlots.map((slot, index) => { const prevSlot = index > 0 ? timeSlots[index - 1] : null; const showDateHeader = !prevSlot || getFestivalDayKey(slot.time.toISOString(), festival.timezone) !== - getFestivalDayKey( - prevSlot.time.toISOString(), - festival.timezone, - ); + getFestivalDayKey(prevSlot.time.toISOString(), festival.timezone); return ( - + +
+

Filters

+
+ +
+ ); diff --git a/tests/e2e/schedule-filter-sheet.spec.ts b/tests/e2e/schedule-filter-sheet.spec.ts new file mode 100644 index 00000000..8915393b --- /dev/null +++ b/tests/e2e/schedule-filter-sheet.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from "@playwright/test"; + +// Seeded in supabase/seed.sql: festival slug "test", edition slug "2025", +// three festival days (Jul 12-14, 2025), stages "Main Stage" and "Club Stage". +const TIMELINE_PATH = "/festivals/test/editions/2025/schedule/timeline"; +const LIST_PATH = "/festivals/test/editions/2025/schedule/list"; +const MAIN_STAGE_ID = "11111111-1111-1111-1111-11111111111a"; + +async function openSheet(page: import("@playwright/test").Page) { + await page.getByTestId("schedule-filters-trigger").click(); + await expect(page.getByTestId("schedule-filter-sheet")).toBeVisible(); +} + +test.describe("Schedule filter sheet", () => { + test("opens from the Timeline toolbar with title and description", async ({ + page, + }) => { + 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"); + } + + // The Filters trigger sits inline in the toolbar row, alongside the + // day-jump buttons - never alone on its own line. + const toolbar = page.getByTestId("timeline-day-toolbar"); + const trigger = toolbar.getByTestId("schedule-filters-trigger"); + await expect(trigger).toBeVisible(); + + await trigger.click(); + + const sheet = page.getByTestId("schedule-filter-sheet"); + await expect(sheet).toBeVisible(); + await expect(sheet.getByText("Filter schedule")).toBeVisible(); + await expect( + sheet.getByText("Narrow the schedule by day, time of day, and stage."), + ).toBeVisible(); + }); + + test("opens from the List view's filter row into the same sheet", async ({ + page, + }) => { + await page.goto(LIST_PATH); + + const listSchedule = page.getByTestId("list-schedule"); + if (!(await listSchedule.isVisible().catch(() => false))) { + test.skip(true, "Schedule not revealed in this environment"); + } + + await openSheet(page); + await expect( + page.getByTestId("schedule-filter-sheet").getByText("Filter schedule"), + ).toBeVisible(); + }); + + test("selecting a day filter updates the badge, both views, and never scrolls the Timeline", async ({ + page, + }) => { + 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("schedule-filters-badge")).toHaveCount(0); + + const scrollLeftBefore = await scrollContainer.evaluate( + (el) => el.scrollLeft, + ); + + await openSheet(page); + await page.getByTestId("day-filter-trigger").click(); + await page.getByRole("option", { name: /^Saturday$/ }).click(); + await page.getByTestId("schedule-filter-sheet").getByText("Done").click(); + + await expect(page).toHaveURL(/day=2025-07-12/); + await expect(page.getByTestId("schedule-filters-badge")).toHaveText("1"); + + // Day filter collapses the Timeline strip to a single day, but applying + // it must never scroll the viewport. + const toolbar = page.getByTestId("timeline-day-toolbar"); + await expect( + toolbar.getByTestId("timeline-day-buttons").getByRole("button"), + ).toHaveCount(1); + + const scrollLeftAfter = await scrollContainer.evaluate( + (el) => el.scrollLeft, + ); + expect(scrollLeftAfter).toBe(scrollLeftBefore); + + // Shared URL state: the List view sees and can clear the same filter. + await page.goto(`${LIST_PATH}?day=2025-07-12`); + await expect(page.getByTestId("schedule-filters-badge")).toHaveText("1"); + }); + + test("clearing filters from the sheet restores both views and removes the badge", async ({ + page, + }) => { + await page.goto(`${TIMELINE_PATH}?day=2025-07-12&stages=${MAIN_STAGE_ID}`); + + 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("schedule-filters-badge")).toHaveText("2"); + + await openSheet(page); + await page.getByTestId("schedule-filters-clear").click(); + + await expect(page.getByTestId("schedule-filters-badge")).toHaveCount(0); + await expect(page).not.toHaveURL(/day=/); + await expect(page).not.toHaveURL(/stages=/); + + await page.goto(LIST_PATH); + await expect(page.getByTestId("schedule-filters-badge")).toHaveCount(0); + }); +}); diff --git a/tests/e2e/timeline-day-toolbar.spec.ts b/tests/e2e/timeline-day-toolbar.spec.ts index fe5e9fc7..95edeb22 100644 --- a/tests/e2e/timeline-day-toolbar.spec.ts +++ b/tests/e2e/timeline-day-toolbar.spec.ts @@ -19,7 +19,9 @@ test.describe("Timeline day-jump toolbar", () => { const toolbar = page.getByTestId("timeline-day-toolbar"); await expect(toolbar).toBeVisible(); - const dayButtons = toolbar.getByRole("button"); + const dayButtons = page + .getByTestId("timeline-day-buttons") + .getByRole("button"); const count = await dayButtons.count(); expect(count).toBeGreaterThanOrEqual(3); @@ -46,7 +48,9 @@ test.describe("Timeline day-jump toolbar", () => { (el) => el.scrollLeft, ); - const dayButtons = page.getByTestId("timeline-day-toolbar").getByRole("button"); + const dayButtons = page + .getByTestId("timeline-day-buttons") + .getByRole("button"); // Jump to the last day, which should be far from the initial viewport. await dayButtons.last().click(); @@ -73,8 +77,9 @@ test.describe("Timeline day-jump toolbar", () => { test.skip(true, "Schedule not revealed in this environment"); } - const toolbar = page.getByTestId("timeline-day-toolbar"); - const allDaysButtons = toolbar.getByRole("button"); + const allDaysButtons = page + .getByTestId("timeline-day-buttons") + .getByRole("button"); const totalDays = await allDaysButtons.count(); expect(totalDays).toBeGreaterThanOrEqual(2); @@ -84,7 +89,9 @@ test.describe("Timeline day-jump toolbar", () => { const filteredToolbar = page.getByTestId("timeline-day-toolbar"); await expect(filteredToolbar).toBeVisible(); - const filteredButtons = filteredToolbar.getByRole("button"); + const filteredButtons = page + .getByTestId("timeline-day-buttons") + .getByRole("button"); await expect(filteredButtons).toHaveCount(1); expect((await filteredButtons.first().textContent())?.trim()).toBe( firstDayLabel, From 6a113f9eb632dd102afd6a863cc42621d098ed1c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:58:51 +0000 Subject: [PATCH 3/3] test(timeline): scope first-day jump spec to day buttons Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- tests/e2e/timeline-day-toolbar.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/timeline-day-toolbar.spec.ts b/tests/e2e/timeline-day-toolbar.spec.ts index 60e3b899..2cc8d577 100644 --- a/tests/e2e/timeline-day-toolbar.spec.ts +++ b/tests/e2e/timeline-day-toolbar.spec.ts @@ -83,7 +83,7 @@ test.describe("Timeline day-jump toolbar", () => { } const dayButtons = page - .getByTestId("timeline-day-toolbar") + .getByTestId("timeline-day-buttons") .getByRole("button"); // Move away first so the jump back is observable. await dayButtons.last().click();