feature: monthly recurring

This commit is contained in:
Benno Tielen 2026-04-23 15:05:04 +02:00
parent f4afd2ff77
commit 0cfdee60ed
16 changed files with 26062 additions and 47 deletions

View file

@ -0,0 +1,55 @@
'use client'
import type { SelectFieldClientComponent } from 'payload'
import { SelectField, useFormFields } from '@payloadcms/ui'
const WEEKDAYS_DE = [
'Sonntag',
'Montag',
'Dienstag',
'Mittwoch',
'Donnerstag',
'Freitag',
'Samstag',
]
// We clamp 5th-occurrence dates (e.g. the 5th Monday of March) to "4." to
// match the clamp in src/jobs/lib/eventRecurrence.ts (weekOfMonthOf). The
// label and the generated rule must agree on which occurrence the event
// represents.
const weekOfMonthOrdinal = (d: Date): number => Math.min(Math.ceil(d.getDate() / 7), 4)
const parseDate = (raw: unknown): Date | null => {
if (!raw || typeof raw !== 'string') return null
const d = new Date(raw)
return Number.isNaN(d.getTime()) ? null : d
}
export const RecurrenceTypeField: SelectFieldClientComponent = (props) => {
const dateValue = useFormFields(([fields]) => fields?.date?.value)
const d = parseDate(dateValue)
const byDateLabel = d
? `Monatlich am ${d.getDate()}.`
: 'Monatlich (Kalendertag)'
const byWeekdayLabel = d
? `Monatlich am ${weekOfMonthOrdinal(d)}. ${WEEKDAYS_DE[d.getDay()]}`
: 'Monatlich (n-ter Wochentag)'
const field = {
...props.field,
options: [
{ label: 'Einmalig', value: 'none' },
{ label: 'Täglich', value: 'daily' },
{ label: 'Wöchentlich', value: 'weekly' },
{ label: 'Alle 2 Wochen', value: 'biweekly' },
{ label: byDateLabel, value: 'monthlyByDate' },
{ label: byWeekdayLabel, value: 'monthlyByWeekday' },
{ label: 'Benutzerdefiniert (mehrere Regeln)', value: 'custom' },
],
}
return <SelectField {...props} field={field} />
}
export default RecurrenceTypeField

View file

@ -12,6 +12,7 @@ import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1e
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RecurrenceTypeField as RecurrenceTypeField_140d3880e55fb539df997bcab97abf93 } from '@/admin/components/RecurrenceTypeField/RecurrenceTypeField'
import { NextOccurrencesTable as NextOccurrencesTable_5b561f1308e1f6674a6813c6174350fc } from '@/admin/components/NextOccurrencesTable/NextOccurrencesTable'
import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
import { ReindexButton as ReindexButton_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
@ -34,6 +35,7 @@ export const importMap = {
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@/admin/components/RecurrenceTypeField/RecurrenceTypeField#RecurrenceTypeField": RecurrenceTypeField_140d3880e55fb539df997bcab97abf93,
"@/admin/components/NextOccurrencesTable/NextOccurrencesTable#NextOccurrencesTable": NextOccurrencesTable_5b561f1308e1f6674a6813c6174350fc,
"@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
"@payloadcms/plugin-search/client#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,

View file

@ -194,14 +194,122 @@ export const Events: CollectionConfig = {
},
options: [
{ label: 'Einmalig', value: 'none' },
{ label: 'Täglich', value: 'daily' },
{ label: 'Wöchentlich', value: 'weekly' },
{ label: 'Alle 2 Wochen', value: 'biweekly' },
{ label: 'Monatlich (gleicher Kalendertag)', value: 'monthlyByDate' },
{ label: 'Monatlich (n-ter Wochentag)', value: 'monthlyByWeekday' },
{ label: 'Benutzerdefiniert (mehrere Regeln)', value: 'custom' },
],
admin: {
description:
'Bei wiederkehrenden Veranstaltungen werden automatisch Termine für die nächsten Wochen erzeugt. Wochentag und Uhrzeit werden aus dem Datumsfeld übernommen.',
'Bei wiederkehrenden Veranstaltungen werden automatisch Termine für die nächsten Wochen erzeugt. Wochentag, Kalendertag und Uhrzeit werden aus dem Datumsfeld übernommen.',
components: {
Field:
'@/admin/components/RecurrenceTypeField/RecurrenceTypeField#RecurrenceTypeField',
},
},
},
{
name: 'recurrenceRules',
type: 'array',
label: { de: 'Wiederholungsregeln' },
labels: {
singular: { de: 'Regel' },
plural: { de: 'Regeln' },
},
admin: {
condition: (_data, siblingData) =>
siblingData?.recurrenceType === 'custom',
description:
'Mehrere Regeln werden kombiniert (z. B. „3. Dienstag und 4. Donnerstag jeden Monat").',
},
validate: (value, { siblingData }: { siblingData: any }) => {
if (siblingData?.recurrenceType !== 'custom') return true
if (!Array.isArray(value) || value.length === 0) {
return 'Mindestens eine Regel erforderlich, wenn Wiederholung = Benutzerdefiniert.'
}
const rules = value as Array<{
frequency?: string
weekday?: string | null
weekOfMonth?: string | null
dayOfMonth?: number | null
}>
for (let i = 0; i < rules.length; i++) {
const r = rules[i]
if (r?.frequency === 'weekly' && !r.weekday) {
return `Regel ${i + 1}: Wochentag fehlt.`
}
if (r?.frequency === 'monthlyByWeekday' && (!r.weekday || !r.weekOfMonth)) {
return `Regel ${i + 1}: Wochentag und Position im Monat sind erforderlich.`
}
if (r?.frequency === 'monthlyByDate' && typeof r.dayOfMonth !== 'number') {
return `Regel ${i + 1}: Kalendertag fehlt.`
}
}
return true
},
fields: [
{
name: 'frequency',
type: 'select',
required: true,
label: { de: 'Art' },
options: [
{ label: 'Täglich', value: 'daily' },
{ label: 'Wöchentlich', value: 'weekly' },
{ label: 'Monatlich (Kalendertag)', value: 'monthlyByDate' },
{ label: 'Monatlich (n-ter Wochentag)', value: 'monthlyByWeekday' },
],
},
{
name: 'weekday',
type: 'select',
label: { de: 'Wochentag' },
options: [
{ label: 'Montag', value: 'monday' },
{ label: 'Dienstag', value: 'tuesday' },
{ label: 'Mittwoch', value: 'wednesday' },
{ label: 'Donnerstag', value: 'thursday' },
{ label: 'Freitag', value: 'friday' },
{ label: 'Samstag', value: 'saturday' },
{ label: 'Sonntag', value: 'sunday' },
],
admin: {
condition: (_data, siblingData) =>
siblingData?.frequency === 'weekly' ||
siblingData?.frequency === 'monthlyByWeekday',
},
},
{
name: 'weekOfMonth',
type: 'select',
label: { de: 'Position im Monat' },
options: [
{ label: '1.', value: 'first' },
{ label: '2.', value: 'second' },
{ label: '3.', value: 'third' },
{ label: '4.', value: 'fourth' },
{ label: 'Letzter', value: 'last' },
],
admin: {
condition: (_data, siblingData) =>
siblingData?.frequency === 'monthlyByWeekday',
},
},
{
name: 'dayOfMonth',
type: 'number',
label: { de: 'Kalendertag' },
min: 1,
max: 31,
admin: {
condition: (_data, siblingData) =>
siblingData?.frequency === 'monthlyByDate',
},
},
],
},
{
name: 'endDate',
type: 'date',

View file

@ -1,5 +1,10 @@
import type { PayloadRequest, TaskConfig } from 'payload'
import type { Payload } from 'payload'
import {
expandEventOccurrences,
type EventRecurrenceType,
type RecurrenceRule,
} from '@/jobs/lib/eventRecurrence'
/**
* Materializes each Event's recurrence rule into `eventOccurrence` rows for
@ -10,9 +15,10 @@ import type { Payload } from 'payload'
* is no editor-edited state to preserve rule edits and recurringonce
* transitions stay trivially correct.
*
* Weekday and time-of-day come directly from `event.date`. We step by
* whole calendar days (`setDate(getDate() + N)`) so DST transitions don't
* shift the wall-clock time.
* Weekday and time-of-day come directly from `event.date`. Calendar-date
* generation lives in scheduleOccurrences.ts (DST-safe by construction:
* dates are built at local midnight and then stitched with the event's
* HH:mm).
*
* Exposed both as a Payload Jobs Queue task (weekly cron backfill) and as a
* direct function called from the Events `afterChange` hook so edits are
@ -36,16 +42,11 @@ type GenerateEventOccurrencesOutput = {
type EventLike = {
id: string
date?: string | null
recurrenceType?: 'none' | 'weekly' | 'biweekly' | null
recurrenceType?: EventRecurrenceType | null
recurrenceRules?: RecurrenceRule[] | null
endDate?: string | null
}
const stepDaysFor = (recurrenceType: string): number | null => {
if (recurrenceType === 'weekly') return 7
if (recurrenceType === 'biweekly') return 14
return null
}
// Treat endDate as inclusive of the whole local day.
const endOfLocalDay = (iso: string): Date => {
const d = new Date(iso)
@ -69,7 +70,7 @@ export const regenerateOccurrencesForEvent = async ({
// Snapshot cancelled dates so manual cancellations survive the wipe/regen.
// Matching by ISO date works because regen derives timestamps from the same
// event.date stepped by whole calendar days — shifts in event.date
// event.date stepped by whole calendar days — shi)fts in event.date
// intentionally drop stale cancellations.
const existing = await payload.find({
collection: 'eventOccurrence',
@ -131,34 +132,24 @@ export const regenerateOccurrencesForEvent = async ({
return { created: created + 1, deleted, skipped }
}
const step = stepDaysFor(recurrenceType)
if (step === null) {
return { created, deleted, skipped: skipped + 1 }
}
const effectiveEnd = event.endDate
? new Date(Math.min(horizon.getTime(), endOfLocalDay(event.endDate).getTime()))
: horizon
const cursor = new Date(eventDate)
while (cursor.getTime() <= effectiveEnd.getTime()) {
if (cursor.getTime() >= now.getTime()) {
const iso = cursor.toISOString()
await payload.create({
collection: 'eventOccurrence',
data: {
event: event.id,
date: iso,
cancelled: cancelledDates.has(iso),
generated: true,
},
req,
})
created += 1
} else {
skipped += 1
}
cursor.setDate(cursor.getDate() + step)
const dates = expandEventOccurrences(event, { now, horizon: effectiveEnd })
for (const date of dates) {
const iso = date.toISOString()
await payload.create({
collection: 'eventOccurrence',
data: {
event: event.id,
date: iso,
cancelled: cancelledDates.has(iso),
generated: true,
},
req,
})
created += 1
}
return { created, deleted, skipped }

View file

@ -0,0 +1,317 @@
import { describe, it, expect } from 'vitest'
import { expandEventOccurrences, type RecurrenceRule } from './eventRecurrence'
// Local-time Date factory (matches scheduleOccurrences.test.ts). Months
// are 0-indexed in the Date constructor, 1-indexed here.
const dt = (
year: number,
month1to12: number,
day: number,
hours = 0,
minutes = 0,
): Date => new Date(year, month1to12 - 1, day, hours, minutes, 0, 0)
// Reference calendar:
// April 2026 — Wed 1; Thu 2, 9, 16, 23, 30; Tue 7, 14, 21, 28; Mon 6, 13, 20, 27; Sun 5, 12, 19, 26
// May 2026 — Tue 5, 12, 19, 26; Thu 7, 14, 21, 28; Mon 4, 11, 18, 25
// June 2026 — Tue 2, 9, 16, 23, 30; Thu 4, 11, 18, 25; Mon 1, 8, 15, 22, 29
// March 2026 — Mon 2, 9, 16, 23, 30 (five Mondays)
describe('expandEventOccurrences', () => {
it('returns [] for recurrenceType = none', () => {
const result = expandEventOccurrences(
{ date: dt(2026, 5, 1, 18, 30).toISOString(), recurrenceType: 'none' },
{ now: dt(2026, 4, 1), horizon: dt(2026, 7, 1) },
)
expect(result).toEqual([])
})
it('returns [] when event.date is missing or invalid', () => {
expect(
expandEventOccurrences(
{ recurrenceType: 'weekly' },
{ now: dt(2026, 4, 1), horizon: dt(2026, 5, 1) },
),
).toEqual([])
expect(
expandEventOccurrences(
{ date: 'not-a-date', recurrenceType: 'weekly' },
{ now: dt(2026, 4, 1), horizon: dt(2026, 5, 1) },
),
).toEqual([])
})
describe('daily', () => {
it('yields every day from event.date to horizon at the event HH:mm', () => {
const eventDate = dt(2026, 4, 10, 18, 30) // Fri
const result = expandEventOccurrences(
{ date: eventDate.toISOString(), recurrenceType: 'daily' },
{ now: dt(2026, 4, 1), horizon: dt(2026, 4, 14, 23, 59) },
)
expect(result.map((d) => d.getDate())).toEqual([10, 11, 12, 13, 14])
for (const d of result) {
expect(d.getHours()).toBe(18)
expect(d.getMinutes()).toBe(30)
}
})
it('starts from now when event.date is before now', () => {
// Past event.date — enumeration should start at `now`, not backfill.
const eventDate = dt(2026, 4, 1, 9, 0)
const result = expandEventOccurrences(
{ date: eventDate.toISOString(), recurrenceType: 'daily' },
{ now: dt(2026, 4, 10, 8, 0), horizon: dt(2026, 4, 12, 23, 59) },
)
// `after` is now=04-10 08:00; daily occurrences at 09:00 fall on
// the 10th, 11th, 12th.
expect(result.map((d) => `${d.getDate()} ${d.getHours()}:${d.getMinutes()}`))
.toEqual(['10 9:0', '11 9:0', '12 9:0'])
})
it('returns [] when horizon < after', () => {
const eventDate = dt(2026, 4, 10, 18, 30)
const result = expandEventOccurrences(
{ date: eventDate.toISOString(), recurrenceType: 'daily' },
{ now: dt(2026, 4, 1), horizon: dt(2026, 4, 9) },
)
expect(result).toEqual([])
})
})
describe('weekly', () => {
it('derives the weekday from event.date', () => {
// 2026-04-07 is a Tuesday
const eventDate = dt(2026, 4, 7, 19, 0)
const result = expandEventOccurrences(
{ date: eventDate.toISOString(), recurrenceType: 'weekly' },
{ now: dt(2026, 4, 1), horizon: dt(2026, 4, 30) },
)
expect(result.map((d) => d.getDate())).toEqual([7, 14, 21, 28])
for (const d of result) {
expect(d.getDay()).toBe(2) // Tuesday
expect(d.getHours()).toBe(19)
expect(d.getMinutes()).toBe(0)
}
})
})
describe('biweekly', () => {
it('anchors parity on event.date', () => {
const eventDate = dt(2026, 4, 7, 19, 0) // Tuesday
const result = expandEventOccurrences(
{ date: eventDate.toISOString(), recurrenceType: 'biweekly' },
{ now: dt(2026, 4, 1), horizon: dt(2026, 6, 30, 23, 59) },
)
// From 04-07: 04-07, 04-21, 05-05, 05-19, 06-02, 06-16, 06-30
expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
'4-7',
'4-21',
'5-5',
'5-19',
'6-2',
'6-16',
'6-30',
])
})
})
describe('monthlyByDate', () => {
it('uses event.date.getDate() as the day-of-month', () => {
const eventDate = dt(2026, 4, 13, 18, 30)
const result = expandEventOccurrences(
{ date: eventDate.toISOString(), recurrenceType: 'monthlyByDate' },
{ now: dt(2026, 4, 1), horizon: dt(2026, 7, 31) },
)
expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
'4-13',
'5-13',
'6-13',
'7-13',
])
for (const d of result) {
expect(d.getHours()).toBe(18)
expect(d.getMinutes()).toBe(30)
}
})
it('skips months without the required day (the 31st)', () => {
const eventDate = dt(2026, 1, 31, 20, 0)
const result = expandEventOccurrences(
{ date: eventDate.toISOString(), recurrenceType: 'monthlyByDate' },
{ now: dt(2026, 1, 1), horizon: dt(2026, 5, 31, 23, 59) },
)
expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
'1-31',
'3-31',
'5-31',
])
})
})
describe('monthlyByWeekday', () => {
it('derives weekday + weekOfMonth from event.date', () => {
// 2026-04-13 is the 2nd Monday of April
const eventDate = dt(2026, 4, 13, 20, 0)
const result = expandEventOccurrences(
{ date: eventDate.toISOString(), recurrenceType: 'monthlyByWeekday' },
{ now: dt(2026, 4, 1), horizon: dt(2026, 7, 31) },
)
// 2nd Monday of each month: Apr 13, May 11, Jun 8, Jul 13
expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
'4-13',
'5-11',
'6-8',
'7-13',
])
for (const d of result) {
expect(d.getDay()).toBe(1) // Monday
}
})
it('clamps a 5th-occurrence event.date to "fourth"', () => {
// 2026-03-30 is the 5th Monday of March.
// Expected derivation: 'fourth' Monday (not 'last') — see weekOfMonthOf rationale.
const eventDate = dt(2026, 3, 30, 20, 0)
const result = expandEventOccurrences(
{ date: eventDate.toISOString(), recurrenceType: 'monthlyByWeekday' },
{ now: dt(2026, 3, 1), horizon: dt(2026, 6, 30) },
)
// 4th Monday of each month: Mar 23 (before now? no — now is Mar 1),
// Apr 27, May 25, Jun 22. March 23 is before event.date though.
// Since `after = max(now, eventDate) = 2026-03-30`, March's 23rd is
// excluded. So: Apr 27, May 25, Jun 22.
expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
'4-27',
'5-25',
'6-22',
])
})
})
describe('custom', () => {
it('merges two monthly rules, sorted and deduplicated', () => {
// 3rd Tuesday AND 4th Thursday of each month Apr-Jun 2026.
// Apr: Tue 21, Thu 23 → 4-21, 4-23
// May: Tue 19, Thu 28 → 5-19, 5-28
// Jun: Tue 16, Thu 25 → 6-16, 6-25
const rules: RecurrenceRule[] = [
{ frequency: 'monthlyByWeekday', weekday: 'tuesday', weekOfMonth: 'third' },
{ frequency: 'monthlyByWeekday', weekday: 'thursday', weekOfMonth: 'fourth' },
]
const eventDate = dt(2026, 4, 21, 18, 30) // seed
const result = expandEventOccurrences(
{
date: eventDate.toISOString(),
recurrenceType: 'custom',
recurrenceRules: rules,
},
{ now: dt(2026, 4, 1), horizon: dt(2026, 6, 30) },
)
expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
'4-21',
'4-23',
'5-19',
'5-28',
'6-16',
'6-25',
])
for (const d of result) {
expect(d.getHours()).toBe(18)
expect(d.getMinutes()).toBe(30)
}
})
it('deduplicates when rules overlap', () => {
// Every Tuesday AND 3rd Tuesday: the 3rd-Tuesday dates are subsumed
// by weekly, so the output equals the weekly sequence alone.
const rules: RecurrenceRule[] = [
{ frequency: 'weekly', weekday: 'tuesday' },
{ frequency: 'monthlyByWeekday', weekday: 'tuesday', weekOfMonth: 'third' },
]
const eventDate = dt(2026, 4, 7, 18, 30)
const result = expandEventOccurrences(
{
date: eventDate.toISOString(),
recurrenceType: 'custom',
recurrenceRules: rules,
},
{ now: dt(2026, 4, 1), horizon: dt(2026, 4, 30) },
)
expect(result.map((d) => d.getDate())).toEqual([7, 14, 21, 28])
})
it('drops rule rows with missing dependent fields rather than throwing', () => {
const rules: RecurrenceRule[] = [
{ frequency: 'monthlyByWeekday', weekday: 'tuesday', weekOfMonth: 'third' },
// Incomplete: weekOfMonth missing — should be dropped.
{ frequency: 'monthlyByWeekday', weekday: 'friday' },
// Incomplete: dayOfMonth missing — should be dropped.
{ frequency: 'monthlyByDate' },
]
const eventDate = dt(2026, 4, 21, 18, 30)
const result = expandEventOccurrences(
{
date: eventDate.toISOString(),
recurrenceType: 'custom',
recurrenceRules: rules,
},
{ now: dt(2026, 4, 1), horizon: dt(2026, 6, 30) },
)
// Only the first (valid) rule contributes.
expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
'4-21',
'5-19',
'6-16',
])
})
it('returns [] when recurrenceType=custom but recurrenceRules is empty or missing', () => {
const eventDate = dt(2026, 4, 13, 18, 30)
expect(
expandEventOccurrences(
{
date: eventDate.toISOString(),
recurrenceType: 'custom',
recurrenceRules: [],
},
{ now: dt(2026, 4, 1), horizon: dt(2026, 6, 30) },
),
).toEqual([])
expect(
expandEventOccurrences(
{ date: eventDate.toISOString(), recurrenceType: 'custom' },
{ now: dt(2026, 4, 1), horizon: dt(2026, 6, 30) },
),
).toEqual([])
})
})
describe('horizon truncation (caller-supplied endDate clipping)', () => {
it('stops at horizon', () => {
const eventDate = dt(2026, 4, 13, 18, 30)
const result = expandEventOccurrences(
{ date: eventDate.toISOString(), recurrenceType: 'monthlyByDate' },
{ now: dt(2026, 4, 1), horizon: dt(2026, 5, 31, 23, 59) },
)
expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
'4-13',
'5-13',
])
})
})
describe('time-of-day invariant', () => {
it('every occurrence has the same local HH:mm as event.date', () => {
const eventDate = dt(2026, 4, 7, 15, 45)
const result = expandEventOccurrences(
{ date: eventDate.toISOString(), recurrenceType: 'weekly' },
{ now: dt(2026, 3, 1), horizon: dt(2026, 11, 30) },
)
expect(result.length).toBeGreaterThan(20) // spans the DST transitions
for (const d of result) {
expect(d.getHours()).toBe(15)
expect(d.getMinutes()).toBe(45)
}
})
})
})

View file

@ -0,0 +1,197 @@
import {
combineDateAndTime,
generateOccurrenceDates,
type ScheduleDay,
type ScheduleEntry,
type WeekOfMonth,
} from './scheduleOccurrences'
export type EventRecurrenceType =
| 'none'
| 'daily'
| 'weekly'
| 'biweekly'
| 'monthlyByDate'
| 'monthlyByWeekday'
| 'custom'
export type RecurrenceRuleFrequency =
| 'daily'
| 'weekly'
| 'monthlyByDate'
| 'monthlyByWeekday'
export interface RecurrenceRule {
frequency: RecurrenceRuleFrequency
weekday?: ScheduleDay | null
weekOfMonth?: WeekOfMonth | null
dayOfMonth?: number | null
}
// JS getDay(): 0 = Sunday ... 6 = Saturday
const WEEKDAY_FROM_INDEX: Record<number, ScheduleDay> = {
0: 'sunday',
1: 'monday',
2: 'tuesday',
3: 'wednesday',
4: 'thursday',
5: 'friday',
6: 'saturday',
}
const WEEK_OF_MONTH_FROM_N: Record<number, WeekOfMonth> = {
1: 'first',
2: 'second',
3: 'third',
4: 'fourth',
}
const weekdayOf = (date: Date): ScheduleDay => WEEKDAY_FROM_INDEX[date.getDay()]
// Clamp 5th-occurrence dates (e.g. the 5th Monday of a 31-day month) to
// 'fourth'. Editors who really want "last" can pick it explicitly through
// a custom rule — auto-deriving to 'last' would surprise users whose event
// landed on a 5th occurrence only because that particular month happens to
// have one.
const weekOfMonthOf = (date: Date): WeekOfMonth => {
const n = Math.ceil(date.getDate() / 7)
return WEEK_OF_MONTH_FROM_N[n] ?? 'fourth'
}
/**
* Map a single RecurrenceRule (custom-mode row) to a ScheduleEntry.
* Returns null when a rule is missing fields its frequency requires
* callers drop those rows rather than throwing, so a half-filled row in
* the admin doesn't explode the job.
*/
const ruleToEntry = (rule: RecurrenceRule): ScheduleEntry | null => {
switch (rule.frequency) {
case 'daily':
return { frequency: 'daily' }
case 'weekly':
if (!rule.weekday) return null
return { frequency: 'weekly', day: rule.weekday }
case 'monthlyByDate':
if (typeof rule.dayOfMonth !== 'number') return null
return { frequency: 'monthlyByDate', dayOfMonth: rule.dayOfMonth }
case 'monthlyByWeekday':
if (!rule.weekday || !rule.weekOfMonth) return null
return {
frequency: 'monthlyByWeekday',
day: rule.weekday,
weekOfMonth: rule.weekOfMonth,
}
default:
return null
}
}
/**
* Translate the Event doc's recurrence configuration to one or more
* ScheduleEntry values. For shorthand modes (weekly/biweekly/monthlyByDate/
* monthlyByWeekday) we derive the weekday/dayOfMonth/weekOfMonth from
* `event.date` so the rule always stays in sync with the user-visible
* Datum field. `custom` uses `recurrenceRules` verbatim.
*/
const entriesForEvent = (
recurrenceType: EventRecurrenceType,
recurrenceRules: RecurrenceRule[] | null | undefined,
eventDate: Date,
): ScheduleEntry[] => {
switch (recurrenceType) {
case 'daily':
return [{ frequency: 'daily' }]
case 'weekly':
return [{ frequency: 'weekly', day: weekdayOf(eventDate) }]
case 'biweekly':
return [
{
frequency: 'biweekly',
day: weekdayOf(eventDate),
biweeklyAnchor: eventDate,
},
]
case 'monthlyByDate':
return [{ frequency: 'monthlyByDate', dayOfMonth: eventDate.getDate() }]
case 'monthlyByWeekday':
return [
{
frequency: 'monthlyByWeekday',
day: weekdayOf(eventDate),
weekOfMonth: weekOfMonthOf(eventDate),
},
]
case 'custom': {
if (!Array.isArray(recurrenceRules)) return []
const entries: ScheduleEntry[] = []
for (const rule of recurrenceRules) {
const entry = ruleToEntry(rule)
if (entry) entries.push(entry)
}
return entries
}
default:
return []
}
}
/**
* Materialise the set of future occurrences for an event, in local wall-
* clock time copied from `event.date`. DST-safe by construction: we ask
* scheduleOccurrences for calendar dates only (at local midnight) and
* then stitch the original time-of-day onto each via combineDateAndTime.
*
* Returned dates are:
* - within [max(now, event.date), horizon] (caller supplies the horizon
* already clipped to event.endDate, if any)
* - sorted ascending
* - deduplicated (needed when a `custom` rule set has overlapping rules,
* e.g. "every Tuesday" and "3rd Tuesday")
*
* Returns an empty array for `recurrenceType === 'none'` the caller
* handles that case as a one-shot payload.create rather than going
* through this path.
*/
export const expandEventOccurrences = (
event: {
date?: string | Date | null
recurrenceType?: EventRecurrenceType | null
recurrenceRules?: RecurrenceRule[] | null
},
{ now, horizon }: { now: Date; horizon: Date },
): Date[] => {
if (!event.date) return []
const eventDate = event.date instanceof Date ? event.date : new Date(event.date)
if (Number.isNaN(eventDate.getTime())) return []
const recurrenceType = event.recurrenceType ?? 'none'
if (recurrenceType === 'none') return []
// Window lower bound: don't backfill occurrences before the event's own
// start date, and don't return past dates relative to `now`.
const after = new Date(Math.max(now.getTime(), eventDate.getTime()))
if (after.getTime() > horizon.getTime()) return []
const entries = entriesForEvent(recurrenceType, event.recurrenceRules, eventDate)
if (entries.length === 0) return []
const seen = new Set<number>()
const out: Date[] = []
for (const entry of entries) {
const dates = generateOccurrenceDates(entry, after, horizon)
for (const d of dates) {
const stitched = combineDateAndTime(d, eventDate)
const t = stitched.getTime()
// Skip occurrences that, after time-of-day stitching, land before
// the window's lower bound (e.g. today's entry at 09:00 when `now`
// is 14:00) or past the horizon.
if (t < after.getTime() || t > horizon.getTime()) continue
if (seen.has(t)) continue
seen.add(t)
out.push(stitched)
}
}
out.sort((a, b) => a.getTime() - b.getTime())
return out
}

View file

@ -166,6 +166,97 @@ describe('generateOccurrenceDates monthlyByWeekday', () => {
})
})
describe('generateOccurrenceDates daily', () => {
it('yields every day in the window, inclusive of both endpoints', () => {
const entry: ScheduleEntry = { frequency: 'daily' }
const result = generateOccurrenceDates(entry, d(2026, 4, 10), d(2026, 4, 14))
expect(result.map((x) => x.getDate())).toEqual([10, 11, 12, 13, 14])
})
it('yields a single day when after == before (same day)', () => {
const entry: ScheduleEntry = { frequency: 'daily' }
const result = generateOccurrenceDates(entry, d(2026, 4, 10), d(2026, 4, 10))
expect(result.map((x) => x.getDate())).toEqual([10])
})
it('returns empty when before < after', () => {
const entry: ScheduleEntry = { frequency: 'daily' }
const result = generateOccurrenceDates(entry, d(2026, 4, 10), d(2026, 4, 9))
expect(result).toEqual([])
})
it('crosses a month boundary', () => {
const entry: ScheduleEntry = { frequency: 'daily' }
const result = generateOccurrenceDates(entry, d(2026, 4, 29), d(2026, 5, 2))
expect(result.map((x) => `${x.getMonth() + 1}-${x.getDate()}`)).toEqual([
'4-29',
'4-30',
'5-1',
'5-2',
])
})
})
describe('generateOccurrenceDates monthlyByDate', () => {
it('yields the 13th of every month in the window', () => {
const entry: ScheduleEntry = { frequency: 'monthlyByDate', dayOfMonth: 13 }
const result = generateOccurrenceDates(entry, d(2026, 4, 1), d(2026, 6, 30))
expect(result.map((x) => `${x.getMonth() + 1}-${x.getDate()}`)).toEqual([
'4-13',
'5-13',
'6-13',
])
})
it('skips months without a 31st', () => {
const entry: ScheduleEntry = { frequency: 'monthlyByDate', dayOfMonth: 31 }
// 2026: Jan=31, Feb=28, Mar=31, Apr=30, May=31, Jun=30, Jul=31,
// Aug=31, Sep=30, Oct=31, Nov=30, Dec=31
const result = generateOccurrenceDates(entry, d(2026, 1, 1), d(2026, 12, 31))
expect(result.map((x) => `${x.getMonth() + 1}-${x.getDate()}`)).toEqual([
'1-31',
'3-31',
'5-31',
'7-31',
'8-31',
'10-31',
'12-31',
])
})
it('skips a month when the window starts after that month\'s target day', () => {
const entry: ScheduleEntry = { frequency: 'monthlyByDate', dayOfMonth: 13 }
// Start 2026-04-14, after April's 13th — April should be skipped.
const result = generateOccurrenceDates(entry, d(2026, 4, 14), d(2026, 6, 30))
expect(result.map((x) => `${x.getMonth() + 1}-${x.getDate()}`)).toEqual([
'5-13',
'6-13',
])
})
it('works for the 1st of the month', () => {
const entry: ScheduleEntry = { frequency: 'monthlyByDate', dayOfMonth: 1 }
const result = generateOccurrenceDates(entry, d(2026, 4, 1), d(2026, 6, 1))
expect(result.map((x) => `${x.getMonth() + 1}-${x.getDate()}`)).toEqual([
'4-1',
'5-1',
'6-1',
])
})
it('returns empty when dayOfMonth is missing', () => {
const entry: ScheduleEntry = { frequency: 'monthlyByDate' }
const result = generateOccurrenceDates(entry, d(2026, 4, 1), d(2026, 6, 30))
expect(result).toEqual([])
})
it('returns empty for out-of-range dayOfMonth', () => {
const entry: ScheduleEntry = { frequency: 'monthlyByDate', dayOfMonth: 32 }
const result = generateOccurrenceDates(entry, d(2026, 4, 1), d(2026, 6, 30))
expect(result).toEqual([])
})
})
describe('generateOccurrenceDates edge cases', () => {
it('returns empty when before < after', () => {
const entry: ScheduleEntry = { frequency: 'weekly', day: 'sunday' }

View file

@ -7,15 +7,21 @@ export type ScheduleDay =
| 'saturday'
| 'sunday'
export type ScheduleFrequency = 'weekly' | 'biweekly' | 'monthlyByWeekday'
export type ScheduleFrequency =
| 'daily'
| 'weekly'
| 'biweekly'
| 'monthlyByDate'
| 'monthlyByWeekday'
export type WeekOfMonth = 'first' | 'second' | 'third' | 'fourth' | 'last'
export interface ScheduleEntry {
frequency: ScheduleFrequency
day: ScheduleDay
day?: ScheduleDay
weekOfMonth?: WeekOfMonth | null
biweeklyAnchor?: string | Date | null
dayOfMonth?: number | null
}
// JS getDay(): 0 = Sunday ... 6 = Saturday
@ -97,17 +103,32 @@ export const generateOccurrenceDates = (
after: Date,
before: Date,
): Date[] => {
const targetDay = DAY_INDEX[entry.day]
if (targetDay === undefined) return []
if (before.getTime() < after.getTime()) return []
const dates: Date[] = []
const afterStart = startOfDay(after)
const beforeEnd = startOfDay(before)
// Frequencies that ride a specific weekday need a valid entry.day.
const needsWeekday =
entry.frequency === 'weekly' ||
entry.frequency === 'biweekly' ||
entry.frequency === 'monthlyByWeekday'
const targetDay = entry.day !== undefined ? DAY_INDEX[entry.day] : undefined
if (needsWeekday && targetDay === undefined) return []
switch (entry.frequency) {
case 'daily': {
let cursor = afterStart
while (cursor.getTime() <= beforeEnd.getTime()) {
dates.push(cursor)
cursor = addDays(cursor, 1)
}
return dates
}
case 'weekly': {
let cursor = firstOccurrenceOnOrAfter(afterStart, targetDay)
let cursor = firstOccurrenceOnOrAfter(afterStart, targetDay!)
while (cursor.getTime() <= beforeEnd.getTime()) {
dates.push(cursor)
cursor = addDays(cursor, 7)
@ -120,7 +141,7 @@ export const generateOccurrenceDates = (
const anchor = startOfDay(new Date(entry.biweeklyAnchor))
if (Number.isNaN(anchor.getTime())) return []
let cursor = firstOccurrenceOnOrAfter(afterStart, targetDay)
let cursor = firstOccurrenceOnOrAfter(afterStart, targetDay!)
// Align cursor with anchor's 2-week parity. Compute the whole-day
// delta using midday to avoid DST rounding pushing it off by one.
const daysFromAnchor = Math.round(
@ -137,6 +158,38 @@ export const generateOccurrenceDates = (
return dates
}
case 'monthlyByDate': {
if (typeof entry.dayOfMonth !== 'number') return []
const dom = entry.dayOfMonth
if (dom < 1 || dom > 31) return []
let year = afterStart.getFullYear()
let month = afterStart.getMonth()
const endYear = beforeEnd.getFullYear()
const endMonth = beforeEnd.getMonth()
// Iterate month-by-month. Months without the requested day (e.g. the
// 31st in April) are skipped — no clamp-down to the last day.
while (year < endYear || (year === endYear && month <= endMonth)) {
const daysInMonth = new Date(year, month + 1, 0).getDate()
if (dom <= daysInMonth) {
const occ = new Date(year, month, dom)
if (
occ.getTime() >= afterStart.getTime() &&
occ.getTime() <= beforeEnd.getTime()
) {
dates.push(occ)
}
}
month += 1
if (month > 11) {
month = 0
year += 1
}
}
return dates
}
case 'monthlyByWeekday': {
if (!entry.weekOfMonth) return []
const n = WEEK_OF_MONTH_N[entry.weekOfMonth]
@ -147,7 +200,7 @@ export const generateOccurrenceDates = (
const endMonth = beforeEnd.getMonth()
while (year < endYear || (year === endYear && month <= endMonth)) {
const occ = nthWeekdayOfMonth(year, month, targetDay, n)
const occ = nthWeekdayOfMonth(year, month, targetDay!, n)
if (
occ &&
occ.getTime() >= afterStart.getTime() &&

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,80 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_event_recurrence_rules_frequency" AS ENUM('daily', 'weekly', 'monthlyByDate', 'monthlyByWeekday');
CREATE TYPE "public"."enum_event_recurrence_rules_weekday" AS ENUM('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday');
CREATE TYPE "public"."enum_event_recurrence_rules_week_of_month" AS ENUM('first', 'second', 'third', 'fourth', 'last');
CREATE TYPE "public"."enum__event_v_version_recurrence_rules_frequency" AS ENUM('daily', 'weekly', 'monthlyByDate', 'monthlyByWeekday');
CREATE TYPE "public"."enum__event_v_version_recurrence_rules_weekday" AS ENUM('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday');
CREATE TYPE "public"."enum__event_v_version_recurrence_rules_week_of_month" AS ENUM('first', 'second', 'third', 'fourth', 'last');
ALTER TYPE "public"."enum_event_recurrence_type" ADD VALUE 'daily' BEFORE 'weekly';
ALTER TYPE "public"."enum_event_recurrence_type" ADD VALUE 'monthlyByDate';
ALTER TYPE "public"."enum_event_recurrence_type" ADD VALUE 'monthlyByWeekday';
ALTER TYPE "public"."enum_event_recurrence_type" ADD VALUE 'custom';
ALTER TYPE "public"."enum__event_v_version_recurrence_type" ADD VALUE 'daily' BEFORE 'weekly';
ALTER TYPE "public"."enum__event_v_version_recurrence_type" ADD VALUE 'monthlyByDate';
ALTER TYPE "public"."enum__event_v_version_recurrence_type" ADD VALUE 'monthlyByWeekday';
ALTER TYPE "public"."enum__event_v_version_recurrence_type" ADD VALUE 'custom';
CREATE TABLE "event_recurrence_rules" (
"_order" integer NOT NULL,
"_parent_id" uuid NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"frequency" "enum_event_recurrence_rules_frequency",
"weekday" "enum_event_recurrence_rules_weekday",
"week_of_month" "enum_event_recurrence_rules_week_of_month",
"day_of_month" numeric
);
CREATE TABLE "_event_v_version_recurrence_rules" (
"_order" integer NOT NULL,
"_parent_id" uuid NOT NULL,
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"frequency" "enum__event_v_version_recurrence_rules_frequency",
"weekday" "enum__event_v_version_recurrence_rules_weekday",
"week_of_month" "enum__event_v_version_recurrence_rules_week_of_month",
"day_of_month" numeric,
"_uuid" varchar
);
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-04-26T11:53:10.432Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-04-26T11:53:10.804Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-05-23T11:53:10.907Z';
ALTER TABLE "event_recurrence_rules" ADD CONSTRAINT "event_recurrence_rules_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."event"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_event_v_version_recurrence_rules" ADD CONSTRAINT "_event_v_version_recurrence_rules_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_event_v"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "event_recurrence_rules_order_idx" ON "event_recurrence_rules" USING btree ("_order");
CREATE INDEX "event_recurrence_rules_parent_id_idx" ON "event_recurrence_rules" USING btree ("_parent_id");
CREATE INDEX "_event_v_version_recurrence_rules_order_idx" ON "_event_v_version_recurrence_rules" USING btree ("_order");
CREATE INDEX "_event_v_version_recurrence_rules_parent_id_idx" ON "_event_v_version_recurrence_rules" USING btree ("_parent_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "event_recurrence_rules" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "_event_v_version_recurrence_rules" DISABLE ROW LEVEL SECURITY;
DROP TABLE "event_recurrence_rules" CASCADE;
DROP TABLE "_event_v_version_recurrence_rules" CASCADE;
ALTER TABLE "event" ALTER COLUMN "recurrence_type" SET DATA TYPE text;
ALTER TABLE "event" ALTER COLUMN "recurrence_type" SET DEFAULT 'none'::text;
UPDATE "event" SET "recurrence_type" = 'none' WHERE "recurrence_type" NOT IN ('none', 'weekly', 'biweekly');
DROP TYPE "public"."enum_event_recurrence_type";
CREATE TYPE "public"."enum_event_recurrence_type" AS ENUM('none', 'weekly', 'biweekly');
ALTER TABLE "event" ALTER COLUMN "recurrence_type" SET DEFAULT 'none'::"public"."enum_event_recurrence_type";
ALTER TABLE "event" ALTER COLUMN "recurrence_type" SET DATA TYPE "public"."enum_event_recurrence_type" USING "recurrence_type"::"public"."enum_event_recurrence_type";
ALTER TABLE "_event_v" ALTER COLUMN "version_recurrence_type" SET DATA TYPE text;
ALTER TABLE "_event_v" ALTER COLUMN "version_recurrence_type" SET DEFAULT 'none'::text;
UPDATE "_event_v" SET "version_recurrence_type" = 'none' WHERE "version_recurrence_type" NOT IN ('none', 'weekly', 'biweekly');
DROP TYPE "public"."enum__event_v_version_recurrence_type";
CREATE TYPE "public"."enum__event_v_version_recurrence_type" AS ENUM('none', 'weekly', 'biweekly');
ALTER TABLE "_event_v" ALTER COLUMN "version_recurrence_type" SET DEFAULT 'none'::"public"."enum__event_v_version_recurrence_type";
ALTER TABLE "_event_v" ALTER COLUMN "version_recurrence_type" SET DATA TYPE "public"."enum__event_v_version_recurrence_type" USING "version_recurrence_type"::"public"."enum__event_v_version_recurrence_type";
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-04-19T11:47:26.630Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-04-19T11:47:26.914Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-05-17T11:47:26.972Z';
DROP TYPE "public"."enum_event_recurrence_rules_frequency";
DROP TYPE "public"."enum_event_recurrence_rules_weekday";
DROP TYPE "public"."enum_event_recurrence_rules_week_of_month";
DROP TYPE "public"."enum__event_v_version_recurrence_rules_frequency";
DROP TYPE "public"."enum__event_v_version_recurrence_rules_weekday";
DROP TYPE "public"."enum__event_v_version_recurrence_rules_week_of_month";`)
}

View file

@ -40,6 +40,7 @@ import * as migration_20260417_072846 from './20260417_072846';
import * as migration_20260417_075155 from './20260417_075155';
import * as migration_20260417_111855_event_occurrences from './20260417_111855_event_occurrences';
import * as migration_20260417_114727_simplify_recurring_events from './20260417_114727_simplify_recurring_events';
import * as migration_20260423_115311 from './20260423_115311';
export const migrations = [
{
@ -250,6 +251,11 @@ export const migrations = [
{
up: migration_20260417_114727_simplify_recurring_events.up,
down: migration_20260417_114727_simplify_recurring_events.down,
name: '20260417_114727_simplify_recurring_events'
name: '20260417_114727_simplify_recurring_events',
},
{
up: migration_20260423_115311.up,
down: migration_20260423_115311.down,
name: '20260423_115311'
},
];

View file

@ -34,6 +34,7 @@ type EventProps = {
createdAt: string,
cancelled: boolean,
recurrenceType?: Event['recurrenceType'],
recurrenceDescription?: string,
location: string | Location,
description: string,
shortDescription: string,
@ -54,6 +55,7 @@ export function EventPage(
createdAt,
cancelled,
recurrenceType,
recurrenceDescription,
location,
description,
shortDescription,
@ -72,6 +74,7 @@ export function EventPage(
const contactPersonPhoto = typeof contact === "object" ? getPhoto("thumbnail", contact.photo) : undefined;
const isRecurring = recurrenceType && recurrenceType !== 'none'
const hasOccurrences = upcomingOccurrences && upcomingOccurrences.length > 0;
const dateOrRecurrence = recurrenceDescription ?? readableDate
return (
<>
@ -123,7 +126,7 @@ export function EventPage(
<Title
size={"md"}
title={title}
subtitle={`${readableDate} - ${typeof location === "object" ? location.name : 'd'}`}
subtitle={`${dateOrRecurrence} - ${typeof location === "object" ? location.name : 'd'}`}
fontStyle={"sans-serif"}
align={"center"}
/>

View file

@ -1074,9 +1074,21 @@ export interface Event {
photo?: (string | null) | Media;
flyer?: (string | null) | Document;
/**
* Bei wiederkehrenden Veranstaltungen werden automatisch Termine für die nächsten Wochen erzeugt. Wochentag und Uhrzeit werden aus dem Datumsfeld übernommen.
* Bei wiederkehrenden Veranstaltungen werden automatisch Termine für die nächsten Wochen erzeugt. Wochentag, Kalendertag und Uhrzeit werden aus dem Datumsfeld übernommen.
*/
recurrenceType: 'none' | 'weekly' | 'biweekly';
recurrenceType: 'none' | 'daily' | 'weekly' | 'biweekly' | 'monthlyByDate' | 'monthlyByWeekday' | 'custom';
/**
* Mehrere Regeln werden kombiniert (z. B. 3. Dienstag und 4. Donnerstag jeden Monat").
*/
recurrenceRules?:
| {
frequency: 'daily' | 'weekly' | 'monthlyByDate' | 'monthlyByWeekday';
weekday?: ('monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday') | null;
weekOfMonth?: ('first' | 'second' | 'third' | 'fourth' | 'last') | null;
dayOfMonth?: number | null;
id?: string | null;
}[]
| null;
/**
* Optional. Nach diesem Datum werden keine weiteren Termine erzeugt.
*/
@ -1823,6 +1835,15 @@ export interface EventSelect<T extends boolean = true> {
photo?: T;
flyer?: T;
recurrenceType?: T;
recurrenceRules?:
| T
| {
frequency?: T;
weekday?: T;
weekOfMonth?: T;
dayOfMonth?: T;
id?: T;
};
endDate?: T;
cancelled?: T;
updatedAt?: T;

View file

@ -1,6 +1,7 @@
import type { ComponentProps } from 'react'
import { Event, EventOccurrence } from '@/payload-types'
import type { EventPage } from '@/pageComponents/Event/Event'
import { describeRecurrence } from '@/utils/recurrenceDescription'
export const getEventGroupSlug = (event: Event): string | undefined => {
const firstGroup = event.group?.[0]
@ -24,6 +25,9 @@ export const eventToPageProps = (
createdAt: event.createdAt,
cancelled: Boolean(event.cancelled || occurrence?.cancelled),
recurrenceType: event.recurrenceType,
// Only describe recurrence on the event page itself; the occurrence
// page shows that occurrence's concrete date.
recurrenceDescription: occurrence ? undefined : describeRecurrence(event),
location: event.location,
description: event.description,
shortDescription: event.shortDescription,

View file

@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest'
import { describeRecurrence } from './recurrenceDescription'
// Reference: 2026-04-13 local time = Monday, 2nd Monday of April 2026.
const tuesdayFirstOfMonth = new Date(2026, 3, 7, 18, 30).toISOString() // 2026-04-07 is a Tuesday, 1st of month
const monday2ndOfMonth = new Date(2026, 3, 13, 18, 30).toISOString() // 2026-04-13
const sunday13thOfMonth = new Date(2026, 8, 13, 10, 0).toISOString() // 2026-09-13 is a Sunday
const monday5thOfMonth = new Date(2026, 2, 30, 20, 0).toISOString() // 2026-03-30
describe('describeRecurrence', () => {
it('returns undefined for none / missing recurrenceType', () => {
expect(describeRecurrence({ recurrenceType: 'none', date: monday2ndOfMonth })).toBeUndefined()
expect(describeRecurrence({ recurrenceType: null, date: monday2ndOfMonth } as never)).toBeUndefined()
})
it('describes daily with time from event.date', () => {
const dateAt10 = new Date(2026, 3, 13, 10, 0).toISOString()
expect(describeRecurrence({ recurrenceType: 'daily', date: dateAt10 })).toBe(
'Täglich um 10:00 Uhr',
)
})
it('describes weekly using the weekday and time of event.date', () => {
expect(describeRecurrence({ recurrenceType: 'weekly', date: tuesdayFirstOfMonth })).toBe(
'Jeden Dienstag um 18:30 Uhr',
)
})
it('describes biweekly', () => {
expect(describeRecurrence({ recurrenceType: 'biweekly', date: monday2ndOfMonth })).toBe(
'Alle 2 Wochen, Montag um 18:30 Uhr',
)
})
it('describes monthlyByDate using the day-of-month from event.date', () => {
expect(describeRecurrence({ recurrenceType: 'monthlyByDate', date: sunday13thOfMonth })).toBe(
'Monatlich am 13. um 10:00 Uhr',
)
})
it('describes monthlyByWeekday using the Nth weekday from event.date', () => {
expect(describeRecurrence({ recurrenceType: 'monthlyByWeekday', date: monday2ndOfMonth })).toBe(
'Monatlich am 2. Montag um 18:30 Uhr',
)
expect(describeRecurrence({ recurrenceType: 'monthlyByWeekday', date: tuesdayFirstOfMonth })).toBe(
'Monatlich am 1. Dienstag um 18:30 Uhr',
)
})
it('clamps a 5th-occurrence weekday to the 4th (matches generator)', () => {
expect(describeRecurrence({ recurrenceType: 'monthlyByWeekday', date: monday5thOfMonth })).toBe(
'Monatlich am 4. Montag um 20:00 Uhr',
)
})
it('joins multiple custom rules and appends time once', () => {
const desc = describeRecurrence({
recurrenceType: 'custom',
date: monday2ndOfMonth,
recurrenceRules: [
{ frequency: 'monthlyByWeekday', weekday: 'tuesday', weekOfMonth: 'third' },
{ frequency: 'monthlyByWeekday', weekday: 'thursday', weekOfMonth: 'fourth' },
],
})
expect(desc).toBe(
'Am 3. Dienstag jedes Monats · Am 4. Donnerstag jedes Monats um 18:30 Uhr',
)
})
it('skips incomplete custom rules and returns Benutzerdefiniert if none are valid', () => {
expect(
describeRecurrence({
recurrenceType: 'custom',
date: monday2ndOfMonth,
recurrenceRules: [
// missing weekOfMonth
{ frequency: 'monthlyByWeekday', weekday: 'tuesday' },
],
}),
).toBe('Benutzerdefiniert um 18:30 Uhr')
})
it('omits the time clause when event.date is missing', () => {
expect(describeRecurrence({ recurrenceType: 'daily', date: null } as never)).toBe('Täglich')
})
it('pads minutes correctly (08:05, not 8:5)', () => {
const dateEarly = new Date(2026, 3, 13, 8, 5).toISOString()
expect(describeRecurrence({ recurrenceType: 'daily', date: dateEarly })).toBe(
'Täglich um 08:05 Uhr',
)
})
})

View file

@ -0,0 +1,112 @@
import type { Event } from '@/payload-types'
const WEEKDAYS_DE = [
'Sonntag',
'Montag',
'Dienstag',
'Mittwoch',
'Donnerstag',
'Freitag',
'Samstag',
]
const WEEKDAY_LABEL: Record<string, string> = {
monday: 'Montag',
tuesday: 'Dienstag',
wednesday: 'Mittwoch',
thursday: 'Donnerstag',
friday: 'Freitag',
saturday: 'Samstag',
sunday: 'Sonntag',
}
const WEEK_OF_MONTH_LABEL: Record<string, string> = {
first: '1.',
second: '2.',
third: '3.',
fourth: '4.',
last: 'letzten',
}
// Match the clamp in eventRecurrence.ts so the label on the Event page
// agrees with the rule the generator actually produces.
const weekOfMonthN = (d: Date): number => Math.min(Math.ceil(d.getDate() / 7), 4)
const formatTime = (d: Date): string => {
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${hh}:${mm}`
}
const withTime = (description: string, d: Date | null): string =>
d ? `${description} um ${formatTime(d)} Uhr` : description
type RecurrenceRule = NonNullable<Event['recurrenceRules']>[number]
const describeCustomRule = (rule: RecurrenceRule): string | null => {
switch (rule.frequency) {
case 'daily':
return 'Täglich'
case 'weekly': {
const weekday = rule.weekday ? WEEKDAY_LABEL[rule.weekday] : null
return weekday ? `Jeden ${weekday}` : null
}
case 'monthlyByDate':
return typeof rule.dayOfMonth === 'number'
? `Am ${rule.dayOfMonth}. jedes Monats`
: null
case 'monthlyByWeekday': {
const weekday = rule.weekday ? WEEKDAY_LABEL[rule.weekday] : null
const week = rule.weekOfMonth ? WEEK_OF_MONTH_LABEL[rule.weekOfMonth] : null
return weekday && week ? `Am ${week} ${weekday} jedes Monats` : null
}
default:
return null
}
}
/**
* Human-readable German description of an event's recurrence. Returns
* `undefined` for one-shot events (recurrenceType = 'none' / missing) so
* callers can fall back to a regular date.
*/
export const describeRecurrence = (
event: Pick<Event, 'recurrenceType' | 'recurrenceRules' | 'date'>,
): string | undefined => {
const type = event.recurrenceType
if (!type || type === 'none') return undefined
const parsed = event.date ? new Date(event.date) : null
const d = parsed && !Number.isNaN(parsed.getTime()) ? parsed : null
switch (type) {
case 'daily':
return withTime('Täglich', d)
case 'weekly':
return withTime(d ? `Jeden ${WEEKDAYS_DE[d.getDay()]}` : 'Wöchentlich', d)
case 'biweekly':
return withTime(
d ? `Alle 2 Wochen, ${WEEKDAYS_DE[d.getDay()]}` : 'Alle 2 Wochen',
d,
)
case 'monthlyByDate':
return withTime(d ? `Monatlich am ${d.getDate()}.` : 'Monatlich', d)
case 'monthlyByWeekday':
return withTime(
d
? `Monatlich am ${weekOfMonthN(d)}. ${WEEKDAYS_DE[d.getDay()]}`
: 'Monatlich',
d,
)
case 'custom': {
const rules = event.recurrenceRules ?? []
const parts = rules
.map(describeCustomRule)
.filter((p): p is string => Boolean(p))
if (parts.length === 0) return withTime('Benutzerdefiniert', d)
return withTime(parts.join(' · '), d)
}
default:
return undefined
}
}