NGU-Web/src/data/eventData.js
Zaldimmar 2e125b4629 Add a reusable event calendar and put it on the front page
EventCalendar shows events on a month grid or as a list of the
month. Multi-day events are lane-packed bars that break at week
edges, series events appear on every meeting with their time, and
clicking a day lists everything on it. Phones get the list. Visitors
can narrow by scope, type, online only and search, all starting at
"all"; a page can pin section, host or type through props, which
hides that control.

Migration 019 rebuilds front_page_sections to allow a 'calendar'
band and slots it in after the retreats carousel, so it can be
reordered, retitled or hidden from the Front page editor.

useEvents drops its empty fallback so a failed request surfaces as
an error rather than an empty list, and returns the scope list
alongside the events for the calendar's scope filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:28:42 -05:00

92 lines
3.9 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════
EVENT DATA
One request, filtered per section. The Retreats page has three
bands of events, and all three call this hook — the cache in
api.js keys on the path, so they share a single fetch and each
narrows the result to what it shows.
useEvents({ section: "national" }) one band
useEvents({ host: "northwest" }) one host's events
useEvents({ status: "upcoming" }) a home page strip
useEvents({ type: "workshop" }) one kind, wherever it is
useEvents({ type: ["class", "workshop"] })
useEvents() everything
`section` and `type` are different questions and stack rather
than overlap: the section is which band of the page an event
belongs to, the type is what kind of gathering it is. A regional
class matches both { section: "regional" } and { type: "class" }.
The event_sections rows (the scope list) come back alongside, for
anything that offers a scope filter.
No fallback. An empty list on a failed request would read as
"nothing scheduled" when the truth is "the server is down", so
the error comes back and each caller says so.
Filtering here rather than in the query keeps the endpoint to
one cached response. At a few dozen events that's the right
trade; if the list ever runs to hundreds, move the filters into
the URL and let each become its own cache entry.
═══════════════════════════════════════════════════════════════ */
import { useMemo } from "react";
import { useResource } from "../lib/useResource.js";
/* One shared empty list, so a memo keyed on `sections` doesn't
restart on every render before the data arrives. */
const NO_SECTIONS = [];
export function useEvents({ section, host, status, type } = {}) {
const { data, error, loading } = useResource("/events");
const all = data?.events;
const sections = data?.sections ?? NO_SECTIONS;
/* An array prop is a new identity on every render, which would
restart the memo each time. Joining it gives the dependency
list something stable to compare. */
const typeKey = Array.isArray(type) ? type.join(",") : (type ?? "");
const events = useMemo(() => {
let list = all ?? [];
if (section) list = list.filter(e => e.section_id === section);
// `host` is an organization or a person slug, and an event can
// have several of either — co-hosting puts one event on both
// hosts' lists, which is the point.
if (host) list = list.filter(e => e.hosts?.some(h => h.id === host));
if (status) list = list.filter(e => e.status === status);
if (typeKey) {
const wanted = new Set(typeKey.split(","));
list = list.filter(e => wanted.has(e.event_type));
}
return list;
}, [all, section, host, status, typeKey]);
return { events, sections, loading, error };
}
/* Past and upcoming, split. `status` arrives already resolved — the
explicit value when there is one, otherwise derived from ends_on
— so nothing here needs to know which of the two it got. */
export function splitByStatus(events = []) {
const upcoming = [];
const past = [];
for (const event of events) {
(event.status === "past" ? past : upcoming).push(event);
}
return { upcoming, past };
}
/* Which of the declared types a list actually contains, in
EVENT_TYPES order rather than whatever order the rows arrived
in. A section with one type has nothing to filter, which is what
lets the chip bar hide itself. */
export function typesPresent(events = [], declared = []) {
const seen = new Set(events.map(e => e.event_type));
return declared.filter(entry => seen.has(entry.id));
}