Recurrence Plugin
Create and manage repeating events with powerful RRULE-based recurrence patterns following the iCalendar specification.
The recurrence system in ilamy Calendar supports creating repeating events using RRULE (Recurrence Rule) patterns based on the iCalendar RFC 5545 specification. Under the hood, it uses the powerful rrule.js library to generate recurring event instances, ensuring robust and reliable recurrence handling.
RFC 5545 Compliant: The recurrence system uses the industry-standard RRULE format powered by rrule.js, ensuring compatibility with other calendar applications when exporting.
Key Features
Section titled “Key Features”Flexible Patterns
Section titled “Flexible Patterns”- Daily, weekly, monthly, yearly recurrence
- Custom intervals (every 2 weeks, every 3 months, etc.)
- Specific weekdays (Mondays and Fridays)
- Month-specific patterns (last Friday of month)
- Count-based or date-based endings
Advanced Management
Section titled “Advanced Management”- Exception dates (skip specific occurrences)
- Modified instances (edit individual occurrences)
- Dynamic generation within date ranges
- Efficient instance filtering
- Override detection and handling
Creating Recurring Events
Section titled “Creating Recurring Events”Basic Recurring Event
Section titled “Basic Recurring Event”Create a recurring event by adding an rrule property to your event object:
import { dayjs } from '@ilamy/calendar'import { RRule } from '@ilamy/calendar/plugins/recurrence'
const weeklyMeeting = { id: 'weekly-standup', title: 'Weekly Team Standup', start: dayjs('2025-08-04T09:00:00'), end: dayjs('2025-08-04T10:00:00'), description: 'Weekly team sync meeting', rrule: { freq: RRule.WEEKLY, interval: 1, byweekday: [RRule.MO], // Every Monday dtstart: dayjs('2025-08-04T09:00:00').toDate(), until: dayjs('2025-12-31T23:59:59').toDate() }}Common Recurrence Patterns
Section titled “Common Recurrence Patterns”Daily Meetings
Section titled “Daily Meetings”rrule: { freq: RRule.DAILY, interval: 1, dtstart: new Date('2025-08-04T09:00:00'), count: 30 // 30 occurrences}Bi-weekly Events
Section titled “Bi-weekly Events”rrule: { freq: RRule.WEEKLY, interval: 2, // Every 2 weeks byweekday: [RRule.FR], // Fridays dtstart: new Date('2025-08-04T14:00:00'), until: new Date('2026-08-04T14:00:00')}Monthly on Specific Weekday
Section titled “Monthly on Specific Weekday”rrule: { freq: RRule.MONTHLY, byweekday: [RRule.FR.nth(-1)], // Last Friday of each month dtstart: new Date('2025-08-04T15:00:00'), count: 12 // 12 months}Work Week Days
Section titled “Work Week Days”rrule: { freq: RRule.WEEKLY, byweekday: [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR], dtstart: new Date('2025-08-04T09:00:00'), until: new Date('2025-12-31T23:59:59')}Instance Generation
Section titled “Instance Generation”Generation (with the recurrence plugin)
Section titled “Generation (with the recurrence plugin)”When the recurrence plugin is registered, the calendar generates recurring event instances within the current view range using rrule.js internally. This happens transparently when navigating between different calendar views. Without the plugin, a base event with an rrule is rendered as a single event and never expanded.
import { IlamyCalendar } from '@ilamy/calendar'import { recurrencePlugin } from '@ilamy/calendar/plugins/recurrence'
const events = [ { id: 'weekly-standup', title: 'Weekly Team Standup', start: dayjs('2025-08-04T09:00:00'), end: dayjs('2025-08-04T10:00:00'), rrule: { freq: RRule.WEEKLY, byweekday: [RRule.MO], dtstart: dayjs('2025-08-04T09:00:00').toDate(), until: dayjs('2025-12-31T23:59:59').toDate() } }]
// Register the plugin so instances are generated for the current view<IlamyCalendar events={events} plugins={[recurrencePlugin()]} />Handling Overrides
Section titled “Handling Overrides”The system automatically handles modified instances and exception dates:
Modified Instances
Section titled “Modified Instances”When you modify a single occurrence of a recurring event, it creates a new event with a recurrenceId that matches the original occurrence time.
An override replaces the occurrence it names: the series expansion skips that
occurrence, and the override is what renders — wherever it has been moved to,
including outside the range the series was expanded over. You do not also need an
exdates entry for it, and adding one changes nothing.
const modifiedInstance = { id: 'weekly-standup-modified', title: 'Extended Team Standup', start: dayjs('2025-08-11T09:00:00'), end: dayjs('2025-08-11T11:00:00'), // Extended duration recurrenceId: '2025-08-11T09:00:00.000Z', // Original occurrence time uid: 'weekly-standup' // Same UID as base event}Exception Dates
Section titled “Exception Dates”Use exdates to skip specific occurrences without creating modified instances.
Entries are ISO strings (exdates?: string[]) keyed to the original
occurrence time, so they must be produced with .toISOString() — a Date never
matches.
const recurringEvent = { // ... other properties rrule: { freq: RRule.WEEKLY, byweekday: [RRule.MO], dtstart: new Date('2025-08-04T09:00:00') }, exdates: [ dayjs('2025-08-18T09:00:00').toISOString(), // Skip this Monday dayjs('2025-08-25T09:00:00').toISOString() // Skip this Monday too ]}Editing and Deleting a Series
Section titled “Editing and Deleting a Series”Editing or deleting an occurrence asks which part of the series it applies to. The three scopes are exported as RecurrenceEditScope from @ilamy/calendar/plugins/recurrence.
| Scope | Edit | Delete |
|---|---|---|
'this' |
Excludes the occurrence from the series and adds an override for it | Excludes the occurrence from the series |
'following' |
Ends the series the day before and starts a new one from the occurrence | Ends the series the day before |
'all' |
Changes the series; occurrences moved or deleted one by one return to it | Removes the series |
Every stored row a scope touches is reported, so your backend stays in step: the series is reported to onEventUpdate, a new override or series to onEventAdd, and anything removed to onEventDelete. With “this and following”, and with “all”, that includes occurrences that were moved one by one and fall in the affected part.
One action can therefore call those callbacks several times. To save it as one unit, use onEventsChange, called once per action with every row it touched:
import type { RecurrenceEditScope } from '@ilamy/calendar/plugins/recurrence'
<IlamyCalendar events={events} plugins={[recurrencePlugin()]} onEventsChange={(change) => { const scope = change.scope as RecurrenceEditScope | undefined api.saveChange({ action: change.action, // 'add' | 'update' | 'delete' scope, // 'this' | 'following' | 'all' for a recurring edit added: change.added, updated: change.updated, deleted: change.deleted, }) }}/>The arrays hold stored rows, not occurrences: a daily series over a whole year is one row, so they stay small. See onEventsChange.
RRULE Properties
Section titled “RRULE Properties”The RRULE object supports the following properties based on the iCalendar specification. These properties are processed by rrule.js to generate recurring event instances.
rrule.js Documentation: For comprehensive documentation of all RRULE properties and advanced patterns, refer to the rrule.js documentation.
| Property | Type | Description | Example |
|---|---|---|---|
freq |
Frequency | How often the event repeats | RRule.DAILY |
interval |
number | Interval between occurrences | 2 (every 2 weeks) |
byweekday |
Weekday[] | Specific days of the week | [RRule.MO, RRule.FR] |
bymonthday |
number[] | Specific days of the month | [1, 15] (1st and 15th) |
bymonth |
number[] | Specific months | [1, 7] (Jan and July) |
dtstart |
Date | Start date for recurrence | new Date('2025-08-04') |
until |
Date | End date for recurrence | new Date('2025-12-31') |
count |
number | Number of occurrences | 10 (10 times) |
Note: You cannot use both until and count in the same RRULE. Choose one termination method.
Advanced Examples
Section titled “Advanced Examples”Complex Recurrence Pattern
Section titled “Complex Recurrence Pattern”First Monday of every quarter:
const quarterlyMeeting = { id: 'quarterly-review', title: 'Quarterly Business Review', start: dayjs('2025-01-06T14:00:00'), // First Monday of 2025 end: dayjs('2025-01-06T16:00:00'), rrule: { freq: RRule.MONTHLY, interval: 3, // Every 3 months byweekday: [RRule.MO.nth(1)], // First Monday dtstart: dayjs('2025-01-06T14:00:00').toDate() }}Event with Exceptions and Modifications
Section titled “Event with Exceptions and Modifications”// Base recurring eventconst weeklyStandup = { id: 'standup', title: 'Team Standup', start: dayjs('2025-08-04T09:00:00'), end: dayjs('2025-08-04T09:30:00'), uid: 'standup-series', rrule: { freq: RRule.WEEKLY, byweekday: [RRule.MO, RRule.WE, RRule.FR], dtstart: dayjs('2025-08-04T09:00:00').toDate(), until: dayjs('2025-12-31T23:59:59').toDate() }, exdates: [ // Skip these occurrences. Each is the occurrence's start as an ISO string, // exactly as the series produces it: a Date, or another time of day, // matches nothing. dayjs('2025-08-15T09:00:00').toISOString(), // Summer vacation dayjs('2025-11-28T09:00:00').toISOString() // Thanksgiving week ]}
// Modified instance for a different timeconst modifiedStandup = { id: 'standup-modified-aug-20', title: 'Extended Team Standup + Planning', start: dayjs('2025-08-20T10:00:00'), // Different time end: dayjs('2025-08-20T11:00:00'), // Longer duration recurrenceId: '2025-08-20T09:00:00.000Z', // Original time uid: 'standup-series'}Working with Multiple Time Zones
Section titled “Working with Multiple Time Zones”A series repeats at the same clock time in the calendar’s zone (the timezone prop, or the browser’s zone without one), including across a daylight-saving change. This is what RFC 5545 asks of a start with a time zone: instances “start at the same local time regardless of time zone changes” (§3.8.5.3). A clock time skipped by the spring change moves forward by the gap, and one repeated by the autumn change takes its first occurrence (§3.3.5).
// In a calendar with timezone="America/New_York"const morningSync = { id: 'morning-sync', title: 'Morning Sync', start: dayjs('2025-03-03T09:00'), // 09:00 New York (EST, 14:00 UTC) end: dayjs('2025-03-03T09:30'), rrule: { freq: RRule.WEEKLY, byweekday: [RRule.MO], count: 26 }}// Every Monday at 09:00 New York time: 14:00 UTC until 9 March,// 13:00 UTC from 10 March, when New York is on daylight time.If a meeting must stay at a fixed UTC time instead, run the calendar in timezone="UTC".
Best Practices
Section titled “Best Practices”Consistent UIDs
Section titled “Consistent UIDs”Use consistent UIDs across the base event and all modified instances to ensure proper override detection.
Date Handling
Section titled “Date Handling”Always use consistent timezone handling. Convert to UTC for storage and use local timezone for display.
Performance Optimization
Section titled “Performance Optimization”Generate instances only for the current view range. The calendar automatically handles this during navigation.
Exception Handling
Section titled “Exception Handling”Use exception dates for simple cancellations and modified instances for changes to time, duration, or other properties.
Integration with Other Features
Section titled “Integration with Other Features”iCalendar Export
Section titled “iCalendar Export”Recurring events are automatically converted to proper RRULE format when exporting to .ics files, maintaining compatibility with other calendar applications.
Calendar Views
Section titled “Calendar Views”All calendar views (month, week, day) automatically handle recurring events, generating and displaying instances as needed for the current view range.