web: extract event filter logic to lib, add tz/DST and year-boundary tests
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user