Compare commits

..

No commits in common. "3e836bb0165044ca4db18d3d4e1a4e54779de59b" and "f4afd2ff7757b405f4a19d13d8e22a24f0814260" have entirely different histories.

19 changed files with 61 additions and 51018 deletions

View file

@ -1,55 +0,0 @@
'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,7 +12,6 @@ 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'
@ -35,7 +34,6 @@ 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

@ -44,29 +44,6 @@ export const Events: CollectionConfig = {
},
},
},
{
name: 'endDateTime',
type: 'date',
label: {
de: 'Enduhrzeit',
},
admin: {
date: {
pickerAppearance: 'dayAndTime',
timeIntervals: 15,
timeFormat: 'HH:mm',
},
},
},
{
name: 'cancelled',
type: 'checkbox',
required: true,
label: {
de: 'Abgesagt',
},
defaultValue: false,
},
{
name: 'location',
type: 'relationship',
@ -217,122 +194,14 @@ 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, Kalendertag und Uhrzeit werden aus dem Datumsfeld übernommen.',
components: {
Field:
'@/admin/components/RecurrenceTypeField/RecurrenceTypeField#RecurrenceTypeField',
'Bei wiederkehrenden Veranstaltungen werden automatisch Termine für die nächsten Wochen erzeugt. Wochentag und Uhrzeit werden aus dem Datumsfeld übernommen.',
},
},
},
{
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',
@ -346,6 +215,15 @@ export const Events: CollectionConfig = {
description: 'Optional. Nach diesem Datum werden keine weiteren Termine erzeugt.',
},
},
{
name: 'cancelled',
type: 'checkbox',
required: true,
label: {
de: 'Abgesagt',
},
defaultValue: false,
},
],
},
{

View file

@ -1,10 +1,5 @@
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
@ -15,10 +10,9 @@ import {
* 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`. 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).
* 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.
*
* 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
@ -42,11 +36,16 @@ type GenerateEventOccurrencesOutput = {
type EventLike = {
id: string
date?: string | null
recurrenceType?: EventRecurrenceType | null
recurrenceRules?: RecurrenceRule[] | null
recurrenceType?: 'none' | 'weekly' | 'biweekly' | 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)
@ -70,7 +69,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 — shi)fts in event.date
// event.date stepped by whole calendar days — shifts in event.date
// intentionally drop stale cancellations.
const existing = await payload.find({
collection: 'eventOccurrence',
@ -132,13 +131,19 @@ 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 dates = expandEventOccurrences(event, { now, horizon: effectiveEnd })
for (const date of dates) {
const iso = date.toISOString()
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: {
@ -150,6 +155,10 @@ export const regenerateOccurrencesForEvent = async ({
req,
})
created += 1
} else {
skipped += 1
}
cursor.setDate(cursor.getDate() + step)
}
return { created, deleted, skipped }

View file

@ -1,317 +0,0 @@
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

@ -1,197 +0,0 @@
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,97 +166,6 @@ 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,21 +7,15 @@ export type ScheduleDay =
| 'saturday'
| 'sunday'
export type ScheduleFrequency =
| 'daily'
| 'weekly'
| 'biweekly'
| 'monthlyByDate'
| 'monthlyByWeekday'
export type ScheduleFrequency = 'weekly' | 'biweekly' | '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
@ -103,32 +97,17 @@ 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)
@ -141,7 +120,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(
@ -158,38 +137,6 @@ 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]
@ -200,7 +147,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

@ -1,80 +0,0 @@
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";`)
}

File diff suppressed because it is too large Load diff

View file

@ -1,19 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-04-26T13:12:25.662Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-04-26T13:12:25.946Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-05-23T13:12:26.003Z';
ALTER TABLE "event" ADD COLUMN "end_date_time" timestamp(3) with time zone;
ALTER TABLE "_event_v" ADD COLUMN "version_end_date_time" timestamp(3) with time zone;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
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" DROP COLUMN "end_date_time";
ALTER TABLE "_event_v" DROP COLUMN "version_end_date_time";`)
}

View file

@ -40,8 +40,6 @@ 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';
import * as migration_20260423_131226_add_event_end_date_time from './20260423_131226_add_event_end_date_time';
export const migrations = [
{
@ -252,16 +250,6 @@ 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',
},
{
up: migration_20260423_115311.up,
down: migration_20260423_115311.down,
name: '20260423_115311',
},
{
up: migration_20260423_131226_add_event_end_date_time.up,
down: migration_20260423_131226_add_event_end_date_time.down,
name: '20260423_131226_add_event_end_date_time'
name: '20260417_114727_simplify_recurring_events'
},
];

View file

@ -31,11 +31,9 @@ type EventProps = {
id: string,
title: string,
date: string,
endDateTime?: string,
createdAt: string,
cancelled: boolean,
recurrenceType?: Event['recurrenceType'],
recurrenceDescription?: string,
location: string | Location,
description: string,
shortDescription: string,
@ -53,11 +51,9 @@ export function EventPage(
id,
title,
date,
endDateTime,
createdAt,
cancelled,
recurrenceType,
recurrenceDescription,
location,
description,
shortDescription,
@ -71,12 +67,11 @@ export function EventPage(
}: EventProps
) {
const published = useDate(createdAt)
const readableDate = readableDateTime(date, endDateTime)
const readableDate = readableDateTime(date)
const where = locationString(location);
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 (
<>
@ -128,7 +123,7 @@ export function EventPage(
<Title
size={"md"}
title={title}
subtitle={`${dateOrRecurrence} - ${typeof location === "object" ? location.name : 'd'}`}
subtitle={`${readableDate} - ${typeof location === "object" ? location.name : 'd'}`}
fontStyle={"sans-serif"}
align={"center"}
/>

View file

@ -1064,8 +1064,6 @@ export interface Event {
id: string;
title: string;
date: string;
endDateTime?: string | null;
cancelled: boolean;
location: string | Location;
shortDescription: string;
description: string;
@ -1076,25 +1074,14 @@ 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, Kalendertag und Uhrzeit werden aus dem Datumsfeld übernommen.
* Bei wiederkehrenden Veranstaltungen werden automatisch Termine für die nächsten Wochen erzeugt. Wochentag und Uhrzeit werden aus dem Datumsfeld übernommen.
*/
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;
recurrenceType: 'none' | 'weekly' | 'biweekly';
/**
* Optional. Nach diesem Datum werden keine weiteren Termine erzeugt.
*/
endDate?: string | null;
cancelled: boolean;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
@ -1826,8 +1813,6 @@ export interface HighlightSelect<T extends boolean = true> {
export interface EventSelect<T extends boolean = true> {
title?: T;
date?: T;
endDateTime?: T;
cancelled?: T;
location?: T;
shortDescription?: T;
description?: T;
@ -1838,16 +1823,8 @@ 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;
createdAt?: T;
_status?: T;

View file

@ -1,7 +1,6 @@
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]
@ -22,13 +21,9 @@ export const eventToPageProps = (
id: event.id,
title: event.title,
date: occurrence?.date ?? event.date,
endDateTime: event.endDateTime ?? undefined,
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

@ -1,15 +1,11 @@
/**
* Return a readable date time
* e.G. Samstag 13-01-2024, 12:00 Uhr
* With endDate: Samstag 13-01-2024, 12:00 Uhr bis 14:00 Uhr
*/
export const readableDateTime = (date: string, endDate?: string | null) => {
export const readableDateTime = (date: string) => {
const dateObj = new Date(date);
const dayName = dateObj.toLocaleDateString("de-DE", { weekday: "long" });
const normalDate = dateObj.toLocaleDateString("de-DE", { dateStyle: "medium" });
const time = dateObj.toLocaleTimeString("de-DE", { timeStyle: "short", timeZone: "Europe/Berlin" });
const base = `${dayName} ${normalDate}, ${time} Uhr`;
if (!endDate) return base;
const endTime = new Date(endDate).toLocaleTimeString("de-DE", { timeStyle: "short", timeZone: "Europe/Berlin" });
return `${base} bis ${endTime} Uhr`;
return `${dayName} ${normalDate}, ${time} Uhr`;
}

View file

@ -1,93 +0,0 @@
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

@ -1,112 +0,0 @@
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
}
}