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