From 12c019fa7463e2aee5497ce667aeb9cf64c9dfd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 05:42:54 +0000 Subject: [PATCH] fix(schedule): keep empty day groups visible in list view when filters match nothing Set-level filters (type, vote, time) previously made a day's group vanish entirely once its sets were filtered out, which unmounted the filter sheet mid-interaction if it was opened from that day's header and made "no matches today" indistinguishable from "this day doesn't exist". Build one group per day from filterScheduleDays' output (which already keeps non-matching days with empty stages) and show an empty-state line instead of dropping the header. The day picker still narrows to the selected day. Closes UPL-64 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011bftBYh39JGWmEMUSJ2xdY --- .../tabs/ScheduleTab/list/ListDayGroup.tsx | 24 ++++-- .../editions/$editionSlug/schedule/list.tsx | 84 ++++++++----------- tests/e2e/schedule-type-filter.spec.ts | 11 ++- 3 files changed, 59 insertions(+), 60 deletions(-) diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/ListDayGroup.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/ListDayGroup.tsx index 08332a4b..8e6235fa 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/list/ListDayGroup.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/list/ListDayGroup.tsx @@ -44,15 +44,21 @@ export function ListDayGroup({ dayKey, slots, timezone }: ListDayGroupProps) { -
- {slots.map((slot) => ( - - ))} -
+ {slots.length === 0 ? ( +

+ No sets match your filters. +

+ ) : ( +
+ {slots.map((slot) => ( + + ))} +
+ )} ); } diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/list.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/list.tsx index 423cc53d..5bb85e22 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/list.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/list.tsx @@ -3,7 +3,6 @@ import { useMemo } from "react"; import { useSuspenseQuery } from "@tanstack/react-query"; import { useScheduleData } from "@/hooks/useScheduleData"; import { useSetsByEditionQuery as useEditionSetsQuery } from "@/api/sets/useSetsByEdition"; -import { getFestivalDayKey } from "@/lib/timeUtils"; import { filterScheduleDays } from "@/lib/scheduleFilter"; import { ListDayGroup } from "@/pages/EditionView/tabs/ScheduleTab/list/ListDayGroup"; import { ScheduleFilterSheet } from "@/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet"; @@ -81,21 +80,30 @@ function ListSchedule() { festival.timezone, ); - // 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 | undefined; - })[] = []; + // The day filter narrows to the days the user picked; set-level filters + // (type/vote/time/stage) never drop a day, they just empty its stages + // (see filterScheduleDays' contract) so its header stays visible with an + // empty state instead of disappearing. + const visibleDays = + selectedDay === "all" + ? filteredScheduleDays + : filteredScheduleDays.filter((day) => day.date === selectedDay); + + return visibleDays.map((day): DayGroup => { + // Enrich 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 daySets: (ScheduleSet & { + stageName: string; + stageColor?: string | undefined; + })[] = []; - filteredScheduleDays.forEach((day) => { day.stages.forEach((stage) => { const stageData = stages.find((s) => s.id === stage.id); stage.sets.forEach((set) => { if (set.startTime) { - allSets.push({ + daySets.push({ ...set, stageName: stage.name, stageColor: stageData?.color || undefined, @@ -103,48 +111,30 @@ function ListSchedule() { } }); }); - }); - - // Group sets by start time - const timeGroups = new Map< - string, - (ScheduleSet & { stageName: string; stageColor?: string | undefined })[] - >(); - allSets.forEach((set) => { - if (!set.startTime) return; + // Group sets by start time + const timeGroups = new Map(); - const timeKey = set.startTime.toISOString(); - if (!timeGroups.has(timeKey)) { - timeGroups.set(timeKey, []); - } - timeGroups.get(timeKey)!.push(set); - }); + daySets.forEach((set) => { + if (!set.startTime) return; - // Convert to sorted array - const slots: TimeSlot[] = Array.from(timeGroups.entries()) - .map(([timeKey, sets]) => ({ - time: new Date(timeKey), - sets: sets, - })) - .sort((a, b) => a.time.getTime() - b.time.getTime()); - - const groups = new Map(); - slots.forEach((slot) => { - const dayKey = getFestivalDayKey( - slot.time.toISOString(), - festival.timezone, - ); - if (!dayKey) return; - if (!groups.has(dayKey)) groups.set(dayKey, []); - groups.get(dayKey)!.push(slot); - }); + const timeKey = set.startTime.toISOString(); + if (!timeGroups.has(timeKey)) { + timeGroups.set(timeKey, []); + } + timeGroups.get(timeKey)!.push(set); + }); - const sortedDayGroups: DayGroup[] = Array.from(groups.entries()) - .map(([dayKey, daySlots]) => ({ dayKey, slots: daySlots })) - .sort((a, b) => a.dayKey.localeCompare(b.dayKey)); + // Convert to sorted array + const slots: TimeSlot[] = Array.from(timeGroups.entries()) + .map(([timeKey, sets]) => ({ + time: new Date(timeKey), + sets: sets, + })) + .sort((a, b) => a.time.getTime() - b.time.getTime()); - return sortedDayGroups; + return { dayKey: day.date, slots }; + }); }, [ scheduleDays, selectedDay, diff --git a/tests/e2e/schedule-type-filter.spec.ts b/tests/e2e/schedule-type-filter.spec.ts index 5a309c6b..ff1800a8 100644 --- a/tests/e2e/schedule-type-filter.spec.ts +++ b/tests/e2e/schedule-type-filter.spec.ts @@ -21,10 +21,10 @@ test.describe("Schedule set-type filter", { tag: "@smoke" }, () => { await expect(listSchedule(page)).toBeVisible(); await expect(page.getByText(MUSIC_SET_NAME).first()).toBeVisible(); - // Open the sheet from the workshop's own day group (Jul 13): filtering - // unmounts day groups left without sets, and the sheet unmounts with its - // host header. - const dayGroup = listSchedule(page).getByRole("region", { name: /Jul 13/ }); + // Open the sheet from a day group the workshop filter empties (Jul 12): + // emptied day groups stay mounted with an empty state, so the sheet that + // opened it must stay open too. + const dayGroup = listSchedule(page).getByRole("region", { name: /Jul 12/ }); await dayGroup.getByRole("button", { name: /Filters/ }).click(); const sheet = page.getByRole("dialog"); await expect(sheet).toBeVisible(); @@ -39,6 +39,9 @@ test.describe("Schedule set-type filter", { tag: "@smoke" }, () => { await expect( dayGroup.getByRole("button", { name: "Filters (1 active)" }), ).toBeVisible(); + await expect( + dayGroup.getByText("No sets match your filters."), + ).toBeVisible(); }); test("a shared ?types= link filters the timeline view too", async ({