From 948957dec7cd93ef8c7329f5651ae9252890ec3b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:56:29 +0000 Subject: [PATCH 1/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 | 6 +- .../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 | 12 +- 9 files changed, 280 insertions(+), 81 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 a5bd3d95..99fba8c5 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx @@ -3,6 +3,7 @@ import { Map } from "lucide-react"; import { DayJumpButtons } from "./DayJumpButtons"; import { NowButton } from "./NowButton"; import { Button } from "@/components/ui/button"; +import { ScheduleFilterSheet } from "../ScheduleFilterSheet"; import type { ScheduleDay } from "@/hooks/useScheduleData"; import { useScrollEdgeFade } from "./useScrollEdgeFade"; @@ -22,7 +23,9 @@ interface TimelineToolbarProps { 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. +// filters; with a day filter active only that day's button shows. Hosts the +// Filters trigger (bottom sheet) inline in the same row - it never renders +// on its own line. export function TimelineToolbar({ days, selectedDay, @@ -89,6 +92,7 @@ export function TimelineToolbar({ {isOverviewExpanded ? "Hide overview" : "Show overview"} + ); 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 67e7f896..fb5668d3 100644 --- a/tests/e2e/timeline-day-toolbar.spec.ts +++ b/tests/e2e/timeline-day-toolbar.spec.ts @@ -17,8 +17,7 @@ test.describe("Timeline day-jump toolbar", () => { const toolbar = page.getByRole("radiogroup", { name: "Jump to day" }); await expect(toolbar).toBeVisible(); - const dayButtons = toolbar.getByRole("radio"); - const count = await dayButtons.count(); + const dayButtons = toolbar.getByRole("radio"); const count = await dayButtons.count(); expect(count).toBeGreaterThanOrEqual(3); for (let i = 0; i < count; i++) { @@ -45,8 +44,7 @@ test.describe("Timeline day-jump toolbar", () => { const dayButtons = page .getByRole("radiogroup", { name: "Jump to day" }) - .getByRole("radio"); - // Jump to the last day, which should be far from the initial viewport. + .getByRole("radio"); // Jump to the last day, which should be far from the initial viewport. await dayButtons.last().click(); await page.waitForTimeout(SCROLL_ANIMATION_WAIT_MS); @@ -108,8 +106,7 @@ test.describe("Timeline day-jump toolbar", () => { await expect(scrollContainer).toBeVisible({ timeout: 15000 }); const toolbar = page.getByRole("radiogroup", { name: "Jump to day" }); - const allDaysButtons = toolbar.getByRole("radio"); - const totalDays = await allDaysButtons.count(); + const allDaysButtons = toolbar.getByRole("radio"); const totalDays = await allDaysButtons.count(); expect(totalDays).toBeGreaterThanOrEqual(2); const firstDayLabel = (await allDaysButtons.first().textContent())?.trim(); @@ -120,8 +117,7 @@ test.describe("Timeline day-jump toolbar", () => { }); await expect(filteredToolbar).toBeVisible(); - const filteredButtons = filteredToolbar.getByRole("radio"); - await expect(filteredButtons).toHaveCount(1); + const filteredButtons = filteredToolbar.getByRole("radio"); await expect(filteredButtons).toHaveCount(1); expect((await filteredButtons.first().textContent())?.trim()).toBe( firstDayLabel, ); From 378248c8c8f4c88a9a27002a9bb4b59754152f81 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 20:42:26 +0000 Subject: [PATCH 2/3] fix(schedule): dark-theme the filter sheet trigger, close icon, and stage chips Ghost/outline buttons here fell back to shadcn's unthemed light-mode accent/background/foreground tokens (the app never toggles a `dark` class), so the Filters trigger flashed a pale pill on hover, the sheet's close icon rendered near-invisible dark-on-dark, and unselected stage chips showed as solid white pills against the dark toolbar. --- src/components/ui/sheet.tsx | 2 +- src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx | 2 +- src/pages/EditionView/tabs/ScheduleTab/StageFilterButtons.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index c13f278b..2330f6cb 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -63,7 +63,7 @@ const SheetContent = React.forwardRef< {...props} > {children} - + Close diff --git a/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx b/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx index 1e63c093..6a51da3d 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx @@ -64,7 +64,7 @@ export function ScheduleFilterSheet({ tab }: ScheduleFilterSheetProps) { className={ hasActiveFilters ? "flex items-center gap-2 bg-purple-600/50 text-purple-100 hover:bg-purple-600/60" - : "flex items-center gap-2 text-purple-300 hover:text-purple-100" + : "flex items-center gap-2 text-purple-300 hover:bg-purple-400/10 hover:text-purple-100" } > diff --git a/src/pages/EditionView/tabs/ScheduleTab/StageFilterButtons.tsx b/src/pages/EditionView/tabs/ScheduleTab/StageFilterButtons.tsx index df54edbf..8bead693 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/StageFilterButtons.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/StageFilterButtons.tsx @@ -34,7 +34,7 @@ export function StageFilterButtons({ className={ selectedStages.includes(stage.id) ? "bg-purple-600 hover:bg-purple-700 text-xs" - : "border-purple-400 text-purple-400 hover:bg-purple-400 hover:text-white text-xs" + : "bg-white/5 border-purple-400/40 text-purple-300 hover:border-purple-300 hover:bg-purple-400/20 hover:text-purple-100 text-xs" } > {stage.name} From 963f36837d0f21c82562738042b323b40b85eb11 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 06:06:09 +0000 Subject: [PATCH 3/3] refactor(schedule): trim filter-sheet comment, revert stray toolbar-test diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: drop the unnecessary docblock on ScheduleFilterSheet, and revert timeline-day-toolbar.spec.ts to main's version — the only diff there was whitespace noise from an earlier conflict resolution, not an intentional change. --- .../tabs/ScheduleTab/ScheduleFilterSheet.tsx | 16 ---------------- tests/e2e/timeline-day-toolbar.spec.ts | 12 ++++++++---- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx b/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx index 6a51da3d..490df100 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx @@ -21,22 +21,6 @@ interface ScheduleFilterSheetProps { tab: "timeline" | "list"; } -/** - * Shared day / time-of-day / stage filter UI for both Schedule views - * (Timeline and List). Renders as a trigger button (with an active-filter - * count badge) plus a bottom sheet - the same component instance handles - * both the trigger and the sheet content so each view only has to render - * one thing in its filter row. - * - * Filter state is the shared URL state from `useTimelineUrlState`, so - * setting a filter on one view keeps it visible (and clearable) on the - * other. Opening, applying, or clearing filters here never scrolls either - * view - only day-jump navigation does that. - * - * Active-filter count: `day` and `time` each count 1 when away from "all", - * and every selected stage counts 1 of its own (so picking two stages adds - * 2). Vote-type chips will fold into this same count in a later ticket. - */ export function ScheduleFilterSheet({ tab }: ScheduleFilterSheetProps) { const [open, setOpen] = useState(false); const { diff --git a/tests/e2e/timeline-day-toolbar.spec.ts b/tests/e2e/timeline-day-toolbar.spec.ts index fb5668d3..67e7f896 100644 --- a/tests/e2e/timeline-day-toolbar.spec.ts +++ b/tests/e2e/timeline-day-toolbar.spec.ts @@ -17,7 +17,8 @@ test.describe("Timeline day-jump toolbar", () => { const toolbar = page.getByRole("radiogroup", { name: "Jump to day" }); await expect(toolbar).toBeVisible(); - const dayButtons = toolbar.getByRole("radio"); const count = await dayButtons.count(); + const dayButtons = toolbar.getByRole("radio"); + const count = await dayButtons.count(); expect(count).toBeGreaterThanOrEqual(3); for (let i = 0; i < count; i++) { @@ -44,7 +45,8 @@ test.describe("Timeline day-jump toolbar", () => { const dayButtons = page .getByRole("radiogroup", { name: "Jump to day" }) - .getByRole("radio"); // Jump to the last day, which should be far from the initial viewport. + .getByRole("radio"); + // Jump to the last day, which should be far from the initial viewport. await dayButtons.last().click(); await page.waitForTimeout(SCROLL_ANIMATION_WAIT_MS); @@ -106,7 +108,8 @@ test.describe("Timeline day-jump toolbar", () => { await expect(scrollContainer).toBeVisible({ timeout: 15000 }); const toolbar = page.getByRole("radiogroup", { name: "Jump to day" }); - const allDaysButtons = toolbar.getByRole("radio"); const totalDays = await allDaysButtons.count(); + const allDaysButtons = toolbar.getByRole("radio"); + const totalDays = await allDaysButtons.count(); expect(totalDays).toBeGreaterThanOrEqual(2); const firstDayLabel = (await allDaysButtons.first().textContent())?.trim(); @@ -117,7 +120,8 @@ test.describe("Timeline day-jump toolbar", () => { }); await expect(filteredToolbar).toBeVisible(); - const filteredButtons = filteredToolbar.getByRole("radio"); await expect(filteredButtons).toHaveCount(1); + const filteredButtons = filteredToolbar.getByRole("radio"); + await expect(filteredButtons).toHaveCount(1); expect((await filteredButtons.first().textContent())?.trim()).toBe( firstDayLabel, );