From 5ed7994a642bbe6e1fef9da29babcaed55a66b84 Mon Sep 17 00:00:00 2001 From: Zaldimmar Date: Fri, 25 Sep 2026 04:46:49 -0500 Subject: [PATCH] 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 --- server/src/admin-crud.js | 8 + server/src/admin-schema.js | 17 ++ server/src/migrations/016_event-series.sql | 73 ++++++ server/src/routes/content.js | 20 ++ src/components/admin/fields.tsx | 4 +- src/lib/adminSchema.d.ts | 3 +- src/lib/adminSchema.js | 63 +++++ src/lib/eventSeries.ts | 259 +++++++++++++++++++++ src/lib/useContent.ts | 3 + src/pages/EventDetail.tsx | 49 +++- src/pages/sections/EventList-Cards.tsx | 4 + 11 files changed, 500 insertions(+), 3 deletions(-) create mode 100644 server/src/migrations/016_event-series.sql create mode 100644 src/lib/eventSeries.ts diff --git a/server/src/admin-crud.js b/server/src/admin-crud.js index d0fe17d..79d6c34 100644 --- a/server/src/admin-crud.js +++ b/server/src/admin-crud.js @@ -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": { + // 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; diff --git a/server/src/admin-schema.js b/server/src/admin-schema.js index f1ce448..0eb4b4a 100644 --- a/server/src/admin-schema.js +++ b/server/src/admin-schema.js @@ -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")], diff --git a/server/src/migrations/016_event-series.sql b/server/src/migrations/016_event-series.sql new file mode 100644 index 0000000..b766eb1 --- /dev/null +++ b/server/src/migrations/016_event-series.sql @@ -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); diff --git a/server/src/routes/content.js b/server/src/routes/content.js index 87aab06..02a860e 100644 --- a/server/src/routes/content.js +++ b/server/src/routes/content.js @@ -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, diff --git a/src/components/admin/fields.tsx b/src/components/admin/fields.tsx index c56f092..d3a761b 100644 --- a/src/components/admin/fields.tsx +++ b/src/components/admin/fields.tsx @@ -225,7 +225,9 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp ) : ( ({ + 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", diff --git a/src/lib/eventSeries.ts b/src/lib/eventSeries.ts new file mode 100644 index 0000000..a5259d5 --- /dev/null +++ b/src/lib/eventSeries.ts @@ -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 { + 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', + }) +} diff --git a/src/lib/useContent.ts b/src/lib/useContent.ts index 1753f6a..025dc94 100644 --- a/src/lib/useContent.ts +++ b/src/lib/useContent.ts @@ -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 diff --git a/src/pages/EventDetail.tsx b/src/pages/EventDetail.tsx index 87a2ace..f1fe27d 100644 --- a/src/pages/EventDetail.tsx +++ b/src/pages/EventDetail.tsx @@ -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: ( +
+
    + {upcoming.map((date) => ( +
  • +

    + {occurrenceLabel(date)} +

    + {times && ( +

    + {times} +

    + )} +
  • + ))} +
+
+ ), + }) + } + 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 (
@@ -227,6 +273,7 @@ function Facts({ event, accent }: { event: EventRecord; accent: string }) { )} {when && {when}} + {schedule && {schedule}} {where && {where}} {event.is_online && where !== 'Online' && ( Online too diff --git a/src/pages/sections/EventList-Cards.tsx b/src/pages/sections/EventList-Cards.tsx index 927900b..bacd614 100644 --- a/src/pages/sections/EventList-Cards.tsx +++ b/src/pages/sections/EventList-Cards.tsx @@ -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({

"{ev.theme}"

)} {ev.date_label &&

{ev.date_label}

} + {schedule &&

{schedule}

} {ev.location_label && (

{ev.location_label}

)} @@ -282,6 +285,7 @@ export function Card({

"{ev.theme}"

)} {ev.date_label &&

{ev.date_label}

} + {schedule &&

{schedule}

} {ev.location_label && (

{ev.location_label}

)}