Merge pull request 'Add recurring series to events' (#5) from feature/event-series into main
Reviewed-on: #5 Tested page. Works.
This commit is contained in:
commit
300c9b74f0
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 SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
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
|
/* Not a value the caller can ever send, so it can mean "leave this
|
||||||
column out of the statement" without colliding with real data. */
|
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.";
|
if (!ISO_DATE.test(value)) errors[key] = "Use YYYY-MM-DD.";
|
||||||
return ISO_DATE.test(value) ? value : null;
|
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: {
|
default: {
|
||||||
const value = String(raw).trim();
|
const value = String(raw).trim();
|
||||||
return value === "" ? null : value;
|
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 real = (name, opts = {}) => ({ name, type: "real", ...opts });
|
||||||
const bool = (name, opts = {}) => ({ name, type: "bool", ...opts });
|
const bool = (name, opts = {}) => ({ name, type: "bool", ...opts });
|
||||||
const date = (name, opts = {}) => ({ name, type: "date", ...opts });
|
const date = (name, opts = {}) => ({ name, type: "date", ...opts });
|
||||||
|
const time = (name, opts = {}) => ({ name, type: "time", ...opts });
|
||||||
const enumeration = (name, values, opts = {}) => ({
|
const enumeration = (name, values, opts = {}) => ({
|
||||||
name,
|
name,
|
||||||
type: "enum",
|
type: "enum",
|
||||||
|
|
@ -281,6 +282,10 @@ const organizations = {
|
||||||
|
|
||||||
/* ── Events ──────────────────────────────────────────────────── */
|
/* ── 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 = {
|
const events = {
|
||||||
key: "events",
|
key: "events",
|
||||||
table: "events",
|
table: "events",
|
||||||
|
|
@ -339,6 +344,18 @@ const events = {
|
||||||
bool("is_published"),
|
bool("is_published"),
|
||||||
int("sort_order"),
|
int("sort_order"),
|
||||||
bool("in_timeline"),
|
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")],
|
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 = []) {
|
function shapeEvent(row, links, cardBlocks, hosts = []) {
|
||||||
const { actions, instagram } = splitLinks(links);
|
const { actions, instagram } = splitLinks(links);
|
||||||
|
|
||||||
|
|
@ -124,6 +143,7 @@ function shapeEvent(row, links, cardBlocks, hosts = []) {
|
||||||
ends_on: row.ends_on,
|
ends_on: row.ends_on,
|
||||||
date_label: row.date_label,
|
date_label: row.date_label,
|
||||||
status: row.effective_status,
|
status: row.effective_status,
|
||||||
|
series: shapeSeries(row),
|
||||||
|
|
||||||
location_label: row.location_label,
|
location_label: row.location_label,
|
||||||
locality: row.locality,
|
locality: row.locality,
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,9 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
type={widget === "number" ? "number" : widget === "date" ? "date" : "text"}
|
type={
|
||||||
|
widget === "number" || widget === "date" || widget === "time" ? widget : "text"
|
||||||
|
}
|
||||||
step={widget === "number" ? "any" : undefined}
|
step={widget === "number" ? "any" : undefined}
|
||||||
{...common}
|
{...common}
|
||||||
readOnly={locked}
|
readOnly={locked}
|
||||||
|
|
|
||||||
3
src/lib/adminSchema.d.ts
vendored
3
src/lib/adminSchema.d.ts
vendored
|
|
@ -27,7 +27,8 @@ export type FieldWidget =
|
||||||
| "checkbox"
|
| "checkbox"
|
||||||
| "color"
|
| "color"
|
||||||
| "number"
|
| "number"
|
||||||
| "date";
|
| "date"
|
||||||
|
| "time";
|
||||||
|
|
||||||
/** A bare value, or [value, label]. */
|
/** A bare value, or [value, label]. */
|
||||||
export type SelectOption = string | readonly [string, string];
|
export type SelectOption = string | readonly [string, string];
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,62 @@ const organizations = {
|
||||||
|
|
||||||
/* ── Events ──────────────────────────────────────────────────── */
|
/* ── 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 = {
|
const events = {
|
||||||
key: "events",
|
key: "events",
|
||||||
label: "Events",
|
label: "Events",
|
||||||
|
|
@ -400,8 +456,15 @@ const events = {
|
||||||
options: ["upcoming", "past", "cancelled"],
|
options: ["upcoming", "past", "cancelled"],
|
||||||
blankLabel: "— derive from end date —",
|
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: "Where", fields: PLACE_FIELDS },
|
||||||
{
|
{
|
||||||
legend: "Appearance",
|
legend: "Appearance",
|
||||||
|
|
|
||||||
259
src/lib/eventSeries.ts
Normal file
259
src/lib/eventSeries.ts
Normal 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
import { detailPath } from './hrefs.ts'
|
import { detailPath } from './hrefs.ts'
|
||||||
import { useRecord, type Resource } from './useRecord.ts'
|
import { useRecord, type Resource } from './useRecord.ts'
|
||||||
import type { EventType } from './eventTypes.ts'
|
import type { EventType } from './eventTypes.ts'
|
||||||
|
import type { EventSeries } from './eventSeries.ts'
|
||||||
|
|
||||||
/* ── Shared shapes ───────────────────────────────────────────── */
|
/* ── Shared shapes ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
|
@ -89,6 +90,8 @@ export type EventRecord = {
|
||||||
ends_on?: string | null
|
ends_on?: string | null
|
||||||
date_label?: string | null
|
date_label?: string | null
|
||||||
status: 'upcoming' | 'past' | 'cancelled'
|
status: 'upcoming' | 'past' | 'cancelled'
|
||||||
|
/** The repeating schedule, or null for a one-off. */
|
||||||
|
series: EventSeries | null
|
||||||
location_label?: string | null
|
location_label?: string | null
|
||||||
locality?: string | null
|
locality?: string | null
|
||||||
state_code?: string | null
|
state_code?: string | null
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
import { awardHref, personHref, refHref } from '../lib/hrefs.ts'
|
import { awardHref, personHref, refHref } from '../lib/hrefs.ts'
|
||||||
import { personPhoto } from '../lib/media.ts'
|
import { personPhoto } from '../lib/media.ts'
|
||||||
import { eventTypeLabel } from '../lib/eventTypes.ts'
|
import { eventTypeLabel } from '../lib/eventTypes.ts'
|
||||||
|
import { occurrenceLabel, seriesLabel, seriesTimes, upcomingOccurrences } from '../lib/eventSeries.ts'
|
||||||
|
|
||||||
const TEAL = '#138ba0'
|
const TEAL = '#138ba0'
|
||||||
const BODY = '#4a6b72'
|
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) {
|
if (groups.length > 0) {
|
||||||
sections.push({
|
sections.push({
|
||||||
id: 'people',
|
id: 'people',
|
||||||
|
|
@ -197,7 +238,12 @@ function Facts({ event, accent }: { event: EventRecord; accent: string }) {
|
||||||
[event.locality, event.state_code].filter(Boolean).join(', ') ||
|
[event.locality, event.state_code].filter(Boolean).join(', ') ||
|
||||||
(event.is_online ? 'Online' : null)
|
(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 (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
|
<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>}
|
{when && <span style={{ color: BODY }}>{when}</span>}
|
||||||
|
{schedule && <span style={{ color: BODY }}>{schedule}</span>}
|
||||||
{where && <span style={{ color: BODY }}>{where}</span>}
|
{where && <span style={{ color: BODY }}>{where}</span>}
|
||||||
{event.is_online && where !== 'Online' && (
|
{event.is_online && where !== 'Online' && (
|
||||||
<span style={{ color: BODY }}>Online too</span>
|
<span style={{ color: BODY }}>Online too</span>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from "../../data/eventData.js";
|
} from "../../data/eventData.js";
|
||||||
import { eventHref } from "../../lib/hrefs.ts";
|
import { eventHref } from "../../lib/hrefs.ts";
|
||||||
import { EVENT_TYPES, eventTypeLabel, type EventType } from "../../lib/eventTypes.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 { EventListItem } from "../../lib/useContent.ts";
|
||||||
import type { SectionToggleProps } from "../../lib/sections.tsx";
|
import type { SectionToggleProps } from "../../lib/sections.tsx";
|
||||||
|
|
||||||
|
|
@ -195,6 +196,7 @@ export function Card({
|
||||||
const color = ev.color || defaultColor;
|
const color = ev.color || defaultColor;
|
||||||
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
|
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
|
||||||
const eventLogo = ev.event_logo;
|
const eventLogo = ev.event_logo;
|
||||||
|
const schedule = seriesLabel(ev.series, ev.starts_on);
|
||||||
const links = ev.links ?? [];
|
const links = ev.links ?? [];
|
||||||
const igHandle = ev.instagram || null;
|
const igHandle = ev.instagram || null;
|
||||||
const igUrl = igHandle
|
const igUrl = igHandle
|
||||||
|
|
@ -255,6 +257,7 @@ export function Card({
|
||||||
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
|
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
|
||||||
)}
|
)}
|
||||||
{ev.date_label && <p className="text-xl">{ev.date_label}</p>}
|
{ev.date_label && <p className="text-xl">{ev.date_label}</p>}
|
||||||
|
{schedule && <p className="text-xl">{schedule}</p>}
|
||||||
{ev.location_label && (
|
{ev.location_label && (
|
||||||
<p className="text-xl">{ev.location_label}</p>
|
<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>
|
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
|
||||||
)}
|
)}
|
||||||
{ev.date_label && <p className="text-2xl">{ev.date_label}</p>}
|
{ev.date_label && <p className="text-2xl">{ev.date_label}</p>}
|
||||||
|
{schedule && <p className="text-2xl">{schedule}</p>}
|
||||||
{ev.location_label && (
|
{ev.location_label && (
|
||||||
<p className="text-2xl">{ev.location_label}</p>
|
<p className="text-2xl">{ev.location_label}</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue