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 4f996954..13463368 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 { @@ -21,18 +22,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 bb693315..2cc8d577 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(); @@ -79,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(); @@ -105,8 +109,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); @@ -116,7 +121,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,