Admin-driven front page with event calendar #7

Merged
ngu-git-admin merged 3 commits from feature/alt-front-page into main 2026-09-25 12:42:34 +01:00
10 changed files with 947 additions and 8 deletions
Showing only changes of commit 2e125b4629 - Show all commits

View file

@ -716,7 +716,7 @@ const frontPage = {
columns: [ columns: [
enumeration( enumeration(
"section", "section",
["countdown", "retreats", "stats", "timeline", "connect"], ["countdown", "retreats", "calendar", "stats", "timeline", "connect"],
{ required: true }, { required: true },
), ),
text("title"), text("title"),

View file

@ -0,0 +1,61 @@
-- ═══════════════════════════════════════════════════════════════
-- FRONT PAGE: calendar band
--
-- Adds 'calendar' to the sections the front page can draw. The key
-- is a CHECK, and SQLite can't alter a CHECK in place, so the table
-- is rebuilt: new table, copy, drop, rename.
--
-- No PRAGMA foreign_keys dance. front_page_sections only points out
-- (at front_page); nothing points in, so dropping the old table
-- cascades into nothing, and the copy keeps every page_id valid.
--
-- The new band is inserted straight after the retreats carousel,
-- where "what's on" reads naturally, by shifting everything below it
-- down one. If retreats was removed on this box, it goes last.
--
-- Adding another section later is the same three steps: this CHECK,
-- the enum in both descriptor halves, and SECTIONS in Home.tsx.
--
-- No BEGIN...END in this file.
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE front_page_sections_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
section TEXT NOT NULL
CHECK (section IN ('countdown', 'retreats', 'calendar', 'stats',
'timeline', 'connect')),
title TEXT,
blurb TEXT,
is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)),
UNIQUE (page_id, section)
) STRICT;
INSERT INTO front_page_sections_new (id, page_id, sort_order, section, title, blurb, is_hidden)
SELECT id, page_id, sort_order, section, title, blurb, is_hidden
FROM front_page_sections;
DROP TABLE front_page_sections;
ALTER TABLE front_page_sections_new RENAME TO front_page_sections;
UPDATE front_page_sections
SET sort_order = sort_order + 1
WHERE page_id = 'home'
AND sort_order > COALESCE(
(SELECT sort_order FROM front_page_sections
WHERE page_id = 'home' AND section = 'retreats'),
(SELECT MAX(sort_order) FROM front_page_sections WHERE page_id = 'home'));
INSERT INTO front_page_sections (page_id, sort_order, section, title, blurb)
SELECT 'home',
COALESCE(
(SELECT sort_order + 1 FROM front_page_sections
WHERE page_id = 'home' AND section = 'retreats'),
(SELECT COALESCE(MAX(sort_order), -1) + 1 FROM front_page_sections
WHERE page_id = 'home')),
'calendar',
'What''s on',
'Every gathering, class and meeting in one place.'
WHERE EXISTS (SELECT 1 FROM front_page WHERE id = 'home');

View file

@ -2,7 +2,7 @@
server/src/routes/content.js sends them. */ server/src/routes/content.js sends them. */
import type { EventType } from "../lib/eventTypes.ts"; import type { EventType } from "../lib/eventTypes.ts";
import type { EventListItem } from "../lib/useContent.ts"; import type { EventListItem, EventSection } from "../lib/useContent.ts";
export type EventFilter = { export type EventFilter = {
section?: string; section?: string;
@ -13,6 +13,8 @@ export type EventFilter = {
export declare function useEvents(filter?: EventFilter): { export declare function useEvents(filter?: EventFilter): {
events: EventListItem[]; events: EventListItem[];
/** event_sections, in scope order. Empty until loaded. */
sections: EventSection[];
loading: boolean; loading: boolean;
error: Error | null; error: Error | null;
}; };

View file

@ -18,6 +18,13 @@
belongs to, the type is what kind of gathering it is. A regional belongs to, the type is what kind of gathering it is. A regional
class matches both { section: "regional" } and { type: "class" }. 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 Filtering here rather than in the query keeps the endpoint to
one cached response. At a few dozen events that's the right one cached response. At a few dozen events that's the right
trade; if the list ever runs to hundreds, move the filters into trade; if the list ever runs to hundreds, move the filters into
@ -28,12 +35,15 @@ import { useMemo } from "react";
import { useResource } from "../lib/useResource.js"; import { useResource } from "../lib/useResource.js";
const EMPTY = { events: [] }; /* 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 } = {}) { export function useEvents({ section, host, status, type } = {}) {
const { data, error, loading } = useResource("/events", { fallback: EMPTY }); const { data, error, loading } = useResource("/events");
const all = data?.events; const all = data?.events;
const sections = data?.sections ?? NO_SECTIONS;
/* An array prop is a new identity on every render, which would /* An array prop is a new identity on every render, which would
restart the memo each time. Joining it gives the dependency restart the memo each time. Joining it gives the dependency
@ -55,7 +65,7 @@ export function useEvents({ section, host, status, type } = {}) {
return list; return list;
}, [all, section, host, status, typeKey]); }, [all, section, host, status, typeKey]);
return { events, loading, error }; return { events, sections, loading, error };
} }
/* Past and upcoming, split. `status` arrives already resolved — the /* Past and upcoming, split. `status` arrives already resolved — the

View file

@ -948,13 +948,14 @@ const timeline = {
the page. Photos and the livestream are editable whatever the the page. Photos and the livestream are editable whatever the
mode, so either can be ready before the switch is flipped. mode, so either can be ready before the switch is flipped.
Section keys and stat sources are the CHECK lists in migration Section keys and stat sources are the CHECK lists in migrations
017. The labels here are what the admin reads; the values are 017 and 019. The labels here are what the admin reads; the values are
what the page and the API key on. */ what the page and the API key on. */
const FRONT_PAGE_SECTIONS = [ const FRONT_PAGE_SECTIONS = [
["countdown", "Countdown to the next event"], ["countdown", "Countdown to the next event"],
["retreats", "National Retreats carousel"], ["retreats", "National Retreats carousel"],
["calendar", "Event calendar"],
["stats", "Numbers"], ["stats", "Numbers"],
["timeline", "Featured timeline"], ["timeline", "Featured timeline"],
["connect", "Find your way in"], ["connect", "Find your way in"],

View file

@ -171,6 +171,39 @@ export function upcomingOccurrences(
return out return out
} }
/** Every meeting between two dates, inclusive, in order. For a
* calendar page: `from` and `to` are the visible range. */
export function occurrencesBetween(
series: EventSeries | null | undefined,
startsOn: string | null | undefined,
endsOn: string | null | undefined,
from: string,
to: string,
): string[] {
if (!series) return []
const out: string[] = []
for (const date of occurrences(series, startsOn, endsOn)) {
if (date > to) break
if (date >= from) out.push(date)
}
return out
}
/** The first meeting on or after `from`, or null if the series has
* ended by then. */
export function firstOccurrenceFrom(
series: EventSeries | null | undefined,
startsOn: string | null | undefined,
endsOn: string | null | undefined,
from: string,
): string | null {
if (!series) return null
for (const date of occurrences(series, startsOn, endsOn)) {
if (date >= from) return date
}
return null
}
/* ── Words ───────────────────────────────────────────────────── */ /* ── Words ───────────────────────────────────────────────────── */
const ORDINALS = ['', '1st', '2nd', '3rd', '4th', 'last'] const ORDINALS = ['', '1st', '2nd', '3rd', '4th', 'last']

View file

@ -16,7 +16,13 @@ import type { EventSeries } from './eventSeries.ts'
export type HeroMode = 'brand' | 'photos' | 'livestream' export type HeroMode = 'brand' | 'photos' | 'livestream'
/** The CHECK list on front_page_sections.section. */ /** The CHECK list on front_page_sections.section. */
export type FrontPageSectionKey = 'countdown' | 'retreats' | 'stats' | 'timeline' | 'connect' export type FrontPageSectionKey =
| 'countdown'
| 'retreats'
| 'calendar'
| 'stats'
| 'timeline'
| 'connect'
export type HeroButton = { label: string; url: string } export type HeroButton = { label: string; url: string }

View file

@ -27,6 +27,7 @@ import type { ReactNode } from 'react'
import HeroStage from './sections/home/HeroStage.tsx' import HeroStage from './sections/home/HeroStage.tsx'
import NextEventCountdown from './sections/home/NextEventCountdown.tsx' import NextEventCountdown from './sections/home/NextEventCountdown.tsx'
import RetreatsBand from './sections/home/RetreatsBand.tsx' import RetreatsBand from './sections/home/RetreatsBand.tsx'
import CalendarBand from './sections/home/CalendarBand.tsx'
import StatsBand from './sections/home/StatsBand.tsx' import StatsBand from './sections/home/StatsBand.tsx'
import FeaturedTimelineRail from './sections/home/FeaturedTimelineRail.tsx' import FeaturedTimelineRail from './sections/home/FeaturedTimelineRail.tsx'
import Pathfinder from './sections/home/Pathfinder.tsx' import Pathfinder from './sections/home/Pathfinder.tsx'
@ -64,6 +65,12 @@ const SECTIONS: Record<
<RetreatsBand id="retreats" title={title} blurb={section.blurb} /> <RetreatsBand id="retreats" title={title} blurb={section.blurb} />
), ),
}, },
calendar: {
title: 'What’s on',
render: ({ section, title }) => (
<CalendarBand id="calendar" title={title} blurb={section.blurb} />
),
},
stats: { stats: {
title: 'By the numbers', title: 'By the numbers',
render: ({ page, section, title }) => render: ({ page, section, title }) =>

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>
)
}