Add recurring series to events

A Series checkbox under When opens a panel for the schedule:
weekly on chosen weekdays, monthly by date, or monthly by weekday
position, every N weeks or months, with meeting times and an
optional meeting count. starts_on anchors the schedule and ends_on
bounds it, so effective_status needs no change.

Occurrences are derived, not stored. src/lib/eventSeries.ts builds
the schedule label shown on cards and the event page, and the
upcoming dates listed on the event page.

Migration 016 adds the columns; the CRUD engine gains a time type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Zaldimmar 2026-09-25 04:46:49 -05:00
parent aebc1c302b
commit 5ed7994a64
11 changed files with 500 additions and 3 deletions

View file

@ -31,6 +31,7 @@ import {
import { awardHref, personHref, refHref } from '../lib/hrefs.ts'
import { personPhoto } from '../lib/media.ts'
import { eventTypeLabel } from '../lib/eventTypes.ts'
import { occurrenceLabel, seriesLabel, seriesTimes, upcomingOccurrences } from '../lib/eventSeries.ts'
const TEAL = '#138ba0'
const BODY = '#4a6b72'
@ -127,6 +128,46 @@ export default function EventDetail() {
},
]
// A cancelled series has no next meeting, whatever its dates say.
const upcoming =
event.status === 'cancelled'
? []
: upcomingOccurrences(event.series, event.starts_on, event.ends_on)
if (upcoming.length > 0) {
const times = seriesTimes(event.series)
sections.push({
id: 'dates',
title: 'Upcoming dates',
blurb: seriesLabel(event.series, event.starts_on) ?? undefined,
accent,
background: '#eef9fb',
content: (
<div className="max-w-6xl mx-auto px-6">
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{upcoming.map((date) => (
<li
key={date}
className="rounded-xl border-l-4 bg-white px-5 py-3"
style={{ borderColor: accent }}
>
<p className="font-semibold" style={{ color: accent }}>
{occurrenceLabel(date)}
</p>
{times && (
<p className="text-sm" style={{ color: BODY }}>
{times}
</p>
)}
</li>
))}
</ul>
</div>
),
})
}
if (groups.length > 0) {
sections.push({
id: 'people',
@ -197,7 +238,12 @@ function Facts({ event, accent }: { event: EventRecord; accent: string }) {
[event.locality, event.state_code].filter(Boolean).join(', ') ||
(event.is_online ? 'Online' : null)
const when = event.date_label || dateRange(event.starts_on, event.ends_on)
const schedule = seriesLabel(event.series, event.starts_on)
// A series with no label of its own is described by its schedule
// rather than by a start-to-end range that reads like one long
// gathering.
const when =
event.date_label || (schedule ? null : dateRange(event.starts_on, event.ends_on))
return (
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
@ -227,6 +273,7 @@ function Facts({ event, accent }: { event: EventRecord; accent: string }) {
)}
{when && <span style={{ color: BODY }}>{when}</span>}
{schedule && <span style={{ color: BODY }}>{schedule}</span>}
{where && <span style={{ color: BODY }}>{where}</span>}
{event.is_online && where !== 'Online' && (
<span style={{ color: BODY }}>Online too</span>