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>
This commit is contained in:
Zaldimmar 2026-09-25 06:28:42 -05:00
parent 641ab167b0
commit 2e125b4629
10 changed files with 947 additions and 8 deletions

View file

@ -0,0 +1,791 @@
/* ═══════════════════════════════════════════════════════════════
EVENT CALENDAR
Every published event on a month grid, or as a list of the month.
Self contained like EventListCards: give it a filter and it
fetches, so it can sit on any page.
<EventCalendar /> everything
<EventCalendar host="northwest" /> one host's calendar
<EventCalendar section="national" /> one scope
<EventCalendar type={["class", "workshop"]} controls={["search"]} />
Two layers of filtering, and they answer different questions:
props what this page's calendar is about. Pinned; the
visitor can't widen them, and the control for a
pinned dimension doesn't render.
controls what the visitor can narrow by within that: scope,
type, online only, search. All four by default,
every one starting at "all".
What lands on a day:
one-off every day from starts_on to ends_on, drawn as one
bar across the days it spans, broken at the week
edge and marked as continuing
series every meeting the schedule produces in view (see
eventSeries.ts), one day each, with its time
undated nowhere — there's no day to put it on. Counted
under the grid so it doesn't vanish without a word.
Bars are laid out per week in lanes, so a long event keeps its
row across the days it covers. MAX_LANES rows show; a day with
more says "+N more", and clicking any day lists everything on it
below the grid.
Below md the grid would be seven unreadable slivers, so the month
view shows the list there instead. The toggle still works; it
just has one answer on a phone.
═══════════════════════════════════════════════════════════════ */
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { typesPresent, useEvents } from '../../data/eventData.js'
import type { EventFilter } from '../../data/eventData.js'
import { EVENT_TYPES, eventTypeLabel } from '../../lib/eventTypes.ts'
import { firstOccurrenceFrom, occurrencesBetween, seriesTimes } from '../../lib/eventSeries.ts'
import { eventHref } from '../../lib/hrefs.ts'
import type { EventListItem } from '../../lib/useContent.ts'
export type CalendarControl = 'scope' | 'type' | 'online' | 'search'
export type CalendarView = 'month' | 'list'
const ALL_CONTROLS: CalendarControl[] = ['scope', 'type', 'online', 'search']
/* Bar rows drawn per week before a day collapses to "+N more". */
const MAX_LANES = 3
const TEAL = '#138ba0'
const INK = '#073d4a'
const BODY = '#4a6b72'
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
type EventCalendarProps = EventFilter & {
accent?: string
/** Which visitor controls render. A pinned prop hides its own. */
controls?: CalendarControl[]
defaultView?: CalendarView
}
/* One appearance of an event on the calendar: a whole one-off, or a
single meeting of a series. Dates are inclusive 'YYYY-MM-DD'. */
type Occurrence = {
key: string
event: EventListItem
start: string
end: string
time: string | null
}
type Segment = Occurrence & {
col: number
span: number
lane: number
continuesBefore: boolean
continuesAfter: boolean
}
/* ── Dates ───────────────────────────────────────────────────── */
const pad = (n: number) => String(n).padStart(2, '0')
const iso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
function parse(date: string): Date {
const [y, m, d] = date.split('-').map(Number)
return new Date(y, m - 1, d)
}
const addDays = (date: string, n: number) => {
const d = parse(date)
d.setDate(d.getDate() + n)
return iso(d)
}
const dayDiff = (a: string, b: string) =>
Math.round((parse(b).getTime() - parse(a).getTime()) / 86_400_000)
const monthKey = (date: string) => date.slice(0, 7)
/* The six Sunday-started weeks that cover a month. */
function gridFor(month: string): string[][] {
const first = parse(`${month}-01`)
const start = addDays(iso(first), -first.getDay())
return Array.from({ length: 6 }, (_, w) =>
Array.from({ length: 7 }, (_, d) => addDays(start, w * 7 + d)),
)
}
const monthTitle = (month: string) =>
parse(`${month}-01`).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
const shiftMonth = (month: string, n: number) => {
const d = parse(`${month}-01`)
d.setMonth(d.getMonth() + n)
return monthKey(iso(d))
}
const longDay = (date: string) =>
parse(date).toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})
function rangeLabel(start: string, end: string): string {
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }
if (start === end) return parse(start).toLocaleDateString(undefined, opts)
return `${parse(start).toLocaleDateString(undefined, opts)} – ${parse(end).toLocaleDateString(undefined, opts)}`
}
/* ── Occurrences ─────────────────────────────────────────────── */
function occurrencesIn(events: EventListItem[], from: string, to: string): Occurrence[] {
const out: Occurrence[] = []
for (const event of events) {
if (!event.starts_on) continue
if (event.series) {
const time = seriesTimes(event.series)
for (const date of occurrencesBetween(event.series, event.starts_on, event.ends_on, from, to)) {
out.push({ key: `${event.id}@${date}`, event, start: date, end: date, time })
}
continue
}
const start = event.starts_on
const end = event.ends_on && event.ends_on >= start ? event.ends_on : start
if (end < from || start > to) continue
out.push({ key: event.id, event, start, end, time: null })
}
// Longest first within a day, so multi-day bars claim the top lanes.
return out.sort(
(a, b) => a.start.localeCompare(b.start) || dayDiff(b.start, b.end) - dayDiff(a.start, a.end),
)
}
/* Greedy lane packing for one week: each segment takes the first
lane whose last occupant ended before it starts. */
function layoutWeek(week: string[], occurrences: Occurrence[]): Segment[] {
const first = week[0]
const last = week[6]
const laneEnds: number[] = []
const segments: Segment[] = []
for (const occ of occurrences) {
if (occ.end < first || occ.start > last) continue
const start = occ.start < first ? first : occ.start
const end = occ.end > last ? last : occ.end
const col = dayDiff(first, start)
const span = dayDiff(start, end) + 1
let lane = laneEnds.findIndex((endCol) => endCol < col)
if (lane === -1) lane = laneEnds.length
laneEnds[lane] = col + span - 1
segments.push({
...occ,
col,
span,
lane,
continuesBefore: occ.start < first,
continuesAfter: occ.end > last,
})
}
return segments
}
/* The next date after `after` that any of these events lands on. */
function nextDateAfter(events: EventListItem[], after: string): string | null {
let best: string | null = null
const from = addDays(after, 1)
for (const event of events) {
if (!event.starts_on) continue
const date = event.series
? firstOccurrenceFrom(event.series, event.starts_on, event.ends_on, from)
: event.starts_on >= from
? event.starts_on
: null
if (date && (!best || date < best)) best = date
}
return best
}
/* ── Component ───────────────────────────────────────────────── */
export default function EventCalendar({
section,
host,
status,
type,
accent = TEAL,
controls = ALL_CONTROLS,
defaultView = 'month',
}: EventCalendarProps) {
const { events: pinned, sections, loading, error } = useEvents({ section, host, status, type })
const today = iso(new Date())
const [month, setMonth] = useState(monthKey(today))
const [view, setView] = useState<CalendarView>(defaultView)
const [selected, setSelected] = useState<string | null>(null)
const [scope, setScope] = useState('')
const [kind, setKind] = useState('')
const [onlineOnly, setOnlineOnly] = useState(false)
const [query, setQuery] = useState('')
const show = (control: CalendarControl) => controls.includes(control)
const showScope = show('scope') && !section
const showType = show('type') && !type
const types = useMemo(() => typesPresent(pinned, EVENT_TYPES), [pinned])
const scopes = useMemo(() => {
const present = new Set(pinned.map((e) => e.section_id))
return sections.filter((s) => present.has(s.id))
}, [pinned, sections])
const events = useMemo(() => {
const needle = query.trim().toLowerCase()
return pinned.filter((e) => {
if (scope && e.section_id !== scope) return false
if (kind && e.event_type !== kind) return false
if (onlineOnly && !e.is_online) return false
if (needle) {
const haystack = [
e.title,
e.theme,
e.location_label,
e.locality,
...(e.hosts ?? []).map((h) => h.name),
]
.filter(Boolean)
.join(' ')
.toLowerCase()
if (!haystack.includes(needle)) return false
}
return true
})
}, [pinned, scope, kind, onlineOnly, query])
const weeks = useMemo(() => gridFor(month), [month])
const gridFrom = weeks[0][0]
const gridTo = weeks[5][6]
const monthFrom = `${month}-01`
const monthTo = addDays(`${shiftMonth(month, 1)}-01`, -1)
const occurrences = useMemo(
() => occurrencesIn(events, gridFrom, gridTo),
[events, gridFrom, gridTo],
)
const inMonth = occurrences.filter((o) => o.end >= monthFrom && o.start <= monthTo)
const undated = events.filter((e) => !e.starts_on).length
const filtering = Boolean(scope || kind || onlineOnly || query)
const next = inMonth.length === 0 ? nextDateAfter(events, monthTo) : null
const onDay = (date: string) => occurrences.filter((o) => o.start <= date && o.end >= date)
const go = (target: string) => {
setMonth(target)
setSelected(null)
}
const clearFilters = () => {
setScope('')
setKind('')
setOnlineOnly(false)
setQuery('')
}
return (
<div className="mx-auto max-w-6xl px-6" style={{ color: INK }}>
{/* ── Month navigation and view ── */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<NavButton label="Previous month" onClick={() => go(shiftMonth(month, -1))} accent={accent}>
‹
</NavButton>
<NavButton label="Next month" onClick={() => go(shiftMonth(month, 1))} accent={accent}>
›
</NavButton>
</div>
<h3 className="min-w-[11rem] font-display text-2xl font-bold" aria-live="polite">
{monthTitle(month)}
</h3>
{month !== monthKey(today) && (
<button
type="button"
onClick={() => go(monthKey(today))}
className="rounded-full border px-3 py-1 text-sm font-semibold hover:bg-white"
style={{ borderColor: accent, color: accent }}
>
Today
</button>
)}
<div
className="ml-auto hidden overflow-hidden rounded-full border md:flex"
style={{ borderColor: accent }}
role="group"
aria-label="Calendar view"
>
{(['month', 'list'] as const).map((option) => (
<button
key={option}
type="button"
onClick={() => setView(option)}
aria-pressed={view === option}
className="px-4 py-1.5 text-sm font-semibold capitalize transition-colors"
style={
view === option
? { background: accent, color: '#ffffff' }
: { color: accent }
}
>
{option}
</button>
))}
</div>
</div>
{/* ── Filters ── */}
{(showScope || showType || show('online') || show('search')) && (
<div className="mt-5 flex flex-wrap items-center gap-3">
{showScope && scopes.length > 1 && (
<FilterSelect
label="Scope"
value={scope}
onChange={setScope}
all="All scopes"
options={scopes.map((s) => [s.id, s.name])}
/>
)}
{showType && types.length > 1 && (
<FilterSelect
label="Type"
value={kind}
onChange={setKind}
all="All types"
options={types.map((t) => [t.id, t.plural])}
/>
)}
{show('online') && (
<label className="flex cursor-pointer items-center gap-2 rounded-full border border-[#138ba0]/25 bg-white px-4 py-2 text-sm">
<input
type="checkbox"
checked={onlineOnly}
onChange={(e) => setOnlineOnly(e.target.checked)}
className="h-4 w-4 rounded"
style={{ accentColor: accent }}
/>
Online only
</label>
)}
{show('search') && (
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search events"
aria-label="Search events"
className="min-w-[12rem] flex-1 rounded-full border border-[#138ba0]/25 bg-white px-4 py-2 text-sm outline-none focus:border-[#138ba0] md:max-w-xs"
/>
)}
{filtering && (
<button
type="button"
onClick={clearFilters}
className="text-sm font-semibold hover:underline"
style={{ color: accent }}
>
Clear filters
</button>
)}
</div>
)}
{/* ── Body ── */}
<div className="mt-6">
{error ? (
<p className="rounded-2xl bg-white p-6 text-[#b3261e]">
Couldn’t load events. {error.message}
</p>
) : loading ? (
<div className="h-[32rem] animate-pulse rounded-3xl bg-white/70" aria-hidden="true" />
) : (
<>
{view === 'month' && (
<div className="hidden md:block">
<MonthGrid
weeks={weeks}
month={month}
today={today}
occurrences={occurrences}
selected={selected}
onSelect={(date) => setSelected((s) => (s === date ? null : date))}
accent={accent}
/>
{selected && (
<DayPanel
date={selected}
items={onDay(selected)}
accent={accent}
onClose={() => setSelected(null)}
/>
)}
</div>
)}
<div className={view === 'month' ? 'md:hidden' : ''}>
<MonthList items={inMonth} monthFrom={monthFrom} accent={accent} />
</div>
{inMonth.length === 0 && (
<div className="mt-4 text-center text-sm" style={{ color: BODY }}>
{next ? (
<button
type="button"
onClick={() => go(monthKey(next))}
className="font-semibold hover:underline"
style={{ color: accent }}
>
Jump to the next event, {monthTitle(monthKey(next))} →
</button>
) : filtering ? (
'Nothing matches these filters.'
) : null}
</div>
)}
{undated > 0 && (
<p className="mt-4 text-center text-xs" style={{ color: BODY }}>
{undated === 1 ? '1 event has' : `${undated} events have`} no dates yet, so{' '}
{undated === 1 ? 'isn’t' : 'aren’t'} on the calendar.
</p>
)}
</>
)}
</div>
</div>
)
}
/* ── Month grid ──────────────────────────────────────────────── */
type MonthGridProps = {
weeks: string[][]
month: string
today: string
occurrences: Occurrence[]
selected: string | null
onSelect: (date: string) => void
accent: string
}
function MonthGrid({ weeks, month, today, occurrences, selected, onSelect, accent }: MonthGridProps) {
return (
<div className="overflow-hidden rounded-3xl border border-[#138ba0]/15 bg-white shadow-sm">
<div className="grid grid-cols-7 border-b border-[#138ba0]/10 bg-[#f6fbfc]">
{WEEKDAYS.map((day) => (
<div
key={day}
className="px-3 py-2 text-xs font-bold uppercase tracking-widest"
style={{ color: BODY }}
>
{day}
</div>
))}
</div>
{weeks.map((week) => {
const segments = layoutWeek(week, occurrences)
const visible = segments.filter((s) => s.lane < MAX_LANES)
const hiddenOn = (col: number) =>
segments.filter((s) => s.lane >= MAX_LANES && s.col <= col && s.col + s.span > col).length
const countOn = (col: number) =>
segments.filter((s) => s.col <= col && s.col + s.span > col).length
return (
<div key={week[0]} className="relative min-h-[8rem] border-b border-[#138ba0]/10 last:border-b-0">
{/* Day cells: the click targets, and the numbers. */}
<div className="absolute inset-0 grid grid-cols-7">
{week.map((date, col) => {
const outside = monthKey(date) !== month
const isToday = date === today
const isSelected = date === selected
const count = countOn(col)
const hidden = hiddenOn(col)
return (
<button
key={date}
type="button"
onClick={() => onSelect(date)}
aria-pressed={isSelected}
aria-label={`${longDay(date)}${count ? `, ${count} event${count === 1 ? '' : 's'}` : ''}`}
className="relative flex flex-col items-start border-r border-[#138ba0]/10 p-2 text-left transition-colors last:border-r-0 hover:bg-[#f6fbfc]"
style={{
background: isSelected ? `${accent}14` : outside ? '#fbfdfd' : undefined,
}}
>
<span
className="flex h-7 w-7 items-center justify-center rounded-full text-sm font-semibold"
style={
isToday
? { background: accent, color: '#ffffff' }
: { color: outside ? '#b8c6c9' : INK }
}
>
{Number(date.slice(8))}
</span>
{hidden > 0 && (
<span className="mt-auto text-xs font-semibold" style={{ color: accent }}>
+{hidden} more
</span>
)}
</button>
)
})}
</div>
{/* Bars, laid over the cells. Only the bars take clicks. */}
<div
className="pointer-events-none relative grid grid-cols-7 gap-y-1 pb-7 pt-10"
style={{ gridTemplateRows: `repeat(${MAX_LANES}, 1.5rem)` }}
>
{visible.map((seg) => (
<Bar key={`${seg.key}-${week[0]}`} seg={seg} accent={accent} />
))}
</div>
</div>
)
})}
</div>
)
}
function Bar({ seg, accent }: { seg: Segment; accent: string }) {
const color = seg.event.color || accent
const cancelled = seg.event.status === 'cancelled'
return (
<Link
to={eventHref(seg.event.id)}
title={`${seg.event.title}${seg.time ? ` · ${seg.time}` : ''}`}
className={`pointer-events-auto flex items-center gap-1 truncate px-2 text-xs font-semibold text-white transition-[filter] hover:brightness-110 ${
seg.continuesBefore ? 'ml-0 rounded-l-none' : 'ml-1 rounded-l-md'
} ${seg.continuesAfter ? 'mr-0 rounded-r-none' : 'mr-1 rounded-r-md'} ${
cancelled ? 'line-through opacity-60' : ''
}`}
style={{
gridColumn: `${seg.col + 1} / span ${seg.span}`,
gridRow: seg.lane + 1,
background: color,
}}
>
{seg.continuesBefore && <span aria-hidden="true">←</span>}
<span className="truncate">
{seg.time && seg.span === 1 && <span className="font-normal opacity-85">{seg.time.split(' – ')[0]} </span>}
{seg.event.title}
</span>
{seg.continuesAfter && (
<span className="ml-auto" aria-hidden="true">
→
</span>
)}
</Link>
)
}
/* ── The selected day ────────────────────────────────────────── */
function DayPanel({
date,
items,
accent,
onClose,
}: {
date: string
items: Occurrence[]
accent: string
onClose: () => void
}) {
return (
<div className="mt-4 rounded-3xl border bg-white p-6" style={{ borderColor: `${accent}40` }}>
<div className="flex items-center gap-4">
<h4 className="font-display text-lg font-bold">{longDay(date)}</h4>
<button
type="button"
onClick={onClose}
className="ml-auto text-sm font-semibold hover:underline"
style={{ color: accent }}
>
Close
</button>
</div>
{items.length === 0 ? (
<p className="mt-3 text-sm" style={{ color: BODY }}>
Nothing on this day.
</p>
) : (
<ul className="mt-4 space-y-3">
{items.map((item) => (
<li key={item.key}>
<EventLine item={item} accent={accent} />
</li>
))}
</ul>
)}
</div>
)
}
/* ── List view ───────────────────────────────────────────────── */
/* The month's occurrences by day. An event that began last month
files under the 1st, where it's still on. */
function MonthList({
items,
monthFrom,
accent,
}: {
items: Occurrence[]
monthFrom: string
accent: string
}) {
if (items.length === 0) {
return (
<p className="rounded-3xl bg-white p-10 text-center" style={{ color: BODY }}>
Nothing on the calendar this month.
</p>
)
}
const byDay = new Map<string, Occurrence[]>()
for (const item of items) {
const day = item.start < monthFrom ? monthFrom : item.start
const list = byDay.get(day)
if (list) list.push(item)
else byDay.set(day, [item])
}
return (
<ol className="space-y-4">
{[...byDay.entries()].map(([day, list]) => {
const d = parse(day)
return (
<li key={day} className="flex gap-5 rounded-3xl bg-white p-5 shadow-sm">
<div className="w-14 shrink-0 text-center">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: accent }}>
{d.toLocaleDateString(undefined, { weekday: 'short' })}
</p>
<p className="font-display text-3xl font-extrabold leading-none">{d.getDate()}</p>
</div>
<ul className="min-w-0 flex-1 space-y-3">
{list.map((item) => (
<li key={item.key}>
<EventLine item={item} accent={accent} />
</li>
))}
</ul>
</li>
)
})}
</ol>
)
}
/* One occurrence as a line of text, shared by the day panel and the
list so the two describe an event the same way. */
function EventLine({ item, accent }: { item: Occurrence; accent: string }) {
const { event } = item
const color = event.color || accent
const where = event.location_label || (event.is_online ? 'Online' : null)
const cancelled = event.status === 'cancelled'
return (
<Link
to={eventHref(event.id)}
className="group flex items-start gap-3 rounded-xl p-2 transition-colors hover:bg-[#f6fbfc]"
>
<span className="mt-1.5 h-3 w-3 shrink-0 rounded-full" style={{ background: color }} aria-hidden="true" />
<span className="min-w-0">
<span className={`block font-semibold group-hover:underline ${cancelled ? 'line-through' : ''}`}>
{event.title}
{cancelled && <span className="ml-2 text-xs font-bold uppercase text-[#b3261e] no-underline">Cancelled</span>}
</span>
<span className="block text-sm" style={{ color: BODY }}>
{[
item.time ?? (item.start !== item.end ? rangeLabel(item.start, item.end) : null),
eventTypeLabel(event.event_type),
where,
]
.filter(Boolean)
.join(' · ')}
</span>
</span>
</Link>
)
}
/* ── Controls ────────────────────────────────────────────────── */
function NavButton({
label,
onClick,
accent,
children,
}: {
label: string
onClick: () => void
accent: string
children: string
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
className="flex h-10 w-10 items-center justify-center rounded-full border text-xl transition-colors hover:bg-white"
style={{ borderColor: accent, color: accent }}
>
{children}
</button>
)
}
function FilterSelect({
label,
value,
onChange,
all,
options,
}: {
label: string
value: string
onChange: (value: string) => void
all: string
options: Array<[string, string]>
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label={label}
className="rounded-full border border-[#138ba0]/25 bg-white px-4 py-2 text-sm outline-none focus:border-[#138ba0]"
>
<option value="">{all}</option>
{options.map(([id, name]) => (
<option key={id} value={id}>
{name}
</option>
))}
</select>
)
}

View file

@ -0,0 +1,28 @@
/* ═══════════════════════════════════════════════════════════════
CALENDAR BAND
EventCalendar on the front page: every scope and every type, all
four visitor filters, starting on this month. The same component
can go on any page with a narrower filter — a region's page would
pass host, a classes page would pass type — and this file only
adds the front page's heading around it.
═══════════════════════════════════════════════════════════════ */
import EventCalendar from '../EventCalendar.tsx'
type CalendarBandProps = { id: string; title: string; blurb?: string | null }
export default function CalendarBand({ id, title, blurb }: CalendarBandProps) {
return (
<section id={id} className="py-24" style={{ background: '#f6fbfc' }}>
<div className="mx-auto mb-10 max-w-6xl px-6">
<h2 className="font-display text-4xl font-extrabold text-[#073d4a] md:text-5xl">
{title}
</h2>
{blurb && <p className="mt-3 max-w-xl text-lg text-[#4a6b72]">{blurb}</p>}
</div>
<EventCalendar />
</section>
)
}