{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 (
-
+
+
+
>
);
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,