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
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue