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>
794 lines
27 KiB
TypeScript
794 lines
27 KiB
TypeScript
/* ═══════════════════════════════════════════════════════════════
|
||
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>
|
||
)
|
||
}
|