407 lines
12 KiB
Markdown
407 lines
12 KiB
Markdown
|
|
# Recurring Events Implementation Guide
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
This document describes the complete implementation of recurring events in the CalDAV calendar application. The implementation follows the iCalendar RFC 5545 standard for RRULE (Recurrence Rule).
|
||
|
|
|
||
|
|
## Architecture
|
||
|
|
|
||
|
|
```
|
||
|
|
┌─────────────────────────────────────────────────────────────┐
|
||
|
|
│ Frontend (Next.js) │
|
||
|
|
├─────────────────────────────────────────────────────────────┤
|
||
|
|
│ RecurrenceEditor Component │
|
||
|
|
│ ├─ UI for frequency selection (DAILY/WEEKLY/MONTHLY/YEARLY) │
|
||
|
|
│ ├─ Interval input │
|
||
|
|
│ ├─ Day/Month/Date selection │
|
||
|
|
│ └─ End conditions (never/until/count) │
|
||
|
|
│ │
|
||
|
|
│ EventCalendarAdapter │
|
||
|
|
│ ├─ Converts IcsRecurrenceRule to RRULE string │
|
||
|
|
│ └─ Parses RRULE to IcsRecurrenceRule │
|
||
|
|
├─────────────────────────────────────────────────────────────┤
|
||
|
|
│ ts-ics Library │
|
||
|
|
│ IcsRecurrenceRule interface │
|
||
|
|
│ ├─ frequency: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' │
|
||
|
|
│ ├─ interval?: number │
|
||
|
|
│ ├─ byDay?: IcsWeekDay[] │
|
||
|
|
│ ├─ byMonthDay?: number[] │
|
||
|
|
│ ├─ byMonth?: number[] │
|
||
|
|
│ ├─ count?: number │
|
||
|
|
│ └─ until?: IcsDateObject │
|
||
|
|
├─────────────────────────────────────────────────────────────┤
|
||
|
|
│ CalDAV Protocol │
|
||
|
|
│ RRULE property in VEVENT │
|
||
|
|
│ Example: RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR │
|
||
|
|
├─────────────────────────────────────────────────────────────┤
|
||
|
|
│ Sabre/dav Server (PHP) │
|
||
|
|
│ Stores and serves iCalendar (.ics) files │
|
||
|
|
│ Handles recurring event expansion │
|
||
|
|
└─────────────────────────────────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
## Component Structure
|
||
|
|
|
||
|
|
### RecurrenceEditor Component
|
||
|
|
|
||
|
|
Location: `src/features/calendar/components/RecurrenceEditor.tsx`
|
||
|
|
|
||
|
|
**Props:**
|
||
|
|
```typescript
|
||
|
|
interface RecurrenceEditorProps {
|
||
|
|
value?: IcsRecurrenceRule;
|
||
|
|
onChange: (rule: IcsRecurrenceRule | undefined) => void;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Features:**
|
||
|
|
- ✅ Simple mode: Quick selection (None, Daily, Weekly, Monthly, Yearly)
|
||
|
|
- ✅ Custom mode: Full control over all recurrence parameters
|
||
|
|
- ✅ DAILY: Interval support (every X days)
|
||
|
|
- ✅ WEEKLY: Interval + day selection (MO, TU, WE, TH, FR, SA, SU)
|
||
|
|
- ✅ MONTHLY: Day of month (1-31) with validation
|
||
|
|
- ✅ YEARLY: Month + day selection with leap year support
|
||
|
|
- ✅ End conditions: Never / Until date / After N occurrences
|
||
|
|
- ✅ Date validation warnings (Feb 30th, Feb 29th leap year, etc.)
|
||
|
|
|
||
|
|
### Example Usage
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
import { RecurrenceEditor } from '@/features/calendar/components/RecurrenceEditor';
|
||
|
|
import { useState } from 'react';
|
||
|
|
import type { IcsRecurrenceRule } from 'ts-ics';
|
||
|
|
|
||
|
|
function EventForm() {
|
||
|
|
const [recurrence, setRecurrence] = useState<IcsRecurrenceRule | undefined>();
|
||
|
|
|
||
|
|
return (
|
||
|
|
<form>
|
||
|
|
{/* Other event fields */}
|
||
|
|
|
||
|
|
<RecurrenceEditor
|
||
|
|
value={recurrence}
|
||
|
|
onChange={setRecurrence}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Save button */}
|
||
|
|
</form>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Integration with Scheduler
|
||
|
|
|
||
|
|
To integrate the RecurrenceEditor into the existing Scheduler modal, add the following:
|
||
|
|
|
||
|
|
### 1. Add recurrence state
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// In EventModal component
|
||
|
|
const [recurrence, setRecurrence] = useState<IcsRecurrenceRule | undefined>(
|
||
|
|
event?.recurrenceRule
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. Add RecurrenceEditor to the form
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
import { RecurrenceEditor } from '../RecurrenceEditor';
|
||
|
|
|
||
|
|
// In the modal JSX, after location/description fields
|
||
|
|
<RecurrenceEditor
|
||
|
|
value={recurrence}
|
||
|
|
onChange={setRecurrence}
|
||
|
|
/>
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. Include recurrence in event save
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const icsEvent: IcsEvent = {
|
||
|
|
// ... existing fields
|
||
|
|
recurrenceRule: recurrence,
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4. Reset recurrence when modal opens
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
useEffect(() => {
|
||
|
|
// ... existing resets
|
||
|
|
setRecurrence(event?.recurrenceRule);
|
||
|
|
}, [event]);
|
||
|
|
```
|
||
|
|
|
||
|
|
## RRULE Examples
|
||
|
|
|
||
|
|
### Daily Recurrence
|
||
|
|
|
||
|
|
**Every day:**
|
||
|
|
```
|
||
|
|
RRULE:FREQ=DAILY;INTERVAL=1
|
||
|
|
```
|
||
|
|
|
||
|
|
**Every 3 days:**
|
||
|
|
```
|
||
|
|
RRULE:FREQ=DAILY;INTERVAL=3
|
||
|
|
```
|
||
|
|
|
||
|
|
**Daily for 10 occurrences:**
|
||
|
|
```
|
||
|
|
RRULE:FREQ=DAILY;COUNT=10
|
||
|
|
```
|
||
|
|
|
||
|
|
**Daily until Dec 31, 2025:**
|
||
|
|
```
|
||
|
|
RRULE:FREQ=DAILY;UNTIL=20251231T235959Z
|
||
|
|
```
|
||
|
|
|
||
|
|
### Weekly Recurrence
|
||
|
|
|
||
|
|
**Every week on Monday:**
|
||
|
|
```
|
||
|
|
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||
|
|
```
|
||
|
|
|
||
|
|
**Every 2 weeks on Monday and Friday:**
|
||
|
|
```
|
||
|
|
RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR
|
||
|
|
```
|
||
|
|
|
||
|
|
**Weekly on weekdays (Mon-Fri):**
|
||
|
|
```
|
||
|
|
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR
|
||
|
|
```
|
||
|
|
|
||
|
|
### Monthly Recurrence
|
||
|
|
|
||
|
|
**Every month on the 15th:**
|
||
|
|
```
|
||
|
|
RRULE:FREQ=MONTHLY;BYMONTHDAY=15
|
||
|
|
```
|
||
|
|
|
||
|
|
**Every 3 months on the 1st:**
|
||
|
|
```
|
||
|
|
RRULE:FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1
|
||
|
|
```
|
||
|
|
|
||
|
|
**Monthly on the last day (31st with fallback):**
|
||
|
|
```
|
||
|
|
RRULE:FREQ=MONTHLY;BYMONTHDAY=31
|
||
|
|
```
|
||
|
|
Note: For months with fewer than 31 days, most implementations skip that occurrence.
|
||
|
|
|
||
|
|
### Yearly Recurrence
|
||
|
|
|
||
|
|
**Every year on March 15th:**
|
||
|
|
```
|
||
|
|
RRULE:FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=15
|
||
|
|
```
|
||
|
|
|
||
|
|
**Every year on February 29th (leap years only):**
|
||
|
|
```
|
||
|
|
RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29
|
||
|
|
```
|
||
|
|
|
||
|
|
**Every 2 years on December 25th:**
|
||
|
|
```
|
||
|
|
RRULE:FREQ=YEARLY;INTERVAL=2;BYMONTH=12;BYMONTHDAY=25
|
||
|
|
```
|
||
|
|
|
||
|
|
## Date Validation
|
||
|
|
|
||
|
|
The RecurrenceEditor includes smart validation for invalid dates:
|
||
|
|
|
||
|
|
### February 30th/31st
|
||
|
|
**Warning:** "February has at most 29 days"
|
||
|
|
|
||
|
|
### February 29th
|
||
|
|
**Warning:** "This date (Feb 29) only exists in leap years"
|
||
|
|
|
||
|
|
### April 31st, June 31st, etc.
|
||
|
|
**Warning:** "This month has at most 30 days"
|
||
|
|
|
||
|
|
### Day > 31
|
||
|
|
**Warning:** "Day must be between 1 and 31"
|
||
|
|
|
||
|
|
## IcsRecurrenceRule Interface (ts-ics)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
interface IcsRecurrenceRule {
|
||
|
|
frequency: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
|
||
|
|
interval?: number; // Default: 1
|
||
|
|
count?: number; // Number of occurrences
|
||
|
|
until?: IcsDateObject; // End date
|
||
|
|
byDay?: IcsWeekDay[]; // Days of week (WEEKLY)
|
||
|
|
byMonthDay?: number[]; // Days of month (MONTHLY, YEARLY)
|
||
|
|
byMonth?: number[]; // Months (YEARLY)
|
||
|
|
bySetPos?: number[]; // Position (e.g., 1st Monday)
|
||
|
|
weekStart?: IcsWeekDay; // Week start day
|
||
|
|
}
|
||
|
|
|
||
|
|
type IcsWeekDay = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
|
||
|
|
```
|
||
|
|
|
||
|
|
## Backend Considerations
|
||
|
|
|
||
|
|
The Django backend **does not need modifications** for recurring events. CalDAV handles recurrence natively:
|
||
|
|
|
||
|
|
1. **Storage:** RRULE is stored as a property in the VEVENT within the .ics file
|
||
|
|
2. **Expansion:** Sabre/dav handles recurring event expansion when clients query date ranges
|
||
|
|
3. **Modifications:** Individual instances can be modified by creating exception events with RECURRENCE-ID
|
||
|
|
|
||
|
|
### Example .ics file with recurrence
|
||
|
|
|
||
|
|
```ics
|
||
|
|
BEGIN:VCALENDAR
|
||
|
|
VERSION:2.0
|
||
|
|
PRODID:-//CalDavService//NONSGML v1.0//EN
|
||
|
|
METHOD:PUBLISH
|
||
|
|
BEGIN:VEVENT
|
||
|
|
UID:abc-123-def-456
|
||
|
|
DTSTART:20260125T140000Z
|
||
|
|
DTEND:20260125T150000Z
|
||
|
|
SUMMARY:Weekly Team Meeting
|
||
|
|
RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20261231T235959Z
|
||
|
|
ORGANIZER;CN=Alice:mailto:alice@example.com
|
||
|
|
ATTENDEE;CN=Bob;PARTSTAT=NEEDS-ACTION:mailto:bob@example.com
|
||
|
|
END:VEVENT
|
||
|
|
END:VCALENDAR
|
||
|
|
```
|
||
|
|
|
||
|
|
## Testing
|
||
|
|
|
||
|
|
### Manual Testing Checklist
|
||
|
|
|
||
|
|
- [ ] Daily recurrence with interval 1, 3, 7
|
||
|
|
- [ ] Weekly recurrence with single day (Monday)
|
||
|
|
- [ ] Weekly recurrence with multiple days (Mon, Wed, Fri)
|
||
|
|
- [ ] Weekly recurrence with interval 2
|
||
|
|
- [ ] Monthly recurrence on day 1, 15, 31
|
||
|
|
- [ ] Monthly recurrence with February validation
|
||
|
|
- [ ] Yearly recurrence on Jan 1, Dec 25
|
||
|
|
- [ ] Yearly recurrence on Feb 29 with leap year warning
|
||
|
|
- [ ] Never-ending recurrence
|
||
|
|
- [ ] Until date recurrence
|
||
|
|
- [ ] Count-based recurrence (10 occurrences)
|
||
|
|
- [ ] Edit recurring event
|
||
|
|
- [ ] Delete recurring event
|
||
|
|
|
||
|
|
### Test Cases
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Test: Weekly on Monday and Friday
|
||
|
|
const rule: IcsRecurrenceRule = {
|
||
|
|
frequency: 'WEEKLY',
|
||
|
|
interval: 1,
|
||
|
|
byDay: [{ day: 'MO' }, { day: 'FR' }],
|
||
|
|
};
|
||
|
|
// Expected RRULE: FREQ=WEEKLY;BYDAY=MO,FR
|
||
|
|
|
||
|
|
// Test: Monthly on 31st (handles months with fewer days)
|
||
|
|
const rule: IcsRecurrenceRule = {
|
||
|
|
frequency: 'MONTHLY',
|
||
|
|
interval: 1,
|
||
|
|
byMonthDay: [31],
|
||
|
|
};
|
||
|
|
// Expected RRULE: FREQ=MONTHLY;BYMONTHDAY=31
|
||
|
|
|
||
|
|
// Test: Yearly on Feb 29
|
||
|
|
const rule: IcsRecurrenceRule = {
|
||
|
|
frequency: 'YEARLY',
|
||
|
|
interval: 1,
|
||
|
|
byMonth: [2],
|
||
|
|
byMonthDay: [29],
|
||
|
|
count: 10,
|
||
|
|
};
|
||
|
|
// Expected RRULE: FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29;COUNT=10
|
||
|
|
```
|
||
|
|
|
||
|
|
## Translations
|
||
|
|
|
||
|
|
All UI strings are internationalized (i18n) with support for:
|
||
|
|
- 🇬🇧 English
|
||
|
|
- 🇫🇷 French
|
||
|
|
- 🇳🇱 Dutch
|
||
|
|
|
||
|
|
Translation keys are defined in `src/features/i18n/translations.json`:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"calendar": {
|
||
|
|
"recurrence": {
|
||
|
|
"label": "Repeat",
|
||
|
|
"daily": "Daily",
|
||
|
|
"weekly": "Weekly",
|
||
|
|
"monthly": "Monthly",
|
||
|
|
"yearly": "Yearly",
|
||
|
|
"repeatOnDay": "Repeat on day",
|
||
|
|
"repeatOnDate": "Repeat on date",
|
||
|
|
"dayOfMonth": "Day",
|
||
|
|
"months": {
|
||
|
|
"january": "January",
|
||
|
|
"february": "February",
|
||
|
|
...
|
||
|
|
},
|
||
|
|
"warnings": {
|
||
|
|
"februaryMax": "February has at most 29 days",
|
||
|
|
"leapYear": "This date (Feb 29) only exists in leap years",
|
||
|
|
...
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Styling
|
||
|
|
|
||
|
|
Styles are in `RecurrenceEditor.scss` using BEM methodology:
|
||
|
|
|
||
|
|
```scss
|
||
|
|
.recurrence-editor {
|
||
|
|
&__label { ... }
|
||
|
|
&__weekday-button { ... }
|
||
|
|
&__weekday-button--selected { ... }
|
||
|
|
&__warning { ... }
|
||
|
|
}
|
||
|
|
|
||
|
|
.recurrence-editor-layout {
|
||
|
|
&--row { ... }
|
||
|
|
&--gap-1rem { ... }
|
||
|
|
&--flex-wrap { ... }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Known Limitations
|
||
|
|
|
||
|
|
1. **No BYDAY with position** (e.g., "2nd Tuesday of month")
|
||
|
|
- Future enhancement
|
||
|
|
- Requires UI for "1st/2nd/3rd/4th/last" + weekday selection
|
||
|
|
|
||
|
|
2. **No BYSETPOS** (complex patterns)
|
||
|
|
- e.g., "Last Friday of every month"
|
||
|
|
- Requires advanced UI
|
||
|
|
|
||
|
|
3. **Time zone handling**
|
||
|
|
- UNTIL dates are converted to UTC
|
||
|
|
- Local time events use floating time
|
||
|
|
|
||
|
|
4. **Recurring event modifications**
|
||
|
|
- Editing single instance creates exception (RECURRENCE-ID)
|
||
|
|
- Not yet implemented in UI (future work)
|
||
|
|
|
||
|
|
## Future Enhancements
|
||
|
|
|
||
|
|
- [ ] Visual calendar preview of recurrence pattern
|
||
|
|
- [ ] Natural language summary ("Every 2 weeks on Monday and Friday")
|
||
|
|
- [ ] Support for BYSETPOS (nth occurrence patterns)
|
||
|
|
- [ ] Exception handling UI for editing single instances
|
||
|
|
- [ ] Recurring event series deletion options (this only / this and future / all)
|
||
|
|
|
||
|
|
## References
|
||
|
|
|
||
|
|
- [RFC 5545 - iCalendar](https://datatracker.ietf.org/doc/html/rfc5545)
|
||
|
|
- [RRULE Specification](https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html)
|
||
|
|
- [ts-ics Documentation](https://github.com/Neuvernetzung/ts-ics)
|
||
|
|
- [Sabre/dav Documentation](https://sabre.io/dav/)
|