web: extract event filter logic to lib, add tz/DST and year-boundary tests

This commit is contained in:
2026-08-31 23:25:11 +02:00
parent 16778eaca2
commit 7df0a646bc
5 changed files with 414 additions and 83 deletions
+13 -83
View File
@@ -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() {
+45
View File
@@ -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"
);
});
});
+54
View File
@@ -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", () => {
+178
View File
@@ -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([]);
});
});
+124
View File
@@ -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;
});
}