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:
parent
aebc1c302b
commit
5ed7994a64
11 changed files with 500 additions and 3 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")],
|
||||
|
|
|
|||
73
server/src/migrations/016_event-series.sql
Normal file
73
server/src/migrations/016_event-series.sql
Normal 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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue