Add recurring series to events #5
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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
3
src/lib/adminSchema.d.ts
vendored
3
src/lib/adminSchema.d.ts
vendored
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
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 { 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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue