From 7df0a646bc60b0c39016c54a95f856ba33b53d6d Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Mon, 31 Aug 2026 23:25:11 +0200 Subject: [PATCH] web: extract event filter logic to lib, add tz/DST and year-boundary tests --- web/src/components/events/EventContainer.tsx | 96 ++-------- web/src/lib/date.test.ts | 45 +++++ web/src/lib/event.test.ts | 54 ++++++ web/src/lib/eventFilter.test.ts | 178 +++++++++++++++++++ web/src/lib/eventFilter.ts | 124 +++++++++++++ 5 files changed, 414 insertions(+), 83 deletions(-) create mode 100644 web/src/lib/eventFilter.test.ts create mode 100644 web/src/lib/eventFilter.ts diff --git a/web/src/components/events/EventContainer.tsx b/web/src/components/events/EventContainer.tsx index c466624..ba323d9 100644 --- a/web/src/components/events/EventContainer.tsx +++ b/web/src/components/events/EventContainer.tsx @@ -9,10 +9,7 @@ import { } from "nuqs"; import { EventItem } from "./EventItem"; import { EventFilter, EventFilterExplained } from "./EventFilter"; -import { unmaskFragment } from "@/gql"; import { - EventCategoryFragmentDefinition, - EventOrganizerFragmentDefinition, EventOverviewItemFragment, EventCategory, SingularEvent, @@ -20,6 +17,12 @@ import { organizeEventsInCalendar, EventOrganizer, } from "@/lib/event"; +import { + filterEvents, + getFilterableCategories, + getFilterableOrganizers, + getFilterableVenues, +} from "@/lib/eventFilter"; import { isTodayOrFuture } from "@/lib/date"; import styles from "./eventContainer.module.scss"; import { formatDate, formatYearMonth } from "@/lib/date"; @@ -29,7 +32,6 @@ import { differenceInCalendarDays, parse, } from "date-fns"; -import { unique } from "@/lib/common"; import { Icon } from "../general/Icon"; import { useState } from "react"; import { VenueFragment } from "@/gql/graphql"; @@ -70,40 +72,13 @@ export const EventContainer = ({ resetFilters(); }; - /* Allow filtering on all categories that are configured to be shown */ - const filterableCategories = eventCategories.filter((x) => x.showInFilters); - - /* - Allow filtering on all organizers that have upcoming events - Filtering on an organizer with no upcoming events will work, - and in that case it's included in the dropdown - */ - const allOrganizers = unmaskFragment( - EventOrganizerFragmentDefinition, - events.flatMap((x) => x.organizers), + const filterableCategories = getFilterableCategories(eventCategories); + const filterableOrganizers = getFilterableOrganizers( + events, + eventOrganizers, + organizer, ); - const uniqueOrganizers: string[] = unique( - allOrganizers - .filter((x) => x.__typename === "EventOrganizer") - .map((x) => x.slug) - .filter((x) => typeof x === "string" && x !== ""), - ); - const filterableOrganizers = uniqueOrganizers - .map((slug) => eventOrganizers.find((haystack) => haystack.slug === slug)) - .filter((x) => x !== undefined) as EventOrganizer[]; - - // If an organizer is selected that's not in the upcoming list, add it - if ( - organizer && - !filterableOrganizers.some((org) => org.slug === organizer) - ) { - const selectedOrganizer = eventOrganizers.find( - (org) => org.slug === organizer, - ); - if (selectedOrganizer) { - filterableOrganizers.push(selectedOrganizer); - } - } + const filterableVenues = getFilterableVenues(events, venues, venue); /* Reset organizer if slug is invalid */ useEffect(() => { @@ -112,26 +87,6 @@ export const EventContainer = ({ } }, [eventOrganizers, organizer]); - /* - Allow filtering on all venues that have upcoming events - Filtering on a venue with no upcoming events will work, - and in that case it's included in the dropdown - */ - const venueSlugsWithUpcomingEvents = unique( - events - .map((x) => x.occurrences) - .flat() - .filter((x) => x.venue?.__typename === "VenuePage") - .map((x) => x.venue?.slug) - .filter((x) => typeof x === "string"), - ); - const filterableVenues = venues - .filter( - (x) => venueSlugsWithUpcomingEvents.includes(x.slug) || x.slug === venue, - ) - .map((x) => venues.find((haystack) => haystack.slug === x.slug)) - .filter((x) => x !== undefined) as VenueFragment[]; - /* Reset venue if slug is invalid */ useEffect(() => { if (!venues.find((haystack) => haystack.slug === venue)) { @@ -139,32 +94,7 @@ export const EventContainer = ({ } }, [venues, venue]); - const filteredEvents = events.filter((event) => { - if (organizer) { - const organizers = unmaskFragment( - EventOrganizerFragmentDefinition, - event.organizers, - ); - if (!organizers.some((o) => o.slug === organizer)) { - return false; - } - } - if (categories) { - const eventCategories = unmaskFragment( - EventCategoryFragmentDefinition, - event.categories, - ); - if (!eventCategories.some((c) => categories.includes(c.slug))) { - return false; - } - } - if (venue) { - if (!event.occurrences.some((occ) => occ.venue?.slug === venue)) { - return false; - } - } - return true; - }); + const filteredEvents = filterEvents(events, { categories, organizer, venue }); const [showFilter, setShowFilter] = useState(false); function toggleFilter() { diff --git a/web/src/lib/date.test.ts b/web/src/lib/date.test.ts index d1ee4a5..e9aa054 100644 --- a/web/src/lib/date.test.ts +++ b/web/src/lib/date.test.ts @@ -70,6 +70,16 @@ describe("formatExtendedDateTime", () => { "tirsdag 7. juli 2026 kl. 20:00" ); }); + + it("uses the correct UTC offset around the DST fall-back", () => { + // DST ends 01:00Z on 2026-10-25: before it Oslo is UTC+2, after UTC+1 + expect(formatExtendedDateTime("2026-10-24T23:30:00Z")).toBe( + "søndag 25. oktober kl. 01:30" + ); + expect(formatExtendedDateTime("2026-10-25T01:30:00Z")).toBe( + "søndag 25. oktober kl. 02:30" + ); + }); }); describe("isTodayOrFuture", () => { @@ -78,6 +88,11 @@ describe("isTodayOrFuture", () => { expect(isTodayOrFuture("2026-07-07T20:00:00Z")).toBe(true); expect(isTodayOrFuture("2026-08-01T00:00:00Z")).toBe(true); }); + + it("judges the day by Oslo wall clock, not UTC", () => { + // 22:30Z on the 6th is 00:30 on the 7th ("today") in Oslo + expect(isTodayOrFuture("2026-07-06T22:30:00Z")).toBe(true); + }); }); describe("compareDates", () => { @@ -110,6 +125,30 @@ describe("groupConsecutiveDates", () => { ]) ).toEqual([["2026-07-03", "2026-07-04"], ["2026-07-06"]]); }); + + it("buckets late-night UTC times on the Oslo day they fall on", () => { + // 22:30Z on the 3rd is 00:30 on the 4th in Oslo (CEST) + expect( + groupConsecutiveDates(["2026-07-03T22:30:00Z", "2026-07-04T20:00:00Z"]) + ).toEqual([["2026-07-04"]]); + }); + + it("stays consecutive across a year boundary", () => { + expect( + groupConsecutiveDates(["2026-12-31T20:00:00Z", "2027-01-01T20:00:00Z"]) + ).toEqual([["2026-12-31", "2027-01-01"]]); + }); + + it("stays consecutive across the DST fall-back day", () => { + // DST ends sunday 2026-10-25 in Oslo (a 25-hour day) + expect( + groupConsecutiveDates([ + "2026-10-24T18:00:00Z", + "2026-10-25T18:00:00Z", + "2026-10-26T18:00:00Z", + ]) + ).toEqual([["2026-10-24", "2026-10-25", "2026-10-26"]]); + }); }); describe("formatOccurrenceMonths", () => { @@ -143,4 +182,10 @@ describe("formatDateRange", () => { "30. juni—2. juli" ); }); + + it("drops the year even across a year boundary (current behavior)", () => { + expect(formatDateRange(["2026-12-30", "2027-01-02"])).toBe( + "30. desember—2. januar" + ); + }); }); diff --git a/web/src/lib/event.test.ts b/web/src/lib/event.test.ts index df21ee1..556fa34 100644 --- a/web/src/lib/event.test.ts +++ b/web/src/lib/event.test.ts @@ -77,6 +77,60 @@ describe("organizeEventsInCalendar", () => { expect(week["2026-07-06"]).toEqual([]); expect(week["2026-07-12"]).toEqual([]); }); + + it("splits a week across the year boundary under a shared week key", () => { + const events = getSingularEvents([ + makeEvent("romjul", ["2026-12-30T20:00:00Z"]), + makeEvent("nyttaar", ["2027-01-02T20:00:00Z"]), + ]); + + const calendar = organizeEventsInCalendar(events); + + // uke 53 (man 28.12. – søn 3.1.) spans both months; each yearMonth + // bucket holds only its own days, under the same week number + expect(Object.keys(calendar)).toEqual(["2026-12", "2027-01"]); + expect(Object.keys(calendar["2026-12"])).toEqual(["53"]); + expect(Object.keys(calendar["2027-01"])).toEqual(["53"]); + expect(Object.keys(calendar["2026-12"]["53"])).toEqual([ + "2026-12-28", + "2026-12-29", + "2026-12-30", + "2026-12-31", + ]); + expect(Object.keys(calendar["2027-01"]["53"])).toEqual([ + "2027-01-01", + "2027-01-02", + "2027-01-03", + ]); + expect(calendar["2026-12"]["53"]["2026-12-30"].map((e) => e.id)).toEqual([ + "romjul", + ]); + expect(calendar["2027-01"]["53"]["2027-01-02"].map((e) => e.id)).toEqual([ + "nyttaar", + ]); + }); + + it("seeds a full 7-day week through the DST fall-back", () => { + // DST ends søndag 2026-10-25 (a 25-hour day in Oslo) + const events = getSingularEvents([ + makeEvent("a", ["2026-10-21T18:00:00Z"]), + ]); + + const calendar = organizeEventsInCalendar(events); + + const weeks = Object.values(calendar["2026-10"]); + expect(weeks).toHaveLength(1); + expect(Object.keys(weeks[0])).toEqual([ + "2026-10-19", + "2026-10-20", + "2026-10-21", + "2026-10-22", + "2026-10-23", + "2026-10-24", + "2026-10-25", + ]); + expect(weeks[0]["2026-10-21"].map((e) => e.id)).toEqual(["a"]); + }); }); describe("organizeEventsByDate", () => { diff --git a/web/src/lib/eventFilter.test.ts b/web/src/lib/eventFilter.test.ts new file mode 100644 index 0000000..1cf4977 --- /dev/null +++ b/web/src/lib/eventFilter.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vitest"; + +import { EventCategory, EventOrganizer, EventOverviewItemFragment } from "./event.ts"; +import { + filterEvents, + getFilterableCategories, + getFilterableOrganizers, + getFilterableVenues, +} from "./eventFilter.ts"; +import { VenueFragment } from "@/gql/graphql"; + +const category = (slug: string, showInFilters = true) => + ({ __typename: "EventCategory", name: slug, slug, showInFilters }) as EventCategory; + +const organizer = (slug: string) => + ({ __typename: "EventOrganizer", id: slug, name: slug, slug }) as EventOrganizer; + +const venue = (slug: string) => + ({ __typename: "VenuePage", id: slug, title: slug, slug }) as VenueFragment; + +const makeEvent = ( + id: string, + { + organizers = [], + categories = [], + venueSlugs = [], + }: { + organizers?: string[]; + categories?: string[]; + venueSlugs?: (string | null)[]; + } = {} +) => + ({ + id, + organizers: organizers.map((slug) => ({ + __typename: "EventOrganizer", + slug, + })), + categories: categories.map((slug) => ({ + __typename: "EventCategory", + slug, + })), + occurrences: venueSlugs.map((slug, i) => ({ + id: `${id}-${i}`, + start: "2026-07-07T18:00:00Z", + end: null, + venue: slug ? { __typename: "VenuePage", slug } : null, + })), + }) as unknown as EventOverviewItemFragment; + +describe("getFilterableCategories", () => { + it("keeps only categories configured to be shown", () => { + const shown = category("konsert"); + const hidden = category("intern", false); + + expect(getFilterableCategories([shown, hidden])).toEqual([shown]); + }); +}); + +describe("getFilterableOrganizers", () => { + const master = [organizer("kor"), organizer("teater"), organizer("film")]; + + it("returns full organizer objects for organizers with upcoming events, deduped", () => { + const events = [ + makeEvent("a", { organizers: ["teater", "kor"] }), + makeEvent("b", { organizers: ["kor"] }), + ]; + + expect(getFilterableOrganizers(events, master, null).map((o) => o.slug)).toEqual([ + "teater", + "kor", + ]); + }); + + it("drops organizers missing from the master list and blank slugs", () => { + const events = [makeEvent("a", { organizers: ["kor", "nedlagt", ""] })]; + + expect(getFilterableOrganizers(events, master, null).map((o) => o.slug)).toEqual([ + "kor", + ]); + }); + + it("appends a selected organizer that has no upcoming events", () => { + const events = [makeEvent("a", { organizers: ["kor"] })]; + + expect( + getFilterableOrganizers(events, master, "film").map((o) => o.slug) + ).toEqual(["kor", "film"]); + }); + + it("ignores a selected organizer that does not exist", () => { + const events = [makeEvent("a", { organizers: ["kor"] })]; + + expect( + getFilterableOrganizers(events, master, "finnes-ikke").map((o) => o.slug) + ).toEqual(["kor"]); + }); +}); + +describe("getFilterableVenues", () => { + const master = [venue("storsalen"), venue("biblioteket"), venue("kjelleren")]; + + it("keeps venues with upcoming events, in master list order", () => { + const events = [ + makeEvent("a", { venueSlugs: ["kjelleren", null] }), + makeEvent("b", { venueSlugs: ["storsalen"] }), + ]; + + expect(getFilterableVenues(events, master, null).map((v) => v.slug)).toEqual([ + "storsalen", + "kjelleren", + ]); + }); + + it("keeps a selected venue that has no upcoming events", () => { + const events = [makeEvent("a", { venueSlugs: ["storsalen"] })]; + + expect( + getFilterableVenues(events, master, "biblioteket").map((v) => v.slug) + ).toEqual(["storsalen", "biblioteket"]); + }); +}); + +describe("filterEvents", () => { + const konsert = makeEvent("konsert", { + organizers: ["kor"], + categories: ["musikk", "fest"], + venueSlugs: ["storsalen", "kjelleren"], + }); + const teaterkveld = makeEvent("teaterkveld", { + organizers: ["teater"], + categories: ["scene"], + venueSlugs: ["biblioteket"], + }); + const events = [konsert, teaterkveld]; + const all = { categories: null, organizer: null, venue: null }; + + it("returns everything when nothing is selected", () => { + expect(filterEvents(events, all)).toEqual(events); + }); + + it("filters by organizer", () => { + expect(filterEvents(events, { ...all, organizer: "kor" })).toEqual([konsert]); + expect(filterEvents(events, { ...all, organizer: "finnes-ikke" })).toEqual([]); + }); + + it("matches events with any of the selected categories", () => { + expect( + filterEvents(events, { ...all, categories: ["fest", "scene"] }) + ).toEqual(events); + expect(filterEvents(events, { ...all, categories: ["scene"] })).toEqual([ + teaterkveld, + ]); + }); + + it("matches a venue on any occurrence", () => { + expect(filterEvents(events, { ...all, venue: "kjelleren" })).toEqual([ + konsert, + ]); + }); + + it("combines filters with AND", () => { + expect( + filterEvents(events, { + categories: ["musikk"], + organizer: "kor", + venue: "storsalen", + }) + ).toEqual([konsert]); + expect( + filterEvents(events, { + categories: ["musikk"], + organizer: "teater", + venue: null, + }) + ).toEqual([]); + }); +}); diff --git a/web/src/lib/eventFilter.ts b/web/src/lib/eventFilter.ts new file mode 100644 index 0000000..a9cb179 --- /dev/null +++ b/web/src/lib/eventFilter.ts @@ -0,0 +1,124 @@ +import { unmaskFragment } from "@/gql"; +import { VenueFragment } from "@/gql/graphql"; +import { + EventCategory, + EventCategoryFragmentDefinition, + EventOrganizer, + EventOrganizerFragmentDefinition, + EventOverviewItemFragment, +} from "@/lib/event"; +import { unique } from "@/lib/common"; + +export type EventFilterSelection = { + categories: string[] | null; + organizer: string | null; + venue: string | null; +}; + +/* Allow filtering on all categories that are configured to be shown */ +export function getFilterableCategories( + eventCategories: EventCategory[] +): EventCategory[] { + return eventCategories.filter((x) => x.showInFilters); +} + +/* + Allow filtering on all organizers that have upcoming events + Filtering on an organizer with no upcoming events will work, + and in that case it's included in the dropdown +*/ +export function getFilterableOrganizers( + events: EventOverviewItemFragment[], + eventOrganizers: EventOrganizer[], + selectedOrganizer: string | null +): EventOrganizer[] { + const allOrganizers = unmaskFragment( + EventOrganizerFragmentDefinition, + events.flatMap((x) => x.organizers) + ); + const uniqueOrganizers: string[] = unique( + allOrganizers + .filter((x) => x.__typename === "EventOrganizer") + .map((x) => x.slug) + .filter((x) => typeof x === "string" && x !== "") + ); + const filterableOrganizers = uniqueOrganizers + .map((slug) => eventOrganizers.find((haystack) => haystack.slug === slug)) + .filter((x) => x !== undefined) as EventOrganizer[]; + + // If an organizer is selected that's not in the upcoming list, add it + if ( + selectedOrganizer && + !filterableOrganizers.some((org) => org.slug === selectedOrganizer) + ) { + const found = eventOrganizers.find( + (org) => org.slug === selectedOrganizer + ); + if (found) { + filterableOrganizers.push(found); + } + } + + return filterableOrganizers; +} + +/* + Allow filtering on all venues that have upcoming events + Filtering on a venue with no upcoming events will work, + and in that case it's included in the dropdown + */ +export function getFilterableVenues( + events: EventOverviewItemFragment[], + venues: VenueFragment[], + selectedVenue: string | null +): VenueFragment[] { + const venueSlugsWithUpcomingEvents = unique( + events + .map((x) => x.occurrences) + .flat() + .filter((x) => x.venue?.__typename === "VenuePage") + .map((x) => x.venue?.slug) + .filter((x) => typeof x === "string") + ); + return venues.filter( + (x) => + venueSlugsWithUpcomingEvents.includes(x.slug) || x.slug === selectedVenue + ); +} + +/* + Categories are OR-ed (an event matches if it has any selected category); + organizer and venue each further narrow the result. A venue matches if + any occurrence of the event happens there. +*/ +export function filterEvents( + events: EventOverviewItemFragment[], + { categories, organizer, venue }: EventFilterSelection +): EventOverviewItemFragment[] { + return events.filter((event) => { + if (organizer) { + const organizers = unmaskFragment( + EventOrganizerFragmentDefinition, + event.organizers + ); + if (!organizers.some((o) => o.slug === organizer)) { + return false; + } + } + if (categories) { + const eventCategories = unmaskFragment( + EventCategoryFragmentDefinition, + event.categories + ); + if (!eventCategories.some((c) => categories.includes(c.slug))) { + return false; + } + } + if (venue) { + if (!event.occurrences.some((occ) => occ.venue?.slug === venue)) { + return false; + } + } + return true; + }); +}