The Nylas Engineering Blog

The deceptively complex world of RRULEs

How to work with repeating calendar events,
from RFC-5545 and beyond

Daily meetings, birthdays, chores, and personal reminders. These are all common types of calendar events which repeat on a set schedule, and modern calendars applications easily support creating them.

example event

But underneath this simple “Repeat” checkbox is a surprising amount of complexity resulting from years of legacy standards with backwards compatibility. This post is about what happens when an RFC meets the real world, including implementation tips for developers who want to dive in and work with RRULEs. We’ll also discuss specifically how the Nylas platform surfaces repeating events, and the trade-offs we made in designing that system.

Repeat as necessary

Working with repeating events is important for a few different kinds of apps. If you’re building a calendar UI, you obviously want to make sure the events you show match what the user sees at the source. However, any kind of scheduling app needs to display events in order to accurately show a user’s availability.

There are two ways to work with repeating events:

  1. Take a single event and the information for it repeats, and generate all the occurrences of that event, or;
  2. Use an API that expands the occurrences for you, and treat them more like standalone events.

We’ll cover both of these, starting from the ground up - a single event which repeats.

The RRULE

The key to any repeating event is the recurrence rule, a way of describing how that event repeats. These are also referred to as RRULEs.

Recurrence rules are primarily defined in RFC 2445, section 4.8.5.4, which also describes the full “iCalendar” spec for .ics files. Calendar providers like iCloud and Google Calendar provide downloads of these files for apps.

The RRULE format encapsulates a repeating pattern, such as “every Thursday”. Combined with the event’s starting time, you can figure out exactly when each future occurrence of the event should begin. Note that the RRULE itself doesn’t encode the starting times.

A simple RRULE for an event which repeats every day looks like this:

RRULE:FREQ=DAILY

The RRULE syntax can also specify a total number of instances, or an end time:

RRULE:FREQ=DAILY;COUNT=10;
RRULE:FREQ=DAILY;UNTIL=20150919T063000Z

We can choose one or more days of the week to repeat on, and even alternate between specific days:

RRULE:FREQ=WEEKLY;BYDAY=TH             # every Thursday
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR       # every Mon, Wed and Fri
RRULE:FREQ=WEEKLY;BYDAY=TU;INTERVAL=2  # every other Tuesday

RRULE syntax goes far beyond these simple examples, including support for day of month (e.g. the third Thursday in November), week numbers, repeating on the same numerical day of a month, and plenty more. If you want to experiment more with specifying RRULEs, the rrule.js demo is a superb place to do so.

The python-dateutil module in Python has a parser which makes it easier to work with RRULEs:

from dateutil.rrule import rrulestr
from datetime import datetime

rule_string = "RRULE:FREQ=WEEKLY;BYDAY=TH"

# Use rrulestr to parse a RFC-formatted string
# Without a start time, it assumes the rule starts from now.
rule = rrulestr(rule_string)

# Get the next occurrence
rule.after(datetime.now())

# Get all the occurrences in December
rule.between(after=datetime(2015,12,1), before=datetime(2015,12,31))

Given a calendar event with an RRULE property, we can figure out all the times that event actually happens. This is usually fairly straightforward, but what happens when the clocks go backwards?

The lunch bell always rings at noon

When a timezone transitions into or out of daylight savings, repeating events are expected to remain at the same local time. For example, lunch is always scheduled for 12:30, even if the underlying UTC time is an hour earlier or later, as Google Calendar shows here:

Repeating across DST

This can cause its share of headaches, especially when you represent datetimes globally in UTC. One alternative way to implement this when using dateutil.rrule is to normalize with an event’s timezone throughout, which ensures that daylight savings is accounted for when we convert the final event times back to UTC.

Here’s an example where we expand the recurrence rule for an event that spans a DST change (in this case, the switch from PDT to PST on 11/1/15). We’re using the arrow Python library which makes working with datetimes a bit easier:

import arrow
from dateutil.rrule import rrulestr

rule_string = "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"

start = arrow.get(2015,07,06,12,30,00,0,'US/Pacific')
rule = rrulestr(rule_string, dtstart=start.datetime)

# When expanding the rule, we get 12:30pm US/Pacific
times = rule.between(
    after=arrow.get(2015,10,30,00,00,01,0,'US/Pacific'),
    before=arrow.get(2015,11,2,23,59,59,0,'US/Pacific'),
    inc=True)

# When converted, 12:30pm on 10/30 becomes 19:30 UTC, and
# 12:30pm on 11/2 becomes 20:30 UTC due to the daylight change on 11/1.
[arrow.get(t).to('UTC') for t in times]

# >>> [<Arrow [2015-10-30T19:30:00+00:00]>,
#      <Arrow [2015-11-02T20:30:00+00:00]>]

Exceptions to the rule

Given an RRULE, we can figure out when a specific repeating event is going to occur. But what about one-off changes to the event? This happens often when repeating meetings are moved for one day, or their agenda/location is changed, or they are cancelled altogether.

Cancellations

Cancellations to a specific repeating instance are fairly straightforward: the iCalendar spec includes support for exception dates when repeating events does not occur on a specific cycle. For example, you may cancel a daily meeting on Christmas Day. These exceptions are expressed in the EXDATE field:

RRULE:FREQ=DAILY
EXDATE:20151225T173000Z

You’ll notice the EXDATE is in fact a datetime (not just a date) represented in ISO 8601. When dealing with repeated events, this means we need to keep careful track of the start time of the original event, and use that to determine at what time the event should repeat. An easier way to identify these individual repetitions is by their full UTC datetime. We also conveniently use the same identifier when specifying repetitions which don’t exist.

In dateutil, to expand a recurrence rule with an EXDATE we need to convert our singular rrule into a rruleset:

from datetime import datetime
from dateutil.rrule import rruleset

# Create a daily recurrence starting on 12/20 at 17:30
daily = rrulestr("RRULE:FREQ=DAILY",
                 dtstart=datetime(2015,12,20,17,30,00))
rules = rruleset()
rules.rrule(daily)       # Add the daily RRULE to the set

# Exclude 12/25 at 17:30
excl_date = datetime(2015,12,25,17,30,00)
rules.exdate(excl_date)  # Add the excluded date to the set

rules.between(datetime(2015,12,24), datetime(2015,12,27))

# >>> [datetime.datetime(2015, 12, 24, 17, 30),
#      datetime.datetime(2015, 12, 26, 17, 30)]

You may have noticed that the rruleset.exdate method takes a datetime instance rather than an EXDATE string. This is a bit annoying, and means you’ll need to parse the EXDATE string into datetimes yourself. Here’s an example in the Nylas Sync Engine for how to do that.

Modifying events

When a change is made to a specific instance of a repeating event, we get out of RFC territory and into something more like a Calendar Wild West. The seemingly logical thing to do is to cancel the instance (using EXDATE) and create a brand new one-off event with the changed information.

From the point of view of the original event, this looks identical to a real cancellation. (In the following example, fields are cherry-picked from the full event.)

Original event:

BEGIN:VEVENT
RRULE:FREQ=DAILY;COUNT=5
SUMMARY:Treasure Hunting
DTSTART;TZID=America/Los_Angeles:20150706T120000
DTEND;TZID=America/Los_Angeles:20150706T130000
END:VEVENT

With one event in the series modified:

BEGIN:VEVENT
RRULE:FREQ=DAILY;COUNT=5
EXDATE;TZID=America/Los_Angeles:20150707T120000
SUMMARY:Treasure Hunting
DTSTART;TZID=America/Los_Angeles:20150706T120000
DTEND;TZID=America/Los_Angeles:20150706T130000
END:VEVENT

BEGIN:VEVENT
SUMMARY:Treasure Hunting
LOCATION:The other island
DTSTART;TZID=America/Los_Angeles:20150707T120000
DTEND;TZID=America/Los_Angeles:20150707T130000
END:VEVENT

By disconnecting the modified event from its parent series, we run into a misleading situation. It looks like the parent isn’t repeating on that specific day, but it actually still is! If we delete or change the parent event, the modified exception event will stick around regardless.

Instead, the prevailing approach is to add metadata to the modified event that points back at its parent, and not update the EXDATE:

BEGIN:VEVENT
UID:0000001
RRULE:FREQ=DAILY;COUNT=5
SUMMARY:Treasure Hunting
DTSTART;TZID=America/Los_Angeles:20150706T120000
DTEND;TZID=America/Los_Angeles:20150706T130000
END:VEVENT

BEGIN:VEVENT
UID:0000001
SUMMARY:Treasure Hunting
LOCATION:The other island
DTSTART;TZID=America/Los_Angeles:20150707T120000
DTEND;TZID=America/Los_Angeles:20150707T130000
RECURRENCE-ID;TZID=America/Los_Angeles:20150707T120000
END:VEVENT

If the RECURRENCE-ID is the original start time of the modified event, and the UID on both events is the same, we can connect the dots and figure out that the exception event replaces an instance in the series which was originally to occur at that time.

Let’s look at this in practice with an example that works directly with the Google Calendar API.

Google Calendar

The Google Calendar docs say that recurrence information for an event is available via the recurrence field. This contains the RRULE and other recurrence information for an event (in practice, almost always just the RRULE).

As we’ve discussed previously, this isn’t sufficient to figure out exactly what’s going on with a repeating event due to cancellations and exceptions.

Cancellations

Google Calendar exposes cancelled events as separate, individual events alongside the original repeating events. By default, the API hides cancellations, but this can be disabled by including showDeleted=True as a URL parameter. This is by design because the Google Calendar API does not update the EXDATE field when an event is cancelled.

A cancelled event is returned here as an abbreviated event object, without fields such as the title and location:

{
  "id": "uid1234_20150707T150000Z",
  "status": "cancelled",
  "recurringEventId": "uid1234",
  "originalStartTime": {
    "dateTime": "2015-07-07T08:00:00-07:00"
  }
}

There are several clues to connect this back to the parent event:

  • recurringEventId is actually the parent event id
  • originalStartTime is the originally scheduled start time for this instance
  • the event’s id is a combination of these two, with the time in UTC

Modifications

Modifications to recurring events via the Google Calendar API look very similar to cancellations, but contain the full event information (title, location, etc). Again, the EXDATE does not change.

Expanding RRULEs with Google Calendar

In order to find all the occurrences of a repeating event, including cancellations and one-off modifications, we must expand the “master” RRULE and iterate through “child” events which are linked back to the master via their IDs. Below is a short example of how this works, given an underlying Event object which contains the Google Calendar data. (A longer working example for this can be found in the open source Nylas Sync Engine.)

from dateutil.rrule import rrulestr

event = Event.get(uid)
event.recurrence = rrulestr(event.rrule, dtstart=event.start)
events = []

# Find events which specifically override the base event
for child in Event.find(recurringEventId=event.id):
    events.append(child)

existing_starts = [e.start for e in events]

# Iterate through all possible future times and create temporary
# copies of the parent event if an exception does not exist
start_times = event.recurrence.between(start, end)
for start in start_times:
    if start not in existing_starts:   # It wasn't deleted or modified
        instance = event.copy()
        instance.start = start
        instance.end = start + event.duration
        instance.uid = "{}_{}".format(event.uid, start.isoformat())
        events.append(instance)

Note the above example does not handle timezones, which are still very important. Keeping track of the event timezone is critical when attempting to match a child event based purely on the intended original start date, particularly as repeating events cross daylight savings boundaries. Google Calendar provides timezones in start, end, and originalStartTime properties.

One major downside of working with events this way is that a seemingly-simple query like “get all events on my calendar between these times” is substantially harder to write. Instead of retrieving all events which start within the supplied times, you need need to check if any previously defined repeating events will occur inside that window.

The Nylas Platform Way

The Nylas Platform Events API makes it simple to generate an accurate representation of a user’s calendar. The original recurrence information is available in RRULE format as recurrence on an event, but you can also simply add expand_recurring=True as a URL parameter to automatically expand all recurring events. This is a quick way to focus on building features, rather than figure out the details of repetitions, cancellations and exceptions yourself.

What about Microsoft Exchange?

This post focused on the published iCalendar standard. Unfortunately, the world of Microsoft Exchange is totally different, and the underlying Exchange ActiveSync protocol expresses recurrences and exceptions in a completely different format via WBXML like this:

<Recurrence>
  <Type>3</Type>
  <Interval>1</Interval>
  <WeekOfMonth>4</WeekOfMonth>
  <DayOfWeek>32</DayOfWeek>
  <CalendarType>0</CalendarType>
</Recurrence>

Further details on this and the countless related edge cases are left as a topic for a future post.

Get started today

One of the goals of the Nylas Platform is to abstract all the complexity described above into a simple, clean and modern API, so you can focus on building great apps rather than fighting old protocols. It includes full support for Google Calendar, Exchange, and more, via a universal API. You may even be using some Nylas-powered apps without realizing it!

If you enjoyed this post, please join our newsletter for occasional updates about the Nylas Platform. Thanks for reading! :)

 

Find this stuff interesting?

Nylas is hiring! Join a small team of engineers building the future of email.