diff --git a/src/lib/scheduleFilter.test.ts b/src/lib/scheduleFilter.test.ts new file mode 100644 index 00000000..5d0571c9 --- /dev/null +++ b/src/lib/scheduleFilter.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it } from "vitest"; +import { filterScheduleDays, type ScheduleFilterCriteria } from "./scheduleFilter"; +import type { ScheduleDay, ScheduleSet } from "@/hooks/useScheduleData"; + +const TIMEZONE = "Europe/Lisbon"; + +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.map((day) => day.date)).toEqual([ + "2024-07-15", + "2024-07-16", + ]); + 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); + }); + }); +}); + +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 new file mode 100644 index 00000000..d610c3a6 --- /dev/null +++ b/src/lib/scheduleFilter.ts @@ -0,0 +1,75 @@ +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) 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, + 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,