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

@ -34,6 +34,7 @@ export class HttpError extends Error {
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
/* Not a value the caller can ever send, so it can mean "leave this
column out of the statement" without colliding with real data. */
@ -98,6 +99,13 @@ function coerceValue(column, raw, errors, prefix = "") {
if (!ISO_DATE.test(value)) errors[key] = "Use YYYY-MM-DD.";
return ISO_DATE.test(value) ? value : null;
}
case "time": {
// <input type="time"> sends HH:MM, or HH:MM:SS when a step
// asks for seconds. Nothing here does, so seconds are dropped.
const value = String(raw).trim().slice(0, 5);
if (!CLOCK_TIME.test(value)) errors[key] = "Use HH:MM, 24-hour.";
return CLOCK_TIME.test(value) ? value : null;
}
default: {
const value = String(raw).trim();
return value === "" ? null : value;

View file

@ -40,6 +40,7 @@ const int = (name, opts = {}) => ({ name, type: "int", ...opts });
const real = (name, opts = {}) => ({ name, type: "real", ...opts });
const bool = (name, opts = {}) => ({ name, type: "bool", ...opts });
const date = (name, opts = {}) => ({ name, type: "date", ...opts });
const time = (name, opts = {}) => ({ name, type: "time", ...opts });
const enumeration = (name, values, opts = {}) => ({
name,
type: "enum",
@ -281,6 +282,10 @@ const organizations = {
/* ── Events ──────────────────────────────────────────────────── */
/* Column suffixes for the series weekday flags, Sunday first to
match Date#getDay. */
const SERIES_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
const events = {
key: "events",
table: "events",
@ -339,6 +344,18 @@ const events = {
bool("is_published"),
int("sort_order"),
bool("in_timeline"),
// A repeating schedule. Columns rather than a side table: the
// schedule is always exactly one per event, and the public view
// is SELECT e.*, so it reaches the site with no join. Ignored
// while is_series is 0. See migration 016 for what each means.
bool("is_series"),
enumeration("series_frequency", ["weekly", "monthly_date", "monthly_weekday"]),
int("series_interval"),
...SERIES_WEEKDAYS.map((day) => bool(`series_${day}`)),
time("series_start_time"),
time("series_end_time"),
int("series_count"),
],
extensions: [timelineExtension("event")],

View file

@ -0,0 +1,73 @@
-- ═══════════════════════════════════════════════════════════════
-- EVENT SERIES
--
-- An event that meets on a schedule — a weekly class, a monthly
-- meeting — is still one row. is_series says the dates repeat; the
-- series_ columns say how. Occurrences are never stored: they are
-- a pure function of these columns plus starts_on and ends_on, and
-- the site works them out when it draws them.
--
-- The event's own dates bound the series. starts_on is the first
-- meeting and anchors everything else: which week an every-other-
-- week series is "on", which day of the month a monthly one keeps,
-- and which weekday it falls on when no day is ticked. ends_on,
-- when set, is the last day it can meet — which is also what keeps
-- effective_status in v_events right with no change to the view.
-- series_count, when set, stops it after that many meetings,
-- whichever comes first.
--
-- series_frequency:
-- weekly on the ticked weekdays, every N weeks
-- monthly_date on starts_on's day of the month (the 13th),
-- every N months; a short month uses its last day
-- monthly_weekday on starts_on's weekday position (2nd Tuesday),
-- every N months; a 5th becomes "last"
--
-- One boolean per weekday rather than a packed text column: each
-- is a checkbox the CRUD engine already knows how to validate and
-- write, and a CHECK can hold it to 0 or 1.
--
-- frequency and interval are NOT NULL with defaults so that a box
-- ticked with nothing else filled in is still a complete schedule —
-- weekly, on starts_on's weekday — and so every existing row gets
-- a valid value without a backfill. They are ignored while
-- is_series is 0.
--
-- Times are 'HH:MM', 24-hour, local to the event. The GLOB is a
-- backstop; the admin engine checks the range before it gets here.
--
-- No change to v_events: it is SELECT e.*, so the columns arrive
-- on /events and /events/:id for free.
--
-- No BEGIN...END in this file, so nothing after it is dropped by
-- the migration runner.
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE events
ADD COLUMN is_series INTEGER NOT NULL DEFAULT 0 CHECK (is_series IN (0, 1));
ALTER TABLE events
ADD COLUMN series_frequency TEXT NOT NULL DEFAULT 'weekly'
CHECK (series_frequency IN ('weekly', 'monthly_date', 'monthly_weekday'));
ALTER TABLE events
ADD COLUMN series_interval INTEGER NOT NULL DEFAULT 1 CHECK (series_interval >= 1);
ALTER TABLE events ADD COLUMN series_sun INTEGER NOT NULL DEFAULT 0 CHECK (series_sun IN (0, 1));
ALTER TABLE events ADD COLUMN series_mon INTEGER NOT NULL DEFAULT 0 CHECK (series_mon IN (0, 1));
ALTER TABLE events ADD COLUMN series_tue INTEGER NOT NULL DEFAULT 0 CHECK (series_tue IN (0, 1));
ALTER TABLE events ADD COLUMN series_wed INTEGER NOT NULL DEFAULT 0 CHECK (series_wed IN (0, 1));
ALTER TABLE events ADD COLUMN series_thu INTEGER NOT NULL DEFAULT 0 CHECK (series_thu IN (0, 1));
ALTER TABLE events ADD COLUMN series_fri INTEGER NOT NULL DEFAULT 0 CHECK (series_fri IN (0, 1));
ALTER TABLE events ADD COLUMN series_sat INTEGER NOT NULL DEFAULT 0 CHECK (series_sat IN (0, 1));
ALTER TABLE events
ADD COLUMN series_start_time TEXT
CHECK (series_start_time GLOB '[0-2][0-9]:[0-5][0-9]');
ALTER TABLE events
ADD COLUMN series_end_time TEXT
CHECK (series_end_time GLOB '[0-2][0-9]:[0-5][0-9]');
ALTER TABLE events
ADD COLUMN series_count INTEGER CHECK (series_count >= 1);

View file

@ -108,6 +108,25 @@ function shapeHost(row) {
};
}
/* The repeating schedule, or null for a one-off. Weekdays collapse
from seven flags to a list of the ticked ones, Sunday first; an
empty list means "starts_on's weekday", which the client resolves
since it already holds starts_on. Occurrences are not sent — they
are derived, and the client derives them against its own today. */
const SERIES_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
function shapeSeries(row) {
if (!asBool(row.is_series)) return null;
return {
frequency: row.series_frequency,
interval: row.series_interval,
weekdays: SERIES_WEEKDAYS.filter((day) => asBool(row[`series_${day}`])),
start_time: row.series_start_time,
end_time: row.series_end_time,
count: row.series_count,
};
}
function shapeEvent(row, links, cardBlocks, hosts = []) {
const { actions, instagram } = splitLinks(links);
@ -124,6 +143,7 @@ function shapeEvent(row, links, cardBlocks, hosts = []) {
ends_on: row.ends_on,
date_label: row.date_label,
status: row.effective_status,
series: shapeSeries(row),
location_label: row.location_label,
locality: row.locality,

View file

@ -225,7 +225,9 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
</div>
) : (
<input
type={widget === "number" ? "number" : widget === "date" ? "date" : "text"}
type={
widget === "number" || widget === "date" || widget === "time" ? widget : "text"
}
step={widget === "number" ? "any" : undefined}
{...common}
readOnly={locked}

View file

@ -27,7 +27,8 @@ export type FieldWidget =
| "checkbox"
| "color"
| "number"
| "date";
| "date"
| "time";
/** A bare value, or [value, label]. */
export type SelectOption = string | readonly [string, string];

View file

@ -326,6 +326,62 @@ const organizations = {
/* ── Events ──────────────────────────────────────────────────── */
/* The panel the Series checkbox opens. Starts and Ends above stay
the series' bounds — the first meeting and the last day it can
meet — so nothing here repeats them. */
const SERIES_WEEKDAYS = [
["sun", "Sunday"],
["mon", "Monday"],
["tue", "Tuesday"],
["wed", "Wednesday"],
["thu", "Thursday"],
["fri", "Friday"],
["sat", "Saturday"],
];
const seriesGroup = {
legend: "Series",
when: { path: "is_series", value: 1 },
note:
"Starts is the first meeting and anchors the schedule; Ends, if set, is the last " +
"day it can meet. The weekdays only apply to a weekly series; with none ticked it " +
"meets on the start date's day. Leave Date label blank and the site describes " +
"the schedule itself.",
fields: [
{
path: "series_frequency",
label: "Repeats",
widget: "select",
options: [
["weekly", "Weekly, on the days ticked below"],
["monthly_date", "Monthly, on the start date's day (the 13th)"],
["monthly_weekday", "Monthly, on the start date's weekday (2nd Tuesday)"],
],
blankLabel: "— weekly —",
},
{
path: "series_interval",
label: "Every",
widget: "number",
help: "1 for every week or month, 2 for every other, and so on",
},
{ path: "series_start_time", label: "Start time", widget: "time" },
{ path: "series_end_time", label: "End time", widget: "time" },
{
path: "series_count",
label: "Number of meetings",
widget: "number",
help: "Stops after this many. Blank to run until Ends, or indefinitely",
},
...SERIES_WEEKDAYS.map(([day, name]) => ({
path: `series_${day}`,
label: name,
widget: "checkbox",
help: `Meets on ${name}s`,
})),
],
};
const events = {
key: "events",
label: "Events",
@ -400,8 +456,15 @@ const events = {
options: ["upcoming", "past", "cancelled"],
blankLabel: "— derive from end date —",
},
{
path: "is_series",
label: "Series",
widget: "checkbox",
help: "Repeats on a schedule — a weekly class, a monthly meeting",
},
],
},
seriesGroup,
{ legend: "Where", fields: PLACE_FIELDS },
{
legend: "Appearance",

259
src/lib/eventSeries.ts Normal file
View file

@ -0,0 +1,259 @@
/* ═══════════════════════════════════════════════════════════════
EVENT SERIES
An event with is_series set meets on a schedule rather than once.
The API sends the schedule as-is (shapeSeries in content.js); this
file is the one place that turns it into words and dates, so the
cards and the detail page can't describe the same series two ways.
Occurrences are derived, never stored. starts_on is the first
meeting and the anchor: it fixes which weeks an every-other-week
series is "on", which day a monthly one keeps, and the weekday
when none is ticked. ends_on, when set, is the last day it can
meet; count, when set, stops it after that many meetings.
Dates are 'YYYY-MM-DD' and are handled as UTC midnights so that
stepping a day never lands on a DST gap. They are calendar dates,
not instants — nothing here converts timezones.
═══════════════════════════════════════════════════════════════ */
export type SeriesFrequency = 'weekly' | 'monthly_date' | 'monthly_weekday'
export type SeriesWeekday = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'
export type EventSeries = {
frequency: SeriesFrequency
interval: number
/** Ticked days, Sunday first. Empty means starts_on's weekday. */
weekdays: SeriesWeekday[]
/** 'HH:MM', 24-hour. */
start_time?: string | null
end_time?: string | null
count?: number | null
}
/** How many upcoming meetings the event page lists. */
export const SERIES_UPCOMING_SHOWN = 6
/* Past this many meetings the walk stops, whatever the schedule
says. Twenty years of a daily-ish weekly series is well inside
it; an open-ended series with a start date decades back is what
it's for. */
const MAX_OCCURRENCES = 5000
/* Index is Date#getUTCDay. */
const WEEKDAYS: SeriesWeekday[] = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
const WEEKDAY_NAMES = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
]
const DAY_MS = 86_400_000
/* ── Dates ───────────────────────────────────────────────────── */
function parseDate(value?: string | null): Date | null {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null
const date = new Date(`${value}T00:00:00Z`)
return Number.isNaN(date.getTime()) ? null : date
}
const isoDate = (date: Date) => date.toISOString().slice(0, 10)
/* The viewer's today, as a calendar date. */
function today(): string {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
const daysInMonth = (year: number, month: number) =>
new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
/* 1st–4th, or 5 for a date in the month's fifth week, which the
schedule treats as "last" — every month has a last Tuesday, not
every month has a fifth. */
const weekOfMonth = (date: Date) => Math.ceil(date.getUTCDate() / 7)
function nthWeekday(year: number, month: number, weekday: number, nth: number): Date {
if (nth >= 5) {
const last = new Date(Date.UTC(year, month, daysInMonth(year, month)))
const back = (last.getUTCDay() - weekday + 7) % 7
return new Date(last.getTime() - back * DAY_MS)
}
const first = new Date(Date.UTC(year, month, 1))
const ahead = (weekday - first.getUTCDay() + 7) % 7
return new Date(Date.UTC(year, month, 1 + ahead + (nth - 1) * 7))
}
/* ── Occurrences ─────────────────────────────────────────────── */
/* Every meeting date in schedule order, lazily, bounded by ends_on,
count and MAX_OCCURRENCES. */
function* occurrences(
series: EventSeries,
startsOn?: string | null,
endsOn?: string | null,
): Generator<string> {
const start = parseDate(startsOn)
if (!start) return
const end = parseDate(endsOn)
const limit = Math.min(series.count ?? MAX_OCCURRENCES, MAX_OCCURRENCES)
const interval = Math.max(1, series.interval || 1)
let emitted = 0
const within = (date: Date) => !end || date.getTime() <= end.getTime()
if (series.frequency === 'weekly') {
const days = new Set(
series.weekdays.length
? series.weekdays.map((day) => WEEKDAYS.indexOf(day))
: [start.getUTCDay()],
)
// Weeks run Sunday to Saturday and are numbered from the one
// starts_on falls in, so "every 2 weeks" means that week, the
// week after next, and so on.
const weekZero = start.getTime() - start.getUTCDay() * DAY_MS
for (let t = start.getTime(); emitted < limit; t += DAY_MS) {
const date = new Date(t)
if (!within(date)) return
const week = Math.floor((t - weekZero) / (7 * DAY_MS))
if (week % interval === 0 && days.has(date.getUTCDay())) {
yield isoDate(date)
emitted += 1
}
}
return
}
const year = start.getUTCFullYear()
const month = start.getUTCMonth()
const day = start.getUTCDate()
const weekday = start.getUTCDay()
const nth = weekOfMonth(start)
for (let step = 0; emitted < limit; step += 1) {
const offset = month + step * interval
const y = year + Math.floor(offset / 12)
const m = offset % 12
const date =
series.frequency === 'monthly_weekday'
? nthWeekday(y, m, weekday, nth)
: new Date(Date.UTC(y, m, Math.min(day, daysInMonth(y, m))))
if (!within(date)) return
yield isoDate(date)
emitted += 1
}
}
/** The next meetings from today on, soonest first. */
export function upcomingOccurrences(
series: EventSeries | null | undefined,
startsOn?: string | null,
endsOn?: string | null,
limit = SERIES_UPCOMING_SHOWN,
): string[] {
if (!series) return []
const from = today()
const out: string[] = []
for (const date of occurrences(series, startsOn, endsOn)) {
if (date < from) continue
out.push(date)
if (out.length >= limit) break
}
return out
}
/* ── Words ───────────────────────────────────────────────────── */
const ORDINALS = ['', '1st', '2nd', '3rd', '4th', 'last']
function ordinalDay(n: number): string {
const tens = n % 100
if (tens >= 11 && tens <= 13) return `${n}th`
return `${n}${['th', 'st', 'nd', 'rd'][n % 10] ?? 'th'}`
}
function joinWords(words: string[]): string {
if (words.length <= 1) return words[0] ?? ''
return `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`
}
function clock(value?: string | null): string | null {
const match = value?.match(/^(\d{2}):(\d{2})$/)
if (!match) return null
const date = new Date(Date.UTC(2000, 0, 1, Number(match[1]), Number(match[2])))
return date.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
timeZone: 'UTC',
})
}
/** "7:00 PM – 8:30 PM", "7:00 PM", or null. */
export function seriesTimes(series: EventSeries | null | undefined): string | null {
if (!series) return null
const from = clock(series.start_time)
const to = clock(series.end_time)
if (from && to) return `${from} – ${to}`
return from ?? to
}
/**
* "Every Tuesday and Thursday, 7:00 PM – 8:30 PM",
* "Every 2 weeks on Monday", "Monthly on the 2nd Tuesday",
* "Every 3 months on the 13th". Null for a one-off event, or a
* series with no start date to anchor it.
*/
export function seriesLabel(
series: EventSeries | null | undefined,
startsOn?: string | null,
): string | null {
const start = parseDate(startsOn)
if (!series || !start) return null
const interval = Math.max(1, series.interval || 1)
let pattern: string
if (series.frequency === 'weekly') {
const days = series.weekdays.length
? series.weekdays.map((day) => WEEKDAY_NAMES[WEEKDAYS.indexOf(day)])
: [WEEKDAY_NAMES[start.getUTCDay()]]
pattern =
interval === 1
? `Every ${joinWords(days)}`
: `Every ${interval === 2 ? 'other week' : `${interval} weeks`} on ${joinWords(days)}`
} else {
const on =
series.frequency === 'monthly_weekday'
? `the ${ORDINALS[weekOfMonth(start)]} ${WEEKDAY_NAMES[start.getUTCDay()]}`
: `the ${ordinalDay(start.getUTCDate())}`
pattern =
interval === 1
? `Monthly on ${on}`
: `Every ${interval === 2 ? 'other month' : `${interval} months`} on ${on}`
}
const times = seriesTimes(series)
return times ? `${pattern}, ${times}` : pattern
}
/** '2026-10-13' → 'Tue, Oct 13, 2026'. */
export function occurrenceLabel(date: string): string {
const parsed = parseDate(date)
if (!parsed) return date
return parsed.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
})
}

View file

@ -14,6 +14,7 @@
import { detailPath } from './hrefs.ts'
import { useRecord, type Resource } from './useRecord.ts'
import type { EventType } from './eventTypes.ts'
import type { EventSeries } from './eventSeries.ts'
/* ── Shared shapes ───────────────────────────────────────────── */
@ -89,6 +90,8 @@ export type EventRecord = {
ends_on?: string | null
date_label?: string | null
status: 'upcoming' | 'past' | 'cancelled'
/** The repeating schedule, or null for a one-off. */
series: EventSeries | null
location_label?: string | null
locality?: string | null
state_code?: string | null

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>

View file

@ -8,6 +8,7 @@ import {
} from "../../data/eventData.js";
import { eventHref } from "../../lib/hrefs.ts";
import { EVENT_TYPES, eventTypeLabel, type EventType } from "../../lib/eventTypes.ts";
import { seriesLabel } from "../../lib/eventSeries.ts";
import type { EventListItem } from "../../lib/useContent.ts";
import type { SectionToggleProps } from "../../lib/sections.tsx";
@ -195,6 +196,7 @@ export function Card({
const color = ev.color || defaultColor;
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
const eventLogo = ev.event_logo;
const schedule = seriesLabel(ev.series, ev.starts_on);
const links = ev.links ?? [];
const igHandle = ev.instagram || null;
const igUrl = igHandle
@ -255,6 +257,7 @@ export function Card({
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
)}
{ev.date_label && <p className="text-xl">{ev.date_label}</p>}
{schedule && <p className="text-xl">{schedule}</p>}
{ev.location_label && (
<p className="text-xl">{ev.location_label}</p>
)}
@ -282,6 +285,7 @@ export function Card({
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
)}
{ev.date_label && <p className="text-2xl">{ev.date_label}</p>}
{schedule && <p className="text-2xl">{schedule}</p>}
{ev.location_label && (
<p className="text-2xl">{ev.location_label}</p>
)}