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";
|
} from "nuqs";
|
||||||
import { EventItem } from "./EventItem";
|
import { EventItem } from "./EventItem";
|
||||||
import { EventFilter, EventFilterExplained } from "./EventFilter";
|
import { EventFilter, EventFilterExplained } from "./EventFilter";
|
||||||
import { unmaskFragment } from "@/gql";
|
|
||||||
import {
|
import {
|
||||||
EventCategoryFragmentDefinition,
|
|
||||||
EventOrganizerFragmentDefinition,
|
|
||||||
EventOverviewItemFragment,
|
EventOverviewItemFragment,
|
||||||
EventCategory,
|
EventCategory,
|
||||||
SingularEvent,
|
SingularEvent,
|
||||||
@@ -20,6 +17,12 @@ import {
|
|||||||
organizeEventsInCalendar,
|
organizeEventsInCalendar,
|
||||||
EventOrganizer,
|
EventOrganizer,
|
||||||
} from "@/lib/event";
|
} from "@/lib/event";
|
||||||
|
import {
|
||||||
|
filterEvents,
|
||||||
|
getFilterableCategories,
|
||||||
|
getFilterableOrganizers,
|
||||||
|
getFilterableVenues,
|
||||||
|
} from "@/lib/eventFilter";
|
||||||
import { isTodayOrFuture } from "@/lib/date";
|
import { isTodayOrFuture } from "@/lib/date";
|
||||||
import styles from "./eventContainer.module.scss";
|
import styles from "./eventContainer.module.scss";
|
||||||
import { formatDate, formatYearMonth } from "@/lib/date";
|
import { formatDate, formatYearMonth } from "@/lib/date";
|
||||||
@@ -29,7 +32,6 @@ import {
|
|||||||
differenceInCalendarDays,
|
differenceInCalendarDays,
|
||||||
parse,
|
parse,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { unique } from "@/lib/common";
|
|
||||||
import { Icon } from "../general/Icon";
|
import { Icon } from "../general/Icon";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { VenueFragment } from "@/gql/graphql";
|
import { VenueFragment } from "@/gql/graphql";
|
||||||
@@ -70,40 +72,13 @@ export const EventContainer = ({
|
|||||||
resetFilters();
|
resetFilters();
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Allow filtering on all categories that are configured to be shown */
|
const filterableCategories = getFilterableCategories(eventCategories);
|
||||||
const filterableCategories = eventCategories.filter((x) => x.showInFilters);
|
const filterableOrganizers = getFilterableOrganizers(
|
||||||
|
events,
|
||||||
/*
|
eventOrganizers,
|
||||||
Allow filtering on all organizers that have upcoming events
|
organizer,
|
||||||
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 uniqueOrganizers: string[] = unique(
|
const filterableVenues = getFilterableVenues(events, venues, venue);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reset organizer if slug is invalid */
|
/* Reset organizer if slug is invalid */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -112,26 +87,6 @@ export const EventContainer = ({
|
|||||||
}
|
}
|
||||||
}, [eventOrganizers, organizer]);
|
}, [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 */
|
/* Reset venue if slug is invalid */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!venues.find((haystack) => haystack.slug === venue)) {
|
if (!venues.find((haystack) => haystack.slug === venue)) {
|
||||||
@@ -139,32 +94,7 @@ export const EventContainer = ({
|
|||||||
}
|
}
|
||||||
}, [venues, venue]);
|
}, [venues, venue]);
|
||||||
|
|
||||||
const filteredEvents = events.filter((event) => {
|
const filteredEvents = filterEvents(events, { categories, organizer, venue });
|
||||||
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 [showFilter, setShowFilter] = useState(false);
|
const [showFilter, setShowFilter] = useState(false);
|
||||||
function toggleFilter() {
|
function toggleFilter() {
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ describe("formatExtendedDateTime", () => {
|
|||||||
"tirsdag 7. juli 2026 kl. 20:00"
|
"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", () => {
|
describe("isTodayOrFuture", () => {
|
||||||
@@ -78,6 +88,11 @@ describe("isTodayOrFuture", () => {
|
|||||||
expect(isTodayOrFuture("2026-07-07T20:00:00Z")).toBe(true);
|
expect(isTodayOrFuture("2026-07-07T20:00:00Z")).toBe(true);
|
||||||
expect(isTodayOrFuture("2026-08-01T00: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", () => {
|
describe("compareDates", () => {
|
||||||
@@ -110,6 +125,30 @@ describe("groupConsecutiveDates", () => {
|
|||||||
])
|
])
|
||||||
).toEqual([["2026-07-03", "2026-07-04"], ["2026-07-06"]]);
|
).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", () => {
|
describe("formatOccurrenceMonths", () => {
|
||||||
@@ -143,4 +182,10 @@ describe("formatDateRange", () => {
|
|||||||
"30. juni—2. juli"
|
"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-06"]).toEqual([]);
|
||||||
expect(week["2026-07-12"]).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", () => {
|
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