NGU-Web/src/pages/sections/EventCalendar.tsx
Zaldimmar 25e592bfea Clean up the schema: drop unused tables, rename scopes, publishable awards
Migrations 020–023, with the code that reads each:

- 020 drops people_lists, people_list_members, v_chapters and
  v_person_affiliations. Nothing queried any of them.
- 021 renames event_sections to event_scopes and events.section_id
  to scope_id, finishing what 015 described. The API sends `scopes`
  and `scope_id`, and useEvents, EventListCards and EventCalendar
  take `scope`. It also inserts national/regional/partner, which only
  the retired seed ever created: a database built from migrations
  alone had no scope for the Retreats bands.
- 022 drops events.sort_order and people.sort_order. Events now sort
  by date (upcoming soonest first, past latest first, undated last)
  on /events, org pages and the countdown. People were only ever
  sorted by sort_name on the site. Every other sort_order stays.
- 023 rebuilds teams with created_at and updated_at plus a touch
  trigger, so the teams editor gets the same optimistic concurrency
  as the other entities.

Awards can be drafted: is_published (added in 011) is on both
descriptor halves with a Publishing group, and the award list, award
page, org awards and event awards leave drafts out.

The migration runner now turns foreign keys off around the per-file
transactions and runs foreign_key_check before each commit. PRAGMA
foreign_keys is a no-op inside a transaction, so 009's warning was
right and a rebuild of a referenced table (023) couldn't be written
otherwise. CLAUDE.md is updated to match.

Also removes the stray src/App.tsx.save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:09:58 -05:00

794 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ═══════════════════════════════════════════════════════════════
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 scope="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.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<CalendarView>(defaultView)
const [selected, setSelected] = useState<string | null>(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 (
<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 && scopeOptions.length > 1 && (
<FilterSelect
label="Scope"
value={pickedScope}
onChange={setPickedScope}
all="All scopes"
options={scopeOptions.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>
)
}