diff --git a/server/src/admin-schema.js b/server/src/admin-schema.js
index 85270ec..e457bb1 100644
--- a/server/src/admin-schema.js
+++ b/server/src/admin-schema.js
@@ -716,7 +716,7 @@ const frontPage = {
columns: [
enumeration(
"section",
- ["countdown", "retreats", "stats", "timeline", "connect"],
+ ["countdown", "retreats", "calendar", "stats", "timeline", "connect"],
{ required: true },
),
text("title"),
diff --git a/server/src/migrations/019_front_page_calendar.sql b/server/src/migrations/019_front_page_calendar.sql
new file mode 100644
index 0000000..a6987b5
--- /dev/null
+++ b/server/src/migrations/019_front_page_calendar.sql
@@ -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');
diff --git a/src/data/eventData.d.ts b/src/data/eventData.d.ts
index 1542ac6..e3d999b 100644
--- a/src/data/eventData.d.ts
+++ b/src/data/eventData.d.ts
@@ -2,7 +2,7 @@
server/src/routes/content.js sends them. */
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 = {
section?: string;
@@ -13,6 +13,8 @@ export type EventFilter = {
export declare function useEvents(filter?: EventFilter): {
events: EventListItem[];
+ /** event_sections, in scope order. Empty until loaded. */
+ sections: EventSection[];
loading: boolean;
error: Error | null;
};
diff --git a/src/data/eventData.js b/src/data/eventData.js
index 648d7ff..1aa242d 100644
--- a/src/data/eventData.js
+++ b/src/data/eventData.js
@@ -18,6 +18,13 @@
belongs to, the type is what kind of gathering it is. A regional
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
one cached response. At a few dozen events that's the right
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";
-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 } = {}) {
- const { data, error, loading } = useResource("/events", { fallback: EMPTY });
+ const { data, error, loading } = useResource("/events");
const all = data?.events;
+ const sections = data?.sections ?? NO_SECTIONS;
/* An array prop is a new identity on every render, which would
restart the memo each time. Joining it gives the dependency
@@ -55,7 +65,7 @@ export function useEvents({ section, host, status, type } = {}) {
return list;
}, [all, section, host, status, typeKey]);
- return { events, loading, error };
+ return { events, sections, loading, error };
}
/* Past and upcoming, split. `status` arrives already resolved — the
diff --git a/src/lib/adminSchema.js b/src/lib/adminSchema.js
index 426bc2c..0ab6fba 100644
--- a/src/lib/adminSchema.js
+++ b/src/lib/adminSchema.js
@@ -948,13 +948,14 @@ const timeline = {
the page. Photos and the livestream are editable whatever the
mode, so either can be ready before the switch is flipped.
- Section keys and stat sources are the CHECK lists in migration
- 017. The labels here are what the admin reads; the values are
+ Section keys and stat sources are the CHECK lists in migrations
+ 017 and 019. The labels here are what the admin reads; the values are
what the page and the API key on. */
const FRONT_PAGE_SECTIONS = [
["countdown", "Countdown to the next event"],
["retreats", "National Retreats carousel"],
+ ["calendar", "Event calendar"],
["stats", "Numbers"],
["timeline", "Featured timeline"],
["connect", "Find your way in"],
diff --git a/src/lib/eventSeries.ts b/src/lib/eventSeries.ts
index a5259d5..3acb405 100644
--- a/src/lib/eventSeries.ts
+++ b/src/lib/eventSeries.ts
@@ -171,6 +171,39 @@ export function upcomingOccurrences(
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 ───────────────────────────────────────────────────── */
const ORDINALS = ['', '1st', '2nd', '3rd', '4th', 'last']
diff --git a/src/lib/useFrontPage.ts b/src/lib/useFrontPage.ts
index 5531779..19f7fa9 100644
--- a/src/lib/useFrontPage.ts
+++ b/src/lib/useFrontPage.ts
@@ -16,7 +16,13 @@ import type { EventSeries } from './eventSeries.ts'
export type HeroMode = 'brand' | 'photos' | 'livestream'
/** 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 }
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 75231d7..686b72e 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -27,6 +27,7 @@ import type { ReactNode } from 'react'
import HeroStage from './sections/home/HeroStage.tsx'
import NextEventCountdown from './sections/home/NextEventCountdown.tsx'
import RetreatsBand from './sections/home/RetreatsBand.tsx'
+import CalendarBand from './sections/home/CalendarBand.tsx'
import StatsBand from './sections/home/StatsBand.tsx'
import FeaturedTimelineRail from './sections/home/FeaturedTimelineRail.tsx'
import Pathfinder from './sections/home/Pathfinder.tsx'
@@ -64,6 +65,12 @@ const SECTIONS: Record<
),
},
+ calendar: {
+ title: 'What’s on',
+ render: ({ section, title }) => (
+
+ ),
+ },
stats: {
title: 'By the numbers',
render: ({ page, section, title }) =>
diff --git a/src/pages/sections/EventCalendar.tsx b/src/pages/sections/EventCalendar.tsx
new file mode 100644
index 0000000..fed7343
--- /dev/null
+++ b/src/pages/sections/EventCalendar.tsx
@@ -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.
+
+ 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.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(defaultView)
+ const [selected, setSelected] = useState(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 (
+
+ )
+}
+
+/* ── 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 (
+