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 { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_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 { 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 { NextOccurrencesTable as NextOccurrencesTable_5b561f1308e1f6674a6813c6174350fc } from '@/admin/components/NextOccurrencesTable/NextOccurrencesTable'
import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client' import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
import { ReindexButton as ReindexButton_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#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@/admin/components/RecurrenceTypeField/RecurrenceTypeField#RecurrenceTypeField": RecurrenceTypeField_140d3880e55fb539df997bcab97abf93,
"@/admin/components/NextOccurrencesTable/NextOccurrencesTable#NextOccurrencesTable": NextOccurrencesTable_5b561f1308e1f6674a6813c6174350fc, "@/admin/components/NextOccurrencesTable/NextOccurrencesTable#NextOccurrencesTable": NextOccurrencesTable_5b561f1308e1f6674a6813c6174350fc,
"@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634, "@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
"@payloadcms/plugin-search/client#ReindexButton": ReindexButton_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', name: 'location',
type: 'relationship', type: 'relationship',
@ -217,122 +194,14 @@ export const Events: CollectionConfig = {
}, },
options: [ options: [
{ label: 'Einmalig', value: 'none' }, { label: 'Einmalig', value: 'none' },
{ label: 'Täglich', value: 'daily' },
{ label: 'Wöchentlich', value: 'weekly' }, { label: 'Wöchentlich', value: 'weekly' },
{ label: 'Alle 2 Wochen', value: 'biweekly' }, { 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: { admin: {
description: description:
'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.',
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', name: 'endDate',
type: 'date', type: 'date',
@ -346,6 +215,15 @@ export const Events: CollectionConfig = {
description: 'Optional. Nach diesem Datum werden keine weiteren Termine erzeugt.', 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 { PayloadRequest, TaskConfig } from 'payload'
import type { Payload } 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 * 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 * is no editor-edited state to preserve rule edits and recurringonce
* transitions stay trivially correct. * transitions stay trivially correct.
* *
* Weekday and time-of-day come directly from `event.date`. Calendar-date * Weekday and time-of-day come directly from `event.date`. We step by
* generation lives in scheduleOccurrences.ts (DST-safe by construction: * whole calendar days (`setDate(getDate() + N)`) so DST transitions don't
* dates are built at local midnight and then stitched with the event's * shift the wall-clock time.
* HH:mm).
* *
* Exposed both as a Payload Jobs Queue task (weekly cron backfill) and as a * 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 * direct function called from the Events `afterChange` hook so edits are
@ -42,11 +36,16 @@ type GenerateEventOccurrencesOutput = {
type EventLike = { type EventLike = {
id: string id: string
date?: string | null date?: string | null
recurrenceType?: EventRecurrenceType | null recurrenceType?: 'none' | 'weekly' | 'biweekly' | null
recurrenceRules?: RecurrenceRule[] | null
endDate?: string | 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. // Treat endDate as inclusive of the whole local day.
const endOfLocalDay = (iso: string): Date => { const endOfLocalDay = (iso: string): Date => {
const d = new Date(iso) const d = new Date(iso)
@ -70,7 +69,7 @@ export const regenerateOccurrencesForEvent = async ({
// Snapshot cancelled dates so manual cancellations survive the wipe/regen. // Snapshot cancelled dates so manual cancellations survive the wipe/regen.
// Matching by ISO date works because regen derives timestamps from the same // 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. // intentionally drop stale cancellations.
const existing = await payload.find({ const existing = await payload.find({
collection: 'eventOccurrence', collection: 'eventOccurrence',
@ -132,13 +131,19 @@ export const regenerateOccurrencesForEvent = async ({
return { created: created + 1, deleted, skipped } return { created: created + 1, deleted, skipped }
} }
const step = stepDaysFor(recurrenceType)
if (step === null) {
return { created, deleted, skipped: skipped + 1 }
}
const effectiveEnd = event.endDate const effectiveEnd = event.endDate
? new Date(Math.min(horizon.getTime(), endOfLocalDay(event.endDate).getTime())) ? new Date(Math.min(horizon.getTime(), endOfLocalDay(event.endDate).getTime()))
: horizon : horizon
const dates = expandEventOccurrences(event, { now, horizon: effectiveEnd }) const cursor = new Date(eventDate)
for (const date of dates) { while (cursor.getTime() <= effectiveEnd.getTime()) {
const iso = date.toISOString() if (cursor.getTime() >= now.getTime()) {
const iso = cursor.toISOString()
await payload.create({ await payload.create({
collection: 'eventOccurrence', collection: 'eventOccurrence',
data: { data: {
@ -150,6 +155,10 @@ export const regenerateOccurrencesForEvent = async ({
req, req,
}) })
created += 1 created += 1
} else {
skipped += 1
}
cursor.setDate(cursor.getDate() + step)
} }
return { created, deleted, skipped } 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', () => { describe('generateOccurrenceDates edge cases', () => {
it('returns empty when before < after', () => { it('returns empty when before < after', () => {
const entry: ScheduleEntry = { frequency: 'weekly', day: 'sunday' } const entry: ScheduleEntry = { frequency: 'weekly', day: 'sunday' }

View file

@ -7,21 +7,15 @@ export type ScheduleDay =
| 'saturday' | 'saturday'
| 'sunday' | 'sunday'
export type ScheduleFrequency = export type ScheduleFrequency = 'weekly' | 'biweekly' | 'monthlyByWeekday'
| 'daily'
| 'weekly'
| 'biweekly'
| 'monthlyByDate'
| 'monthlyByWeekday'
export type WeekOfMonth = 'first' | 'second' | 'third' | 'fourth' | 'last' export type WeekOfMonth = 'first' | 'second' | 'third' | 'fourth' | 'last'
export interface ScheduleEntry { export interface ScheduleEntry {
frequency: ScheduleFrequency frequency: ScheduleFrequency
day?: ScheduleDay day: ScheduleDay
weekOfMonth?: WeekOfMonth | null weekOfMonth?: WeekOfMonth | null
biweeklyAnchor?: string | Date | null biweeklyAnchor?: string | Date | null
dayOfMonth?: number | null
} }
// JS getDay(): 0 = Sunday ... 6 = Saturday // JS getDay(): 0 = Sunday ... 6 = Saturday
@ -103,32 +97,17 @@ export const generateOccurrenceDates = (
after: Date, after: Date,
before: Date, before: Date,
): Date[] => { ): Date[] => {
const targetDay = DAY_INDEX[entry.day]
if (targetDay === undefined) return []
if (before.getTime() < after.getTime()) return [] if (before.getTime() < after.getTime()) return []
const dates: Date[] = [] const dates: Date[] = []
const afterStart = startOfDay(after) const afterStart = startOfDay(after)
const beforeEnd = startOfDay(before) 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) { switch (entry.frequency) {
case 'daily': {
let cursor = afterStart
while (cursor.getTime() <= beforeEnd.getTime()) {
dates.push(cursor)
cursor = addDays(cursor, 1)
}
return dates
}
case 'weekly': { case 'weekly': {
let cursor = firstOccurrenceOnOrAfter(afterStart, targetDay!) let cursor = firstOccurrenceOnOrAfter(afterStart, targetDay)
while (cursor.getTime() <= beforeEnd.getTime()) { while (cursor.getTime() <= beforeEnd.getTime()) {
dates.push(cursor) dates.push(cursor)
cursor = addDays(cursor, 7) cursor = addDays(cursor, 7)
@ -141,7 +120,7 @@ export const generateOccurrenceDates = (
const anchor = startOfDay(new Date(entry.biweeklyAnchor)) const anchor = startOfDay(new Date(entry.biweeklyAnchor))
if (Number.isNaN(anchor.getTime())) return [] 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 // Align cursor with anchor's 2-week parity. Compute the whole-day
// delta using midday to avoid DST rounding pushing it off by one. // delta using midday to avoid DST rounding pushing it off by one.
const daysFromAnchor = Math.round( const daysFromAnchor = Math.round(
@ -158,38 +137,6 @@ export const generateOccurrenceDates = (
return dates 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': { case 'monthlyByWeekday': {
if (!entry.weekOfMonth) return [] if (!entry.weekOfMonth) return []
const n = WEEK_OF_MONTH_N[entry.weekOfMonth] const n = WEEK_OF_MONTH_N[entry.weekOfMonth]
@ -200,7 +147,7 @@ export const generateOccurrenceDates = (
const endMonth = beforeEnd.getMonth() const endMonth = beforeEnd.getMonth()
while (year < endYear || (year === endYear && month <= endMonth)) { while (year < endYear || (year === endYear && month <= endMonth)) {
const occ = nthWeekdayOfMonth(year, month, targetDay!, n) const occ = nthWeekdayOfMonth(year, month, targetDay, n)
if ( if (
occ && occ &&
occ.getTime() >= afterStart.getTime() && 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_075155 from './20260417_075155';
import * as migration_20260417_111855_event_occurrences from './20260417_111855_event_occurrences'; 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_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 = [ export const migrations = [
{ {
@ -252,16 +250,6 @@ export const migrations = [
{ {
up: migration_20260417_114727_simplify_recurring_events.up, up: migration_20260417_114727_simplify_recurring_events.up,
down: migration_20260417_114727_simplify_recurring_events.down, 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',
},
{
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'
}, },
]; ];

View file

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

View file

@ -1064,8 +1064,6 @@ export interface Event {
id: string; id: string;
title: string; title: string;
date: string; date: string;
endDateTime?: string | null;
cancelled: boolean;
location: string | Location; location: string | Location;
shortDescription: string; shortDescription: string;
description: string; description: string;
@ -1076,25 +1074,14 @@ export interface Event {
photo?: (string | null) | Media; photo?: (string | null) | Media;
flyer?: (string | null) | Document; 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'; recurrenceType: 'none' | 'weekly' | 'biweekly';
/**
* 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. * Optional. Nach diesem Datum werden keine weiteren Termine erzeugt.
*/ */
endDate?: string | null; endDate?: string | null;
cancelled: boolean;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
_status?: ('draft' | 'published') | null; _status?: ('draft' | 'published') | null;
@ -1826,8 +1813,6 @@ export interface HighlightSelect<T extends boolean = true> {
export interface EventSelect<T extends boolean = true> { export interface EventSelect<T extends boolean = true> {
title?: T; title?: T;
date?: T; date?: T;
endDateTime?: T;
cancelled?: T;
location?: T; location?: T;
shortDescription?: T; shortDescription?: T;
description?: T; description?: T;
@ -1838,16 +1823,8 @@ export interface EventSelect<T extends boolean = true> {
photo?: T; photo?: T;
flyer?: T; flyer?: T;
recurrenceType?: T; recurrenceType?: T;
recurrenceRules?:
| T
| {
frequency?: T;
weekday?: T;
weekOfMonth?: T;
dayOfMonth?: T;
id?: T;
};
endDate?: T; endDate?: T;
cancelled?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
_status?: T; _status?: T;

View file

@ -1,7 +1,6 @@
import type { ComponentProps } from 'react' import type { ComponentProps } from 'react'
import { Event, EventOccurrence } from '@/payload-types' import { Event, EventOccurrence } from '@/payload-types'
import type { EventPage } from '@/pageComponents/Event/Event' import type { EventPage } from '@/pageComponents/Event/Event'
import { describeRecurrence } from '@/utils/recurrenceDescription'
export const getEventGroupSlug = (event: Event): string | undefined => { export const getEventGroupSlug = (event: Event): string | undefined => {
const firstGroup = event.group?.[0] const firstGroup = event.group?.[0]
@ -22,13 +21,9 @@ export const eventToPageProps = (
id: event.id, id: event.id,
title: event.title, title: event.title,
date: occurrence?.date ?? event.date, date: occurrence?.date ?? event.date,
endDateTime: event.endDateTime ?? undefined,
createdAt: event.createdAt, createdAt: event.createdAt,
cancelled: Boolean(event.cancelled || occurrence?.cancelled), cancelled: Boolean(event.cancelled || occurrence?.cancelled),
recurrenceType: event.recurrenceType, 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, location: event.location,
description: event.description, description: event.description,
shortDescription: event.shortDescription, shortDescription: event.shortDescription,

View file

@ -1,15 +1,11 @@
/** /**
* Return a readable date time * Return a readable date time
* e.G. Samstag 13-01-2024, 12:00 Uhr * 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 dateObj = new Date(date);
const dayName = dateObj.toLocaleDateString("de-DE", { weekday: "long" }); const dayName = dateObj.toLocaleDateString("de-DE", { weekday: "long" });
const normalDate = dateObj.toLocaleDateString("de-DE", { dateStyle: "medium" }); const normalDate = dateObj.toLocaleDateString("de-DE", { dateStyle: "medium" });
const time = dateObj.toLocaleTimeString("de-DE", { timeStyle: "short", timeZone: "Europe/Berlin" }); const time = dateObj.toLocaleTimeString("de-DE", { timeStyle: "short", timeZone: "Europe/Berlin" });
const base = `${dayName} ${normalDate}, ${time} Uhr`; return `${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`;
} }

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
}
}