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>
92 lines
3.9 KiB
JavaScript
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));
|
|
}
|