/* ═══════════════════════════════════════════════════════════════ 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 { 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 } /** Every meeting between two dates, inclusive, in order. For a * calendar page: `from` and `to` are the visible range. */ export function occurrencesBetween( series: EventSeries | null | undefined, startsOn: string | null | undefined, endsOn: string | null | undefined, from: string, to: string, ): string[] { if (!series) return [] const out: string[] = [] for (const date of occurrences(series, startsOn, endsOn)) { if (date > to) break if (date >= from) out.push(date) } return out } /** The first meeting on or after `from`, or null if the series has * ended by then. */ export function firstOccurrenceFrom( series: EventSeries | null | undefined, startsOn: string | null | undefined, endsOn: string | null | undefined, from: string, ): string | null { if (!series) return null for (const date of occurrences(series, startsOn, endsOn)) { if (date >= from) return date } return null } /* ── 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', }) }