Compare commits
No commits in common. "3e836bb0165044ca4db18d3d4e1a4e54779de59b" and "f4afd2ff7757b405f4a19d13d8e22a24f0814260" have entirely different histories.
3e836bb016
...
f4afd2ff77
19 changed files with 61 additions and 51018 deletions
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 recurring→once
|
||||
* 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,24 +131,34 @@ 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()
|
||||
await payload.create({
|
||||
collection: 'eventOccurrence',
|
||||
data: {
|
||||
event: event.id,
|
||||
date: iso,
|
||||
cancelled: cancelledDates.has(iso),
|
||||
generated: true,
|
||||
},
|
||||
req,
|
||||
})
|
||||
created += 1
|
||||
const cursor = new Date(eventDate)
|
||||
while (cursor.getTime() <= effectiveEnd.getTime()) {
|
||||
if (cursor.getTime() >= now.getTime()) {
|
||||
const iso = cursor.toISOString()
|
||||
await payload.create({
|
||||
collection: 'eventOccurrence',
|
||||
data: {
|
||||
event: event.id,
|
||||
date: iso,
|
||||
cancelled: cancelledDates.has(iso),
|
||||
generated: true,
|
||||
},
|
||||
req,
|
||||
})
|
||||
created += 1
|
||||
} else {
|
||||
skipped += 1
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + step)
|
||||
}
|
||||
|
||||
return { created, deleted, skipped }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue