Overview
Chronos Calendar provides full support for recurring events using the iCalendar RRULE standard. This includes complex recurrence patterns, exceptions (cancelled/modified instances), and efficient expansion for display.
How Recurring Events Work
Master Events
A recurring event series is represented by a “master event” that contains:
RRULE : Recurrence rule defining the pattern
EXDATE : Exception dates (instances that don’t occur)
RDATE : Additional dates (extra instances)
Start Date : First occurrence
Duration : Length of each instance
Master events are not displayed directly in the calendar. They serve as templates for generating individual instances.
Event Expansion
To display recurring events, Chronos expands masters into individual instances:
// From recurrence.ts:110-119
export function expandRecurringEvents (
masters : CalendarEvent [],
exceptions : CalendarEvent [],
rangeStart : Date ,
rangeEnd : Date
) : ExpandedEvent [] {
const cacheKey = computeCacheKey ( masters , exceptions , rangeStart , rangeEnd )
if ( expansionCache && expansionCache . key === cacheKey ) {
return [ ... expansionCache . result ] // Return cached result
}
Expansion results are cached to avoid recalculating when the view hasn’t changed. This significantly improves performance.
Recurrence Rules (RRULE)
Supported Patterns
Daily
Weekly
Monthly
Yearly
Custom
RRULE:FREQ=DAILY;INTERVAL=1
Repeats every day. Example: Daily standup meeting RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
Repeats on specific days of the week. Example: Gym sessions on Monday, Wednesday, Friday RRULE:FREQ=MONTHLY;BYMONTHDAY=15
Repeats on a specific day each month. Example: Monthly team meeting on the 15th RRULE:FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25
Repeats annually on a specific date. Example: Christmas celebration RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,TH;UNTIL=20241231T235959Z
Complex patterns with multiple constraints:
Every 2 weeks
On Tuesday and Thursday
Until end of 2024
RRULE Components
FREQ Frequency: DAILY, WEEKLY, MONTHLY, YEARLY
INTERVAL Repeat every N periods (e.g., every 2 weeks)
BYDAY Days of week: MO, TU, WE, TH, FR, SA, SU
BYMONTHDAY Day of month: 1-31
COUNT Maximum number of occurrences
UNTIL End date for the series
Building RRULE Strings
The system constructs complete RRULE strings with proper date/time handling:
// From recurrence.ts:92-108
function buildRRuleString ( dtstart : Date , rruleStrings : string [], options ?: { timeZone ?: string ; allDay ?: boolean }) : string {
if ( options ?. allDay ) {
const year = dtstart . getFullYear ()
const month = String ( dtstart . getMonth () + 1 ). padStart ( 2 , '0' )
const day = String ( dtstart . getDate ()). padStart ( 2 , '0' )
return `DTSTART;VALUE=DATE: ${ year }${ month }${ day } \n ${ rruleStrings . join ( ' \n ' ) } `
}
if ( options ?. timeZone ) {
const localTime = formatDateTimeForTimezone ( dtstart , options . timeZone )
const formatted = localTime . replace ( / [ -: ] / g , '' )
return `DTSTART;TZID= ${ options . timeZone } : ${ formatted } \n ${ rruleStrings . join ( ' \n ' ) } `
}
const utcFormatted = dtstart . toISOString (). replace ( / [ -: ] / g , '' ). split ( '.' )[ 0 ] + 'Z'
return `DTSTART: ${ utcFormatted } \n ${ rruleStrings . join ( ' \n ' ) } `
}
Date formatting for all-day vs timed events
Exception Handling
Cancelled Instances
Individual occurrences can be cancelled without affecting the rest of the series:
// From recurrence.ts:164-179
for ( const instanceDate of instances ) {
const matchingException = masterExceptions . find (( exc ) =>
instanceMatchesException (
instanceDate ,
exc . recurringEventId === master . id ? exc . originalStartTime : undefined ,
isAllDay
)
)
if ( matchingException ) {
if ( matchingException . status === 'cancelled' ) continue // Skip cancelled
expanded . push ({
... matchingException ,
isVirtual: false ,
originalMasterId: master . id ,
})
}
}
Cancelled instances are identified by status: 'cancelled' and excluded from the calendar.
Modified Instances
An exception can modify specific details of an occurrence:
Time Change : Rescheduled to different time
Title Change : Different event name
Location Change : Different venue
Duration Change : Shorter or longer
When an exception exists, it completely overrides the master event for that specific date.
Exception Matching
// From recurrence.ts:75-90
function instanceMatchesException (
instanceDate : Date ,
exceptionOriginalStart : EventDateTime | undefined ,
isAllDay : boolean
) : boolean {
if ( ! exceptionOriginalStart ) return false
if ( isAllDay ) {
return exceptionOriginalStart . date === formatDateString ( instanceDate )
}
const exceptionTime = exceptionOriginalStart . dateTime
? new Date ( exceptionOriginalStart . dateTime ). getTime ()
: 0
return Math . abs ( instanceDate . getTime () - exceptionTime ) < 1000 // 1 second tolerance
}
Exceptions are matched by comparing the originalStartTime field with the generated instance date.
Virtual Instances
Generated instances are marked as “virtual”:
// From recurrence.ts:180-192
else {
const endDate = new Date ( instanceDate . getTime () + durationMs )
expanded . push ({
... master ,
id: ` ${ master . id } _ ${ instanceDate . getTime () } ` , // Unique ID
start: formatDateTime ( instanceDate , isAllDay , timeZone ),
end: formatDateTime ( endDate , isAllDay , master . end . timeZone ),
recurrence: undefined , // Remove RRULE from instances
recurringEventId: master . id , // Link to master
isVirtual: true , // Mark as virtual
originalMasterId: master . id ,
})
}
Virtual instances have synthetic IDs like master_id_timestamp and are generated on-the-fly rather than stored in the database.
Event Duration Calculation
Each instance uses the master event’s duration:
// From recurrence.ts:51-59
function getEventDurationMs ( event : CalendarEvent ) : number {
const startMs = event . start . dateTime
? new Date ( event . start . dateTime ). getTime ()
: new Date (( event . start . date ?? '1970-01-01' ) + 'T00:00:00' ). getTime ()
const endMs = event . end . dateTime
? new Date ( event . end . dateTime ). getTime ()
: new Date (( event . end . date ?? '1970-01-01' ) + 'T00:00:00' ). getTime ()
return endMs - startMs
}
The duration is added to each instance’s start time to calculate its end time.
Expansion Caching
Cache Key Generation
// From recurrence.ts:16-25
function computeCacheKey (
masters : CalendarEvent [],
exceptions : CalendarEvent [],
rangeStart : Date ,
rangeEnd : Date
) : string {
const masterIds = masters . map (( m ) => ` ${ m . id } : ${ m . updated } ` ). sort (). join ( ',' )
const exceptionIds = exceptions . map (( e ) => ` ${ e . id } : ${ e . updated } ` ). sort (). join ( ',' )
return ` ${ masterIds } | ${ exceptionIds } | ${ rangeStart . getTime () } | ${ rangeEnd . getTime () } `
}
Cache keys include:
All master event IDs and update timestamps
All exception event IDs and update timestamps
Date range (start and end)
This ensures the cache is invalidated when any event changes.
Instant Rendering Cached expansions return immediately without recalculation
View Navigation Switching between views reuses cached results
Memory Efficient Only one expansion cached at a time
Smart Invalidation Cache cleared when events or date range changes
Merging with Regular Events
Expanded recurring events are merged with regular events:
// From recurrence.ts:200-228
export function mergeEventsWithExpanded (
regularEvents : CalendarEvent [],
expandedEvents : ExpandedEvent []
) : ExpandedEvent [] {
const merged : ExpandedEvent [] = []
const addedIds = new Set < string >()
for ( const event of regularEvents ) {
if ( ! addedIds . has ( event . id )) {
merged . push ({ ... event , isVirtual: false })
addedIds . add ( event . id )
}
}
for ( const event of expandedEvents ) {
if ( ! addedIds . has ( event . id )) {
merged . push ( event )
addedIds . add ( event . id )
}
}
return merged . sort (( a , b ) => {
const toTime = ( e : ExpandedEvent ) =>
e . start . dateTime
? new Date ( e . start . dateTime ). getTime ()
: new Date (( e . start . date ?? '1970-01-01' ) + 'T00:00:00' ). getTime ()
return toTime ( a ) - toTime ( b )
})
}
The final result is sorted chronologically.
Usage in Calendar Views
Complete Workflow
// From recurrence.ts:230-239
export function getExpandedEvents (
events : CalendarEvent [],
masters : CalendarEvent [],
exceptions : CalendarEvent [],
rangeStart : Date ,
rangeEnd : Date
) : ExpandedEvent [] {
const expanded = expandRecurringEvents ( masters , exceptions , rangeStart , rangeEnd )
return mergeEventsWithExpanded ( events , expanded )
}
Load from Database : Fetch events, masters, and exceptions from IndexedDB
Expand Masters : Generate instances for each master within the date range
Apply Exceptions : Replace instances with exceptions where they exist
Merge with Regular Events : Combine expanded instances with one-time events
Sort Chronologically : Order all events by start time
Render : Display in calendar UI
Time Zone Handling
All-Day Events
All-day recurring events use date-only format:
if ( isAllDay ) {
return { date: formatDateString ( date ) }
}
No time zone conversion needed.
Timed Events
Timed events preserve the original time zone:
// From recurrence.ts:35-49
function formatDateTimeForTimezone ( date : Date , timeZone : string ) : string {
const parts = new Intl . DateTimeFormat ( 'en-US' , {
timeZone ,
year: 'numeric' ,
month: '2-digit' ,
day: '2-digit' ,
hour: '2-digit' ,
minute: '2-digit' ,
second: '2-digit' ,
hourCycle: 'h23' ,
}). formatToParts ( date )
const get = ( type : string ) => parts . find (( p ) => p . type === type )?. value || ''
return ` ${ get ( 'year' ) } - ${ get ( 'month' ) } - ${ get ( 'day' ) } T ${ get ( 'hour' ) } : ${ get ( 'minute' ) } : ${ get ( 'second' ) } `
}
This ensures recurring events respect their original time zone, even when expanded.
RRULE Library Integration
Parsing RRULE Strings
// From recurrence.ts:27-33
function parseRRuleString ( rruleString : string , tzid ?: string ) : RRuleSet | RRule | null {
try {
return rrulestr ( rruleString , { forceset: true , tzid })
} catch {
return null
}
}
The rrule library handles complex parsing and validation.
Instance Generation
// From recurrence.ts:157-162
let instances : Date []
try {
instances = rruleSet . between ( rangeStart , rangeEnd , true )
} catch {
continue
}
The library’s between() method efficiently generates instances within the date range.
Chronos uses the rrule library, which is the standard JavaScript implementation of the iCalendar RRULE specification.
Best Practices
Limit Date Ranges Expand only the visible date range to minimize computation
Cache Results Reuse expansion results when possible
Handle Errors Gracefully skip events with invalid RRULEs
Test Edge Cases Validate complex patterns like leap years and DST transitions