From 26140926e85e57b31d97059e62cd55f5742d22e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:19:25 +0000 Subject: [PATCH 1/2] 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 50f12eeb59f92d52d9d6226b5055386ff711c9b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 18:15:29 +0000 Subject: [PATCH 2/2] test(schedule): address review notes on filter tests Helpers move below the test cases, the all-days test asserts dates, and the empty-day comment now states the real reason (mirroring the original inline logic; dropping would render identically). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/lib/scheduleFilter.test.ts | 78 ++++++++++++++++++---------------- src/lib/scheduleFilter.ts | 6 ++- 2 files changed, 45 insertions(+), 39 deletions(-) diff --git a/src/lib/scheduleFilter.test.ts b/src/lib/scheduleFilter.test.ts index e08939cf..5d0571c9 100644 --- a/src/lib/scheduleFilter.test.ts +++ b/src/lib/scheduleFilter.test.ts @@ -4,43 +4,6 @@ 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'", () => { @@ -51,6 +14,10 @@ describe("filterScheduleDays", () => { const result = filterScheduleDays(days, baseCriteria(), TIMEZONE); + expect(result.map((day) => day.date)).toEqual([ + "2024-07-15", + "2024-07-16", + ]); expect(result[0].stages).toHaveLength(1); expect(result[1].stages).toHaveLength(1); }); @@ -296,3 +263,40 @@ describe("filterScheduleDays", () => { }); }); }); + +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, + }; +} diff --git a/src/lib/scheduleFilter.ts b/src/lib/scheduleFilter.ts index 56fbe61c..d610c3a6 100644 --- a/src/lib/scheduleFilter.ts +++ b/src/lib/scheduleFilter.ts @@ -44,8 +44,10 @@ function matchesTimeOfDay( // 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. +// as an entry with empty stages (rather than dropped) purely to mirror the +// original inline Timeline logic; the strip's axis bounds derive from the +// remaining sets' times, so an empty day entry contributes nothing and +// dropping it would render identically. export function filterScheduleDays( scheduleDays: ScheduleDay[], criteria: ScheduleFilterCriteria,