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 ( +
+ {/* ── Month navigation and view ── */} +
+
+ go(shiftMonth(month, -1))} accent={accent}> + ‹ + + go(shiftMonth(month, 1))} accent={accent}> + › + +
+ +

+ {monthTitle(month)} +

+ + {month !== monthKey(today) && ( + + )} + +
+ {(['month', 'list'] as const).map((option) => ( + + ))} +
+
+ + {/* ── Filters ── */} + {(showScope || showType || show('online') || show('search')) && ( +
+ {showScope && scopes.length > 1 && ( + [s.id, s.name])} + /> + )} + {showType && types.length > 1 && ( + [t.id, t.plural])} + /> + )} + {show('online') && ( + + )} + {show('search') && ( + setQuery(e.target.value)} + placeholder="Search events" + aria-label="Search events" + className="min-w-[12rem] flex-1 rounded-full border border-[#138ba0]/25 bg-white px-4 py-2 text-sm outline-none focus:border-[#138ba0] md:max-w-xs" + /> + )} + {filtering && ( + + )} +
+ )} + + {/* ── Body ── */} +
+ {error ? ( +

+ Couldn’t load events. {error.message} +

+ ) : loading ? ( + +
+ ) +} + +/* ── Month grid ──────────────────────────────────────────────── */ + +type MonthGridProps = { + weeks: string[][] + month: string + today: string + occurrences: Occurrence[] + selected: string | null + onSelect: (date: string) => void + accent: string +} + +function MonthGrid({ weeks, month, today, occurrences, selected, onSelect, accent }: MonthGridProps) { + return ( +
+
+ {WEEKDAYS.map((day) => ( +
+ {day} +
+ ))} +
+ + {weeks.map((week) => { + const segments = layoutWeek(week, occurrences) + const visible = segments.filter((s) => s.lane < MAX_LANES) + const hiddenOn = (col: number) => + segments.filter((s) => s.lane >= MAX_LANES && s.col <= col && s.col + s.span > col).length + const countOn = (col: number) => + segments.filter((s) => s.col <= col && s.col + s.span > col).length + + return ( +
+ {/* Day cells: the click targets, and the numbers. */} +
+ {week.map((date, col) => { + const outside = monthKey(date) !== month + const isToday = date === today + const isSelected = date === selected + const count = countOn(col) + const hidden = hiddenOn(col) + return ( + + ) + })} +
+ + {/* Bars, laid over the cells. Only the bars take clicks. */} +
+ {visible.map((seg) => ( + + ))} +
+
+ ) + })} +
+ ) +} + +function Bar({ seg, accent }: { seg: Segment; accent: string }) { + const color = seg.event.color || accent + const cancelled = seg.event.status === 'cancelled' + + return ( + + {seg.continuesBefore && } + + {seg.time && seg.span === 1 && {seg.time.split(' – ')[0]} } + {seg.event.title} + + {seg.continuesAfter && ( + + )} + + ) +} + +/* ── The selected day ────────────────────────────────────────── */ + +function DayPanel({ + date, + items, + accent, + onClose, +}: { + date: string + items: Occurrence[] + accent: string + onClose: () => void +}) { + return ( +
+
+

{longDay(date)}

+ +
+ {items.length === 0 ? ( +

+ Nothing on this day. +

+ ) : ( +
    + {items.map((item) => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} + +/* ── List view ───────────────────────────────────────────────── */ + +/* The month's occurrences by day. An event that began last month + files under the 1st, where it's still on. */ +function MonthList({ + items, + monthFrom, + accent, +}: { + items: Occurrence[] + monthFrom: string + accent: string +}) { + if (items.length === 0) { + return ( +

+ Nothing on the calendar this month. +

+ ) + } + + const byDay = new Map() + for (const item of items) { + const day = item.start < monthFrom ? monthFrom : item.start + const list = byDay.get(day) + if (list) list.push(item) + else byDay.set(day, [item]) + } + + return ( +
    + {[...byDay.entries()].map(([day, list]) => { + const d = parse(day) + return ( +
  1. +
    +

    + {d.toLocaleDateString(undefined, { weekday: 'short' })} +

    +

    {d.getDate()}

    +
    +
      + {list.map((item) => ( +
    • + +
    • + ))} +
    +
  2. + ) + })} +
+ ) +} + +/* One occurrence as a line of text, shared by the day panel and the + list so the two describe an event the same way. */ +function EventLine({ item, accent }: { item: Occurrence; accent: string }) { + const { event } = item + const color = event.color || accent + const where = event.location_label || (event.is_online ? 'Online' : null) + const cancelled = event.status === 'cancelled' + + return ( + +