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:
Zaldimmar 2026-09-25 04:46:49 -05:00
parent aebc1c302b
commit 5ed7994a64
11 changed files with 500 additions and 3 deletions

259
src/lib/eventSeries.ts Normal file
View 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',
})
}