/* ═══════════════════════════════════════════════════════════════ 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. everything one host's calendar one scope 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.ts' import { EVENT_TYPES, eventTypeLabel, type EventType } 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 = { scope?: string host?: string status?: EventListItem['status'] type?: EventType | EventType[] 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({ scope, host, status, type, accent = TEAL, controls = ALL_CONTROLS, defaultView = 'month', }: EventCalendarProps) { const { events: pinned, scopes, loading, error } = useEvents({ scope, host, status, type }) const today = iso(new Date()) const [month, setMonth] = useState(monthKey(today)) const [view, setView] = useState(defaultView) const [selected, setSelected] = useState(null) const [pickedScope, setPickedScope] = 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') && !scope const showType = show('type') && !type const types = useMemo(() => typesPresent(pinned, EVENT_TYPES), [pinned]) const scopeOptions = useMemo(() => { const present = new Set(pinned.map((e) => e.scope_id)) return scopes.filter((s) => present.has(s.id)) }, [pinned, scopes]) const events = useMemo(() => { const needle = query.trim().toLowerCase() return pinned.filter((e) => { if (pickedScope && e.scope_id !== pickedScope) 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, pickedScope, 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(pickedScope || 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 = () => { setPickedScope('') setKind('') setOnlineOnly(false) setQuery('') } return (
{/* ── Month navigation and view ── */}
go(shiftMonth(month, -1))} accent={accent}> ‹ go(shiftMonth(month, 1))} accent={accent}> ›

{monthTitle(month)}

{month !== monthKey(today) && ( )}
{(['month', 'list'] as const).map((option) => ( ))}
{/* ── Filters ── */} {(showScope || showType || show('online') || show('search')) && (
{showScope && scopeOptions.length > 1 && ( [s.id, s.name])} /> )} {showType && types.length > 1 && ( [t.id, t.plural])} /> )} {show('online') && ( )} {show('search') && ( 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 && ( )}
)} {/* ── Body ── */}
{error ? (

Couldn’t load events. {error.message}

) : loading ? (
) } /* ── 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 (
{WEEKDAYS.map((day) => (
{day}
))}
{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 (
{/* Day cells: the click targets, and the numbers. */}
{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 ( ) })}
{/* Bars, laid over the cells. Only the bars take clicks. */}
{visible.map((seg) => ( ))}
) })}
) } function Bar({ seg, accent }: { seg: Segment; accent: string }) { const color = seg.event.color || accent const cancelled = seg.event.status === 'cancelled' return ( {seg.continuesBefore && } {seg.time && seg.span === 1 && {seg.time.split(' – ')[0]} } {seg.event.title} {seg.continuesAfter && ( )} ) } /* ── The selected day ────────────────────────────────────────── */ function DayPanel({ date, items, accent, onClose, }: { date: string items: Occurrence[] accent: string onClose: () => void }) { return (

{longDay(date)}

{items.length === 0 ? (

Nothing on this day.

) : (
    {items.map((item) => (
  • ))}
)}
) } /* ── 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 (

Nothing on the calendar this month.

) } const byDay = new Map() 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 (
    {[...byDay.entries()].map(([day, list]) => { const d = parse(day) return (
  1. {d.toLocaleDateString(undefined, { weekday: 'short' })}

    {d.getDate()}

      {list.map((item) => (
    • ))}
  2. ) })}
) } /* 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 (