github

ICalendar and RRule Voodoo

In my day job I work as a backend developer, and I've dealt with enough cron jobs to know that scheduling is a pain. I mean cron is okay, it's syntax is simple enough, but it's not very flexible. The whole thing works until a project manager comes and asks for a thing to run every 3rd Tuesday of the month, or every 2nd Friday of the month, or every 3rd Friday of the month except when it's a full moon. You see where I am going with this.

This post is about how I used ICalendar and RRule to make scheduling tolerable. I won't go into implementation details, but I'll try to explain how ICalendar and RRule work and how you can use them to make your life easier.

ICalendar and RRule

ICalendar is a RFC 5545 standard for universal calendar data exchange. It's a text based format that allows you to describe events, to-dos, journal entries etc. RRule is a part of the ICalendar standard that allows you to describe recurring events. It's a string that describes the rules of a recurring event. The whole thing is disgustingly verbose, hard to read and even harder to write by hand. But it's a standard and it works. Thankfully, people much smarter than me have written libraries to handle this format.

In this post I'll try my best to explain the dark magic that is ICalendar format and the even darker magic that is RRule, also known as Recurrence Rule. All of my examples will be using the Biweekly library, which is a Java library for parsing and generating iCalendar files.

I'll keep it relatively surface level, focusing how to use this standard to create a scheduler for your backend application.

ICalendar, VEvent

ICalendar files are simple plain text files, they consist of components and properties and structured similarly to XML. Much like in XML, components can have properties and other components as children.

Check this example of a partial ICal file that I exported from Google Calendar:

BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:My Calendar
X-WR-TIMEZONE:UTC
BEGIN:VTIMEZONE
TZID:Europe/Istanbul
X-LIC-LOCATION:Europe/Istanbul
BEGIN:STANDARD
TZOFFSETFROM:+0300
TZOFFSETTO:+0300
TZNAME:GMT+3
DTSTART:19700101T000000
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
...
END:VEVENT
...
END:VCALENDAR

Woah, that's a lot of stuff, thankfully we don't need to worry about most of it. Unless you are writing a calendar application, you won't need to worry about most of the properties.

The important part for us is the 'VEVENT' component. This is where you describe your event. Here is a simple example of a 'VEVENT' component:

BEGIN:VEVENT
DTSTART;TZID=Europe/Istanbul:20241121T200000
DTEND;TZID=Europe/Istanbul:20241123T193000
RRULE:FREQ=WEEKLY;BYDAY=TH
DTSTAMP:20250120T065317Z
UID:121t55q4t6r0ug2tjhoeh824qv@google.com
CREATED:20241226T072821Z
LAST-MODIFIED:20241230T082255Z
SEQUENCE:1
STATUS:CONFIRMED
SUMMARY:Event Name
TRANSP:OPAQUE
END:VEVENT

Looks simple enough, right? We have a start date 'DTSTART', an end date 'DTEND', a recurrence rule 'RRULE', a summary 'SUMMARY' and some other properties that we don't care about. DTSTART and DTEND are pretty self explanatory, they are the start and end dates of the event. SUMMARY is the name of the event. RRULE is the recurrence rule, which is what we are interested in.

RRule

RRule is the voodoo magic that allows you to describe recurring events. It is a string that describes the recurrence rule of an event. It's a string that looks like this:

FREQ=WEEKLY;BYDAY=TH

This rule describes an event that occurs weekly on Thursdays. The 'FREQ' part is the frequency of the event, in this case it's weekly. The 'BYDAY' part is the day of the week that the event occurs. So, this rule describes an event that occurs every Thursday, event's duration is the time between 'DTSTART' and 'DTEND'. Simple enough.

Here is a more complex example where we want an event to run biweekly on Fridays and Mondays at 8:00 PM:

RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=MO;BYDAY=MO,FR;BYHOUR=20;BYMINUTE=0;BYSECOND=0

This rule describes an event that occurs weekly, every two weeks, on Mondays and Fridays at 8:00 PM. The 'INTERVAL' part is the interval between events, in this case it's 2 weeks. The 'WKST' part is the week start day, in this case it's Monday. The 'BYHOUR', 'BYMINUTE' and 'BYSECOND' parts are the time of the event.

I suggest playing around with jkbrzt's RRule demo here, it's a great tool to understand how RRule works.

Biweekly

Biweekly is a Java library that allows you to parse and generate ICalendar files. It also has a nice API for reading and writing RRule strings. I originally used ICal4j library, but it is not that good, has very apparent bugs.

So, in my case, I used Biweekly to read a calendar file put together by the product team, parse the 'VEVENT' components and generate a schedule for events. Doing it this way ensures that there is very little friction between the product team and the backend team, as they can just put together a calendar file and the backend can read it and generate a schedule.

If you want to follow along I suggest creating a calendar using google calendar, export it and it will give you an ICalendar file.

First, parse the ICalendar file from a byte stream.

private ICalendar parse(byte[] content) throws IOException {
        return Biweekly.parse(new ByteArrayInputStream(content)).first();
}

Biweekly.parse method returns a list of ICalendar objects, but in our case we only have one calendar in the file, so we just take the first one.

First, we need to collect the timezone information from the calendar file, as we need it to parse the dates.

TimezoneInfo timeZoneInfo = calendar.getTimezoneInfo();
TimeZone defaultTimeZone = timeZoneInfo.getTimezones().stream()
        .findFirst()
        .orElseThrow(IllegalStateException::new)
        .getTimeZone();

Next, we need to iterate through the events.

List<VEvent> events = calendar.getEvents()
for (VEvent event : events) {
    ...
}

Now we need to determine the correct timezone for the event. Timezone information may vary between events, so we need to check if the event has a timezone information. If it doesn't have a timezone information, we use the default timezone we collected earlier.

DateStart dtStart = event.getDateStart();
TimeZone timeZone;
if(timeZoneInfo.isFloating(dtStart)) {
    timeZone = defaultTimeZone;
} else {
    TimezoneAssignment dtStartTimeZone = timeZoneInfo.getTimezone(dtStart);
    timeZone = (dtStartTimeZone == null) ? defaultTimeZone : dtStartTimeZone.getTimeZone();
}

Now we collect the event iterator:

DateIterator dateIterator = event.getDateIterator(timeZone);

If you know the time of the previous event, you may want to advance the iterator to the next event after that time.

dateIterator.advanceTo(searchDate);

Doing this will make sure that you don't generate events that have already passed.

Finally, we can iterate through the events and generate the schedule.

if (dateIterator.hasNext()) {
    Date next = dateIterator.next();
    // Do something with the event
}

Next thing to do would be saving the event to a database or sending it to a message queue. I won't go into that here.

And that's it. You can now generate a schedule from an ICalendar file. You can also generate ICalendar files using Biweekly, but I won't go into that here.