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>
259 lines
8.9 KiB
TypeScript
259 lines
8.9 KiB
TypeScript
/* ═══════════════════════════════════════════════════════════════
|
||
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',
|
||
})
|
||
}
|